Equality and value objects

The equality set

Three methods together decide what "the same value" means: == for a comparison a reader writes, eql? and hash for the lookup a Hash and a Set do. A class that defines only the first is equal in a test and different in a set:

require "set" class Money attr_reader :cents def initialize(cents) @cents = cents end def ==(other) other.is_a?(Money) && cents == other.cents end end p Money.new(100) == Money.new(100) p Money.new(100).eql?(Money.new(100)) p Set[Money.new(100), Money.new(100)].size

ruby eq.rb

true
false
2

The two values are equal and the set holds both of them, because the set never calls ==. That is the bug the next two sections fix.

A value object without the work

Data.define gives all three methods from the fields, freezes the value and computes a hash from the fields. It is a hash key and a set member with no extra code:

ruby -e 'A = Data.define(:v); p({A.new(v: 1) => :x}[A.new(v: 1)]); p A.new(v: 1) == A.new(v: 1); p A.new(v: 1).hash == A.new(v: 1).hash'

:x
true
true

The lookup found the value under a different instance, which is the whole purpose of a value object: two instances with the same fields are interchangeable. The frozen state is part of it too, because a key that can change its hash stops being findable.

A class that defines only ==

When a hand-written class needs the same behaviour, the pattern is a comparison plus two aliases:

class Money attr_reader :cents def initialize(cents) @cents = cents freeze end def ==(other) other.is_a?(Money) && cents == other.cents end alias eql? == def hash [Money, cents].hash end end p Set[Money.new(100), Money.new(100)].size p({ Money.new(100) => :x }[Money.new(100)])

ruby eq2.rb

1
:x

The hash includes the class, so a Money and an Integer with the same number do not collide. freeze in the constructor is what keeps the hash stable for the life of the object.

What to watch for

alias eql? == is only correct when the comparison is strict about the class, as above. If == accepts a plain number for convenience, the alias makes 1.eql?(Money.new(1)) depend on the order of the arguments.

A mutable field in a value object breaks its hash. Freeze the object, or keep the fields that feed hash immutable even when the rest of the object can change.

Next: Exceptions

Article author: Arthur Isaev