In Progress
Unit 1, Lesson 1
In Progress

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