Ruby allows you to create ranges for custom objects by implementing just a
couple standard methods: <=>
and succ
.
Problem
You need to generate an array of months between two arbitrary dates. Normally this sort of thing can be solved with a range but date ranges give you an entry for every day, not every month.
You could generate all the days and then filter them such that you only keep one
per month but that forces you to create a lot more data than you actually
need. It also leaves you with an array of Date
objects which don’t really
represent the concept you are trying to work with.
(date1..date2).select { |date| date.day == 1 }
Domain Object
Instead of working with Date
objects that represent the concept of a day in
time, we might want to implement our own value object that represents the
concept of a month in time.
class Month
def initialize(months)
# months since Jan, 1BCE (there is no year zero)
# https://en.wikipedia.org/wiki/Year_zero
@months = months
end
end
The standard way to model time in software is to store a single counter since a set point in time (e.g. 24,267 months since 1BCE) rather than multiple counter like in English (e.g. 2022 years and 4 months since 1BCE). It makes implementing both math and domain operations much easier.
To make this object a bit nicer to work with, we might add some convenience methods like:
- A
.from_parts
class method as an alternate constructor #month
and#day
accessors to get human values- a custom
#inspect
to make it easier to read output from the console and tests
You can see a full implementation in this gist.
Becoming rangeable
Ruby can construct a range out of any object that implements the <=>
comparison operator. If you want that range to be iterable, you also need to
implement succ
to generate the next value.
class Month
# other methods...
def <=>(other)
months <=> other.months
end
def succ
self.class.new(months.succ)
end
end
Note that since we are using a single value internally, we get to just delegate the methods to the internal number.
Generating our array
Let’s see it in action!
Month.from_parts(2021, 10)..Month.from_parts(2022, 3)
=> [October 2021, November 2021, December 2021, January 2022, February 2022, March 2022]
By implementing just a couple methods, we are now able to generate a series of
months. Yay interfaces and polymorphism! As a bonus, we also get a nice value
object that will likely have more relevant methods for our domain than a regular
Date
would.