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.
There will be attributes:
Of course we can initialize an object with
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.
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
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
require "./point2" require "set" s = Set.new s << Point.new(4, 5) s.include?(Point.new(4, 5)) # => true
true, showing that
Equalizer has implemented value-based hashing for
As a free gift,
Equalizer gave us one more thing: a pretty
#inspect method that neatly formats the values of the object's
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.