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.
Responses