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