Adamantium
If you have some familiarity with the Value Object pattern, you probably know that value objects work best when they are immutable. But making objects truly immutable is surprisingly tricky in Ruby.
In this episode, you’ll learn how to use a Rubygem which handles the hard parts of putting an object into deep freeze.
Video transcript & code
Back in Episode #201 we talked about the advantages of making value objects immutable. I'm not going to rehash that episode here, but the nutshell version is that it's a lot harder to introduce accidental bugs by unwittingly changing a value object, if that object refuses to be changed after creation.
Immutable value objects are so useful that there are some Ruby gems to make them easier to define. Today we're going to meet one of those gems, named "adamantium". Adamantium is one of the many small, focused gems to come out of the Ruby Object Mapper project, or "ROM" for short.
Let's introduce this gem by using it in our Quantity
class. We're going to remove the freeze line from the initializer. Instead, we require "adamantium" and include Adamantium into the class.
require "adamantium"
class Quantity
include Adamantium
def initialize(magnitude)
@magnitude = magnitude.to_f
end
end
Now let's check to see if the class is still immutable. We'll introduce a method with a typo in it. Instead of checking if the value of the quantity is zero, it inadvertently sets the value to zero.
require "adamantium"
class Quantity
def zero?
@magnitude = 0.0
end
end
Now we'll instantiate a quantity, and ask it if it is zero.
require "./quantities"
f = Feet.new(100)
f.zero? # =>
# ~> /home/avdi/Dropbox/rubytapas/xxx-adamantium/quantities.rb:147:in `zero?': can't modify frozen Feet (RuntimeError)
# ~> from -:4:in `<main>'
Excellent! It looks like Adamantium did, indeed, make our value object immutable and thereby prevent the bug from going unnoticed.
Right about now I'm sure you're thinking "why on earth would we replace a single line of code with a dependency on a third-party gem?" That's an excellent question. To answer it, let's take a look at some of the other capabilities that Adamantium adds.
Our Quantity
classes are extremely basic value objects. They only have a single attribute, magnitude
. And that attribute is a Ruby Float
, which is itself inherently immutable.
Let's flesh out our pattern language for measurements a bit. We'll add a Dimension
type, which represents different dimensions like "height" or "width". And a Measurement
type, which combines a Dimension
with a Quantity
.
require "./quantities"
class Dimension
attr_reader :name
def initialize(name)
@name = name
end
end
class Measurement
attr_reader :dimension, :quantity
def initialize(dimension, quantity)
@dimension = dimension
@quantity = quantity
end
end
Now let's create a dimension named "height", and a new height measurement. We can query the measurement for its height. We can also update its quantity if we want.
Now let's dig down a few layers, into the dimension and then into the dimension's name. Because strings in Ruby are mutable, we can modify the name of the dimension. If we then check the height
dimension, sure enough, the name has been changed. This is exactly the sort of modification-at-a-distance—whether intentional or, more likely, accidental—that we want to prevent with value objects.
require "./measurement"
height = Dimension.new("Height")
m = Measurement.new(height, Feet(24))
m.quantity # => #<Feet:24.0>
m.dimension # => #<Dimension:0x00000000e6d890 @name="Height">
m.dimension.name << "Oops"
height.name # => "HeightOops"
Now let's update the Measurement
class to include Adamantium
. This time, when we try to update the dimension name, we get an exception saying we can't modify a frozen string.
require "./quantities"
require "adamantium"
class Dimension
attr_reader :name
def initialize(name)
@name = name
end
end
class Measurement
include Adamantium
attr_reader :dimension, :quantity
def initialize(dimension, quantity)
@dimension = dimension
@quantity = quantity
end
end
require "./measurement2"
height = Dimension.new("Height")
m = Measurement.new(height, Feet(24))
m.dimension.name << "Oops"
# ~> -:6:in `<main>': can't modify frozen String (RuntimeError)
This does a better job of illustrating the power of Adamantium: it is able to deeply freeze an object, including its attributes, and their attributes, and so on.
We should be aware that it has, in fact, frozen the original height
object, not a copy of it. We can see that if we compare the before and after state of the height
dimension. So in the name of preventing future changes, it has actually made one final change to all of the constituent objects of the measurement. For this reason it probably makes the most sense to use it only on objects that "own" all of their attributes. Or if they share objects with others, those shared objects should also be immutable. Since value objects should only be made up of other value objects, this shouldn't be too much of a problem.
require "./measurement2"
height = Dimension.new("Height")
height.frozen? # => false
height.name.frozen? # => false
Measurement.new(height, Feet(24))
height.name.frozen? # => true
height.frozen? # => true
Now let's add a custom #hash
method to our Measurement
class. (If you're not familiar with #hash
methods, check out Episode #203). Like we did in that episode, we'll use an array to hash together the object's attributes as well as its class.
require "./quantities"
require "adamantium"
class Dimension
attr_reader :name
def initialize(name)
@name = name
end
end
class Measurement
include Adamantium
attr_reader :dimension, :quantity
def initialize(dimension, quantity)
@dimension = dimension
@quantity = quantity
end
def hash
[dimension, quantity, self.class].hash
end
end
As we look at this, we realize something: every single time this #hash
method is called, it will recursively re-compute the hash value of the dimension
, the quantity
, and the class
and then roll them all together. To show this, we can put a tell-tale in the Dimension
#hash
method.
class Dimension
def hash
puts "called: #{self}#hash"
super
end
end
Then we'll send Measurement#hash
a few times. Every time we take the hash value, the #hash
method on height
is also invoked.
require "./measurement3"
height = Dimension.new("Height")
m = Measurement.new(height, Feet(24))
m.hash # => -1389332281969560678
m.hash # => -1389332281969560678
m.hash # => -1389332281969560678
# >> called: #<Dimension:0x00000001b808c0>#hash
# >> called: #<Dimension:0x00000001b808c0>#hash
# >> called: #<Dimension:0x00000001b808c0>#hash
But wait a second… if Measurement
is immutable, its hash value will never change! Why should we take the time to recompute its hash value every time?
Adamantium has another trick up its sleeve to address just this concern. After we define the #hash
method, we can add the line memoize :hash
. This tells Adamantium to modify the method so that it will only ever compute the value once.
This time, no matter how many times we send the #hash
message, the Dimension#hash
method is only invoked once.
require "./quantities"
require "adamantium"
class Dimension
attr_reader :name
def initialize(name)
@name = name
end
def hash
puts "called: #{self}#hash"
super
end
end
class Measurement
include Adamantium
attr_reader :dimension, :quantity
def initialize(dimension, quantity)
@dimension = dimension
@quantity = quantity
end
def hash
[dimension, quantity, self.class].hash
end
memoize :hash
end
height = Dimension.new("Height")
m = Measurement.new(height, Feet(24))
m.hash # => 1974307320020610792
m.hash # => 1974307320020610792
m.hash # => 1974307320020610792
# >> called: #<Dimension:0x00000001370928>#hash
As you can see, Adamantium provides quite a bit more power than a simple call to #freeze. It's a useful tool to have around if we are building value objects. Happy hacking!
Responses