home blog about

Ruby Comparable and Enumerable

Context

Even with nearly a decade of experience writing Ruby, I still get surprised by the language’s versatility.

I recently stumbled upon a problem that required me to work with (annual) quarters. Mostly, comparing them and iterating over them.

Achieving that was a breeze thanks to Ruby’s Comparable and Enumerator modules.

Here is the simplified Quarter class we’ll work with for this example:


class Quarter

  attr_reader :quarter, :year

  # @param quarter [Integer] The quarter number (1-4).
  # @param year    [Integer] The year.
  def initialize(quarter = nil, year = nil)
    @quarter = quarter.to_i
    @year = year.to_i
  end
  
end

Comparable

Comparable is a module you can include in your classes to provide comparison methods (<, <=, ==, >=, >, between?, clamp).

According to the documentation:

The Comparable mixin is used by classes whose objects may be ordered. The class must define the <=> operator, which compares the receiver against another object

https://ruby-doc.org/3.3.3/Comparable.html

Let’s implement <=> and include the Comparable module in our Quarter class:

class Quarter
  include Comparable

  attr_reader :quarter, :year

  def initialize(quarter = nil, year = nil)
    @quarter = quarter.to_i
    @year = year.to_i
  end

  def <=>(other)
    [year, quarter] <=> [other.year, other.quarter]
  end
end

That’s all! Now, we can compare quarters:

>> q1 = Quarter.new(2024, 1)
>> q2 = Quarter.new(2024, 2)
>> q3 = Quarter.new(2024, 3)
>> q4 = Quarter.new(2024, 4)

>> q1 < q2
#> true

>> q2.between?(q1, q3)
#> true

>> q4.clamp(q1, q3)
#> q3

Enumerable

https://ruby-doc.org/3.3.3/Enumerable.html

Let’s say we want to iterate over quarters without initializing them one by one.
We can do this by implementing #succ, which returns the quarter following the current one. Or in other words: quarter + 1.


class Quarter

  # ...
  
  # Adds a specified number of quarters to the Quarter object.
  # This method calculates the new quarter and year after adding the specified count of quarters.
  # It handles year transition when the addition spans over multiple years.
  #
  # @param count [Integer] The number of quarters to add to the current Quarter object.
  # @return [Quarter] A new Quarter object representing the time after adding the specified quarters.
  def +(count)
    total_quarters = quarter + count - 1
    Quarter.new(year + total_quarters / 4, total_quarters % 4 + 1)
  end

  # Advances the Quarter object to the next quarter.
  def succ
    self + 1
  end
end

Now, we can use a range of quarters:

>> first = Quarter.new(2024, 1)
>> last = Quarter.new(2024, 4)
>> range = (first..last)

>> range.find{ |q| q.quarter == 4 }
#> #<Quarter:0x000000011d137c98 @quarter=4, @year=2024>

>>  range.group_by(&:year)
#> {
#     2024=>
#       [#<Quarter:0x000000011d4d5030 @quarter=1, @year=2024>, #<Quarter:0x000000011d4fc9f0 @quarter=2, @year=2024>, ... ]
#     2025=>
#       [#<Quarter:0x000000011d4fc888 @quarter=1, @year=2025>, #<Quarter:0x000000011d4fc7e8 @quarter=2, @year=2025>, ...]
#  }

Wrapping up

So, it turns out that Ruby’s Comparable and Enumerable modules are powerful tools that can help you build expressive and readable code with minimal effort.
Check out their documentation:

For reference, here is the whole Quarter class made compatible with Comparable and Enumerable:

class Quarter
  include Comparable

  attr_reader :quarter, :year

  def initialize(year = nil, quarter = nil)
    @quarter = quarter.to_i
    @year = year.to_i
  end

  def <=>(other)
    [@year, @quarter] <=> [other.year, other.quarter]
  end

  def +(count)
    total_quarters = @quarter + count - 1
    Quarter.new(@year + total_quarters / 4, total_quarters % 4 + 1)
  end

  def succ
    self + 1
  end
end

Please reach out if you have comments or questions: