In the last couple of weeks I’ve repeatedly stumbled over a smell in a project I’m working on: the classifier of an object was inflected by the prefix of its name. As an analogy, think of the ISBN code, where groups of numbers have a special meaning.
This is a classic example of the Value Object pattern, which has received quite some press, lately. Most prominently Martin Fowler published a post, where he provides a great introduction to the topic, as well as implementation details for JavaScript (which has the issue that you cannot override equality methods) and Java.
Looking at Ruby, Luca Guidi, who is the creator of Hanami (formerly Lotus), has released a series of two posts on the DNSimple blog (part 1, part 2). I invested some time last week and implemented a value object (I took the ISBN example from above) into my playground project (a Rails application I keep to try out new gems/techniques/reproduce bugs).
Something I solved differently in my own implementation are the equality operators ==
, eql?
, and equal?
. There’s some great explanation on Object#eql?
, which basically says:
equal?
should returntrue
only if both references are the same (e.g.a.equal?(a) # => true
)eql?
should returntrue
when both objects have the same value (i.e. in our case, when they refer to the same ISBN)==
behaves the same likeeql?
, but for classes likeNumeric
it performs type conversion (e.g.1 == 1.0 # => true
)
For my Isbn
class, I’ve implemented it as follows:
class Isbn
def initialize(value)
@value = value.to_s.delete('-')
end
def ==(other)
other = Isbn.new(other) unless other.is_a?(Isbn)
hash == other.hash
end
def eql?(other)
return false unless other.is_a?(Isbn)
hash.eql?(other.hash)
end
def hash
value.hash
end
private
attr_reader :value
end
This allows us to e.g. instantiate a Isbn
object from a string a = Isbn.new('9780-0465050659'
), but also from a number b = Isbn.new('9_780_465_050_659')
. Those two objects will be equal (a == b # => true)
).
Here’s the corresponding spec file with the full range of tests:
describe Isbn do
describe '#initialize' do
subject { described_class.new(value) }
context 'with a string value' do
let(:value) { '978-0465050659' }
it { expect(subject) }
end
context 'with an integer value' do
let(:value) { 9_780_465_050_659 }
it { expect(subject) }
end
end
describe '#==' do
subject { a == b }
context 'with matching values and classes' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { described_class.new('978-0465050659') }
it { is_expected.to eq true }
end
context 'with matching values (strings)' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { '978-0465050659' }
it { is_expected.to eq true }
end
context 'with matching values (integers)' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { 9_780_465_050_659 }
it { is_expected.to eq true }
end
context 'with non-matching values' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { '978-0465050658' }
it { is_expected.to eq false }
end
end
describe '#eql?' do
subject { a.eql?(b) }
context 'with matching values and classes' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { described_class.new('978-0465050659') }
it { is_expected.to eq true }
end
context 'with matching values (strings)' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { '978-0465050659' }
it { is_expected.to eq false }
end
context 'with matching values (integers)' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { 9_780_465_050_659 }
it { is_expected.to eq false }
end
context 'with non-matching values' do
let(:a) { described_class.new('978-0465050659') }
let(:b) { described_class.new('978-0465050658') }
it { is_expected.to eq false }
end
end
describe '#valid?' do
subject { described_class.new(value).valid? }
context 'with a valid ISBN' do
let(:value) { '978-0465050659' }
it { is_expected.to eq true }
end
context 'with an valid ISBN' do
let(:value) { '978-0465050658' }
it { is_expected.to eq false }
end
end
end