In Progress
Unit 1, Lesson 21
In Progress

Equalizer

Video transcript & code

Over the last few weeks we've talked a lot about Value Objects. We've discussed how immutable value objects can reduce the possibility of mutation-related bugs in code. And we've looked at some libraries and gems that make it easier to build value objects.

Today let's build a value object to represent that classic computer science example, a point in 2D space.

We'll include Adamantium for immutability, as we saw in Episode #219.

There will be attributes: x, and y.

Of course we can initialize an object with x and y values.

Remember that in Ruby the equivalence operator takes into account only an object's identity by default. In order to compare two points for equivalence, we need to override the operator to use the object's state instead. Our version first checks the class of the other object, then compares x and y coordinates.

It seems like we should be done at this point. But as we learned in Episode #204, if there's a possibility we might use these objects as keys in a hash, or if we ever might store instances in a ruby Set, we need to define a #hash method that calculates using the object's value instead of its identity. We use an array to combine the hash values of x, y, and class into a single value.

We're almost done. But there's one more rule we need to satisfy in order to make this object behave correctly as a hash key or as an element in a set: along with the #hash method, we also need to define the hash equality, or #eql? method, in terms of the object's value. To do that, we alias it to the equivalence operator.

require "adamantium"

class Point
  include Adamantium
  attr_reader :x, :y

  def initialize(x, y)
    @x, @y = x, y
  end

  def ==(other)
    other.is_a?(self.class) && x == other.x && y == other.y
  end

  def hash
    [x, y, self.class].hash
  end

  alias_method :eql?, :==
end

This seems like an awful lot of work to get a well-behaved value object. If we're going to be writing a lot of value object classes, we don't want to have to repeat similar boilerplate for the object's equality methods every time.

Enter the equalizer gem, by Dan Kubb. Like Adamantium, it is one of the spin-offs of the ROM project.

Let's include Equalizer into our class. The way we do this might be a little surprising: instead of just including a module, we include a generated module parameterized with the methods we want to use to determine equality. In our case, those are the #x and #y methods.

We then proceed to remove the equivalence operator, the #hash method, and the hash equality operator. The reason we remove these is that the Equalizer module generates them for us, based on the arguments names we passed to the module constructor.

require "adamantium"
require "equalizer"

class Point
  include Adamantium
  include Equalizer.new(:x, :y)

  attr_reader :x, :y

  def initialize(x, y)
    @x, @y = x, y
  end
end

Let's check out what Equalizer has provided for us. First of all, let's experiment with seeing if two objects are equal or not.

require "./point2"

Point.new(23, 32) == Point.new(23, 32) # => true
Point.new(23, 32) == Point.new(23, 33) # => false

Now let's try storing a Point in a Set container. Then we'll ask the container if it contains a Point with the same value. Remember from Episode #204, that using Ruby's default identity-based hashing semantics, this would return false.

require "./point2"
require "set"

s = Set.new
s << Point.new(4, 5)
s.include?(Point.new(4, 5))     # => true

It returns true, showing that Equalizer has implemented value-based hashing for Point objects.

As a free gift, Equalizer gave us one more thing: a pretty #inspect method that neatly formats the values of the object's x and y attributes.

require "./point2"

puts Point.new(4, 5).inspect
# => nil
# >> #<Point x=4 y=5>

In general Ruby is a pretty low-ceremony language. But even in Ruby there are cases where some boilerplate code needs to be repeated over and over. Defining equality semantics for value objects is one of those cases. Thankfully, Ruby also has sufficient metaprogramming power to enable the creation of libraries like Equalizer, that can do all that busywork for us.

Happy hacking!

Responses