Last time we talked about how to reduce the abuse of primitive types, by the extraction of objects to represent concepts in our model domain.
But how shall we design these objects? Let's talk about their fundamental properties.
Imagine we're building a weather app.
We recognized that floats weren't the right fit to express temperatures, because it was odd to ask if "a float is cold". Better to extract
We call it a value object.
class Temperature def initialize(temperature) @temperature = Float(temperature) end protected attr_reader :temperature end
We have defined a protected accessor (
temperature), to let other instances of
Temperature to have access to it.
Now let this object to answer that question.
The main purpose of a value object is answer meaningful questions for our application. Something like: "Is 37°F cold?".
class Temperature def initialize(temperature) @temperature = Float(temperature) end def cold? temperature < 59.0 # Fahrenheit degrees end protected attr_reader :temperature end Temperature.new(37).cold? # => true
Yay! We're now able to tell if a temperature is cold or not.
If we look at the implementation of
#cold?, we spot the same problem again: we're using a float to represent a model domain concept: the cold threshold temperature. That's primitive obsession biting again! We can use
class Temperature def initialize(temperature) @temperature = Float(temperature) end def cold? temperature < COLD_THRESHOLD end protected COLD_THRESHOLD = new(59) attr_reader :temperature end Temperature.new(37).cold? # => temperature.rb:8:in `<': comparison of Float with Temperature failed (ArgumentError)
Ouch! We can't compare a
@temperature) with a
Temperature instance (
What we want here is to make our
Temperature to be comparable with other
Temperature instances. Luckily there is a useful mixin in Ruby standard library:
class Temperature include Comparable def initialize(temperature) @temperature = Float(temperature) end def cold? self < COLD_THRESHOLD end def <=>(other) temperature <=> other.temperature end def to_s temperature.to_s end protected COLD_THRESHOLD = new(59) attr_reader :temperature end Temperature.new(37).cold? # => true Temperature.new(37) > Temperature.new(68) # => false Temperature.new(37) == Temperature.new(37) # => true
Comparable requires to define the spaceship operator (aka
#<=>) and to return
other is respectively lesser, equal, or greater than. Now, thanks to
Temperature is able to respond to comparability checks like lesser than (
#<), or equal to (
We're also capable to check if two temperatures are equal if they carry the same value.
Now we need to get a list of all the unique temperatures of the last month. We start from raw data which include duplicates:
[Temperature.new(44), Temperature.new(68), Temperature.new(68)].uniq # => [44.0, 68.0, 68.0] # WRONG
What's going on here? We expected to see
68.0 only once, but there is twice!
This bug is caused by how a language like Ruby checks for unique entries.
It transforms the object identity (
#object_id) into an integer which will remain constant during the life span of the process. This technique is called hashing.
We can tell our language how to check for identity equality, by providing our own hashing implementation.
class Temperature # ... alias eql? == def hash temperature.hash end end
But wait! We use a value (
@temperature) to define its identity?
That's right! Because value objects define their identity by their value. 68°F is.. 68°F – and it will be always equal to 68°F.
[Temperature.new(44), Temperature.new(68), Temperature.new(68)].uniq # => [44.0, 68.0]
Because we delegate important behaviors to the encapsulated value, we want it to not change. When we implement a hashing strategy we want it to be constant until the end of the program execution. If the value changes, we can't meet this requirement. We should
In our case, the value that we hold is a
Float, which is already frozen. But if we have other types like strings, hashes, arrays, we should explicitly do that. Preferably, we should define the getter as
protected to prevent accidental data modifications.
Temperature to be responsible for specific model domain questions, but also to behave like a primitive.
If you think, our language type system is made of basic "generic primitives", on top of which we build another layer of model domain "specific primitives": the value objects.