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

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

You might also like