In Progress
Unit 1, Lesson 1
In Progress

Comparable

Video transcript & code

Today we're going to continue our work on a class that represents measurements in feet. We spent the last few episodes giving this class the ability to compare itself for equality with other objects. We talked about identity, equivalence, case equality, and hash equality.

class Feet
  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def ==(other)
    other.is_a?(Feet) && magnitude == other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  alias_method :eql?, :==
end

But there's more to comparison than just equality. One of the properties that we take for granted on core types like strings and numbers that we can compare two values to see which is greater.

3 > 4                           # => false

So far, our Feet objects lack this fundamental ability. Because operators in Ruby are really methods, comparing two Feet objects results in a NoMethodError.

require "./feet"

Feet.new(3) > Feet.new(4)       # => 
# ~> -:3:in `<main>': undefined method `>' for #<Feet:0x0000000132ea78 @magnitude=3.0> (NoMethodError)

We can begin to address this omission by defining the requisite operator methods, such as greater-than. We can crib the implementation from the equivalence operator; the only change we need to make is in which operator to apply to the magnitudes of the two objects.

class Feet
  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def ==(other)
    other.is_a?(Feet) && magnitude == other.magnitude
  end

  def >(other)
    other.is_a?(Feet) && magnitude > other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  alias_method :eql?, :==
end

That takes care of the greater-than case. But there are a lot of comparison operators. Besides greater-than, there's greater-than-or-equal, less-than, and less-than-or-equal. If we continue onwards like this, we're going to be writing a lot of tedious, repetitive code.

There's also one other comparison operator we need to implement: the spaceship operator. This operator isn't as well known as the others, but it's just as important, if not more so.

Before we go any further, let's quickly review how the spaceship operator works on core classes.

When the left-hand value is lesser than the right-hand value, it returns -1. When the left-hand is greater than the right-hand, it returns 1. And when the two values are equivalent, it returns 0. In effect, it wraps the functions of all the other comparison operators into one super-operator.

3 <=> 4                         # => -1
4 <=> 3                         # => 1
4 <=> 4                         # => 0

Why is the spaceship so important? It's easiest to explain by demonstration. Let's throw some Feet objects into an array, and then try to sort them.

require "./feet2"

[Feet.new(8), Feet.new(6), Feet.new(7)].sort
# => 
# ~> -:3:in `sort': comparison of Feet with Feet failed (ArgumentError)
# ~>    from -:3:in `<main>'

We get the argument error "comparison of Feet with Feet failed".

Now let's implement the spaceship operator. We'll do it the same way we implemented equivalence and greater-than, but this time using the spaceship operator on the Feet magnitudes.

class Feet
  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def ==(other)
    other.is_a?(Feet) && magnitude == other.magnitude
  end

  def >(other)
    other.is_a?(Feet) && magnitude > other.magnitude
  end

  def <=>(other)
    other.is_a?(Feet) && magnitude <=> other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  alias_method :eql?, :==
end

With that done, we'll try again to sort the array of Feet.

require "./feet3"

[Feet.new(8), Feet.new(6), Feet.new(7)].sort
# => [#<Feet:0x00000000acb840 @magnitude=6.0>,
#     #<Feet:0x00000000acb818 @magnitude=7.0>,
#     #<Feet:0x00000000acb8b8 @magnitude=8.0>]

This time, the array is successfully sorted. It should be clear now why the spaceship operator matters: it's the operator Ruby uses by default to compare two objects when sorting them.

But that's not the only reason the spaceship is important. As it turns out, now that we've implemented spaceship, we don't need to bother with all the other comparison operators. We can simply include the core Ruby module Comparable. So long as we implement the spaceship, Comparable gives us all the other comparisons for free.

So we can get rid of our handwritten greater-than operator now. Not only that, but since the spaceship can also be used to determine equivalence, we don't need our own equivalence operator anymore either. The Comparable module provides us with one.

class Feet
  include Comparable
  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def <=>(other)
    other.is_a?(Feet) && magnitude <=> other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  alias_method :eql?, :==
end

Feet objects now have access to lesser-than, lesser-than-or-equal, greater-than, and greater-than-or equal operators. As well as equivalence.

require "./feet4"
Feet.new(3) < Feet.new(4)       # => true
Feet.new(3) <= Feet.new(4)      # => true
Feet.new(3) > Feet.new(4)       # => false
Feet.new(3) >= Feet.new(4)      # => false
Feet.new(3) == Feet.new(4)      # => false

In edition to these, Comparable provides one other handy method: between?. This method can tell us if an object falls between two other objects in value.

require "./feet4"
Feet.new(4).between?(Feet.new(3), Feet.new(5))
# => true

Today we've added a total of one line to our Feet class, but we've gained five essential operators and a bonus method. That seems like a good deal to me. Happy hacking!

Responses