Using ruby Range with custom classes
… and check why 5600+ Rails engineers read also this
Using ruby Range with custom classes
I am a huge fan of Ruby classes, their API and overall design. It’s still sometimes that
something surprises me a little bit. I raise my eyebrow and need to find answers.
What surprised me this time was Range
class. But let’s start from the beginning (even though it is
a long digression from the main topic).
Ruby, gimme my Month
please. Would you? Kindly?
Every time I implement any kind of reporting functionality for our clients I wonder why
is there no Month
class. I mean, there is such concept as month. Why not make it a
class? I wondered how other languages deal with it and it turns out Java recently added
Month
class to its API.
I looked at its implementation, its methods and no… That’s not what I want.
To add more to the confusion I realized that there are two concepts here
- YearMonth - the concept of particular month in particular year like
January 2014
. That’s the thing that I need. - Month - the general concept of Month. Like
January
in general. Every January. Not just a specific one. This what you have in the Java API.
So to avoid confusion I decided to think about my little object that I have in mind (January 2014) as YearMonth
. If
you come up with a better name for it, leave me a comment. I honestly couldn’t come up with anything different and more
sophisticated. Maybe because English as second language… Anyway…
YearMonth
and what not…
I the domain of Reporting we often think in terms of Time periods. Our customers often would like to have
reporting per days, weeks, months, quarters etc. When someone tells me to create a report from January 2014 to
May 2014 with the accuracy of month, well… I would like to say in my code
YearMonth.new(2014, 1)..YearMonth.new(2014, 5)
. That’s how my OOP part of the brain thinks about the problem.
What are the clues telling us that despite having the variety of classes for operating on time
(like Date
, DateTime
, Time
and even ActiveSupport::TimeWithZone
) we still need more classes? I don’t know this
will convince you but here are my thoughts:
YearMonth
# Actual
Time.days_in_month(2014, 1)
Time.new(2014, 1).end_of_month
vs
# Imaginary
january2014 = YearMonth.new(2014, 1)
january2014.number_of_days
january2014.end_of
Year
Same goes for other:
Date.new(2000).leap?
Date.new(2000).beginning_of_year
vs
year2000 = Year.new(2000)
year.leap?
year.beginning_of
Week
Date.new(2001, 2, 3).cweek
Date.new(2001, 2, 3).cwyear
vs
week = Week.from_date(2001, 2, 3)
week.year
week.number
The pattern
Here is the pattern that I see. Whenever we want to do something related to a period of time such as
Year, Quarter, Month, Week we create an instance of moment (Time
, Date
) in time that
happens to belong to this period (such as first day or first second of year). Then we use this object to query it
about the attributes of the time period it belongs with methods such as #beginning_of_year
, #beginning_of_quarter
,
#beginning_of_month
, #beginning_of_week
.
So I think we are often missing the abstraction of time periods that we think about and that we work with. I understand that these methods are very useful when what we are doing depends on current time or current day or selected moment provided by the user. However in my case, when the users gives me an integer representing Year (2014) I would really like to create an instance of Year and operate on it. Operating on bunch of static methods or creating a Date (January 1st, 2014) to deal with Years does not taste me.
Even deeper digression
What does my boss say? 😉He says that knowing about things such as next and previous month is not the responsibility
of YearMonth
class but rather something above (conceptually higher) like a Calendar
. It’s not that May 2014
knows
that the next month in a year is June 2014
but rather the calendar knows about it. I find it an interesting point
of view. What do you think?
YearMonth
Ok, enough with the digressions. The main topic was using custom class with Range
. Let’s have an exemplary class.
class YearMonth < Struct.new(:year, :month)
def initialize(year, month)
raise ArgumentError unless Fixnum === year
raise ArgumentError unless Fixnum === month
raise ArgumentError unless year > 0
raise ArgumentError unless month >= 1 && month <= 12
super
end
def next
if month == 12
self.class.new(year+1, 1)
else
self.class.new(year, month+1)
end
end
alias_method :succ, :next
def beginning_of
Time.new(year, month, 1)
end
def end_of
beginning_of.end_of_month
end
private :year=, :month=
end
This was used as a Value Object attribute in my AR class:
class ReportingConfiguration < ActiveRecord::Base
composed_of :start,
class_name: YearMonth.name,
mapping: [ %w(start_year year), %w(start_month month) ]
composed_of :end,
class_name: YearMonth.name,
mapping: [ %w(end_year year), %w(end_month month) ]
def each_month
(self.start..self.end)
end
end
And it was all supposed to work but…
… bad value for range
YearMonth.new(2014, 1)..YearMonth.new(2014, 2)
# => ArgumentError: bad value for range
That certainly wasn’t something that I was expecting.
What do we use Range
for?
Let’s think a moment about it. What do we actually use the Range
class for? There are at least two usecases:
- iterating over the collection (without the need to create all its elements)
- checking whether another object is part of the
Range
(again, without the need to create all its elements)
For both of the usecases we need to add different methods to our custom (YearMonth
) class for it to be
compatible with Range
.
Iterating
range = YearMonth.new(2014, 1)..YearMonth.new(2014, 3)
# => #<struct YearMonth year=2014, month=1>..#<struct YearMonth year=2014, month=3>
range.each {|ym| puts ym.inspect }
# #<struct YearMonth year=2014, month=1>
# #<struct YearMonth year=2014, month=2>
# #<struct YearMonth year=2014, month=3>
Iterating requires you to implement #succ
method.
def next
if month == 12
self.class.new(year+1, 1)
else
self.class.new(year, month+1)
end
end
alias_method :succ, :next
That’s how our Range knows how to yield next element from the range collection.
But how does it know when to stop yielding next elements? When it creates the instance of YearMonth.new(2014, 3)
as a third element that is yielded how does it know that it is the last one?
Well that’s when the next usecase comes handy.
Inclusion
Checking the inclusion of values in Range require you to implement the <=>
operator. In other
words your class should be Comparable
. And that’s the thing I forgot about. And it actually makes sense because how
else would the Range
know when to stop without the ability to compare last generated element with the upper bound of
your Range?
class YearMonth
include Comparable
def <=>(other)
(year <=> other.year).nonzero? || month <=> other.month
end
end
If you are not familiar with <=>
operator here is a little reminder for you. It should return -1
, 0
or 1
depending on whether the compared objects is greater, equal to, or lower:
YearMonth.new(2014, 1) <=> YearMonth.new(2014, 3)
# => -1
YearMonth.new(2014, 1) <=> YearMonth.new(2014, 1)
# => 0
YearMonth.new(2014, 3) <=> YearMonth.new(2014, 1)
# => 1
If you have <=>
operator implemented and include Comparable
module into your class you get the behavior
of classic operators <
, <=
, ==
, >=
and >
for free:
YearMonth.new(2014, 3) > YearMonth.new(2014, 1)
# => true
YearMonth.new(2014, 1) >= YearMonth.new(2014, 1)
# => true
YearMonth.new(2015, 1) < YearMonth.new(2014, 3)
# => false
Doc
The Range
documentation explains it nicely:
Ranges can be constructed using any objects that can be compared using the <=>
operator. Methods that treat the
range as a sequence (#each
and methods inherited from Enumerable
) expect the begin object to implement a succ
method
to return the next object in sequence. The step
and include?
methods require the begin object to implement
succ
or to be numeric.
My Lesson
Somehow I expected that is the #succ
methods that is most important for the Range
to exist and work correctly.
Probably because I was so focused on the fact that ranges can iterate over elements.
It is however that the <=>
method in your own class is the most important factor. That’s because you can check whether
element is part of range without the ability to iterate over subsequent elements. But you can’t generate subsequent
elements without knowing which one is the last one (or whether you should start iterating at all).
All this can be summarized in a few examples:
# Range needs to know that 2 <= 1 is false
# so it doesn't start iterating
(2..1).each{|i| puts i} # no output
# Range needs to know that 1.succ gives 2
# 2.succ gives 3
# and 3 == 3 so we need to stop iterating
(1..3).each{|i| puts i}
# You can't iterate over classes that don't have #succ method
(1.0..2.0).each{|i| puts i}
# => TypeError: can't iterate from Float
1.0.succ
# => NoMethodError: undefined method `succ' for 1.0:Float
# But you can check for inclusion in Range
(1.0..2.0).include?(1.5)
=> true
So Range
will give always you the ability to check if something is in the range, but it only might give you the
ability to iterate.
Resources
- Range documentation
#nonzero?
- Back to basics: the mess we’ve made of our fundamental data types - not Ruby related but FYI, dates are more complicated then what usually like to think about them.
- Falsehoods programmers believe about time
- More falsehoods programmers believe about time; “wisdom of the crowd” edition
Time.days_in_month
composed_of
removed from Rails 4- Value objects and Aggregates in Rails
Simple YearMonth
implementation
class YearMonth < Struct.new(:year, :month)
include Comparable
def initialize(year, month)
raise ArgumentError unless Fixnum === year
raise ArgumentError unless Fixnum === month
raise ArgumentError unless year > 0
raise ArgumentError unless month >= 1 && month <= 12
super
end
def next
if month == 12
self.class.new(year+1, 1)
else
self.class.new(year, month+1)
end
end
alias_method :succ, :next
def <=>(other)
(year <=> other.year).nonzero? || month <=> other.month
end
def beginning_of
Time.new(year, month, 1)
end
def end_of
beginning_of.end_of_month
end
private :year=, :month=
end