In Progress
Unit 1, Lesson 1
In Progress

Deep Freeze

Freezing a Ruby object can leave you skating on thin ice. In this episode you’ll learn how to render complex Ruby objects totally immutable.

Video transcript & code

There are times when we want to use a Ruby object as a kind of "master copy", from which new objects can be duplicated. For instance, back in Episode #484, we set up a master shopping list pre-populated with some common staples.

Then we froze it, to prevent accidental modifications of the master list.

That way, if we accidentally assigned the original master list to a new variable instead of taking a copy of it, and then tried to modify one of its attributes, we'd get an error alerting us of the mistake.

ShoppingList = Struct.new(:name, :items) # ~> RuntimeError: can't modify frozen ShoppingList

MASTER_LIST = ShoppingList.new("Master List", [
                                 "Bread",
                                 "Milk",
                                 "Beer"])

MASTER_LIST.freeze
today_list = MASTER_LIST

today_list.name = "Wednesday Shopping"

# ~> RuntimeError
# ~> can't modify frozen ShoppingList
# ~>
# ~> xmptmp-in27080IWH.rb:1:in `name='
# ~> xmptmp-in27080IWH.rb:11:in `<main>'

Unfortunately, it turns out that this protection is incomplete. it does prevent us from assigning a new value to an attribute of the master list. But if instead we modify an attribute in place, for instance we append an item to the list, we get no error in the original list is changed.

ShoppingList = Struct.new(:name, :items)

MASTER_LIST = ShoppingList.new("Master List", [
                                 "Bread",
                                 "Milk",
                                 "Beer"])

MASTER_LIST.freeze
today_list = MASTER_LIST

today_list.items << "Fireworks"

MASTER_LIST.items
# => ["Bread", "Milk", "Beer", "Fireworks"]

The problem here is that just like Ruby's object copying methods, the freeze method is a "shallow" operation. It will prevent an objects' instance variables from being updated, but it will not freeze the object referred to by those instance variables.

So, how do we deeply freeze an object, including all of the objects it refers to, all the objects they refer to, and so on?

Well, we could roll our own solution. But as is so often the case, there is already a gem for this.

Dan Kubb's ice_nine gem exists just for this purpose. After installing and requiring it, we can tell it to deep freeze an object.

Afterwards, attempts to update the value of object attributes result in an error, just as with a regular freeze.

But unlike with a regular freeze, any attempts to modify contained objects also fail.

That's because ice_nine performs a smart recursive freezing operation, walking down the object graph and freezing every object that it can along the way. I call it a "smart" freezing operation, because ice_nine is clever enough not to try and freeze objects which have no freeze method.

require "ice_nine"

# ...

IceNine.deep_freeze(MASTER_LIST)
today_list = MASTER_LIST

today_list.items << "Fireworks" # ~> RuntimeError: can't modify frozen Array

MASTER_LIST.items
# =>

# ~> RuntimeError
# ~> can't modify frozen Array
# ~>
# ~> xmptmp-in270809NP.rb:13:in `<main>'

Once we fix our mistake and take a duplicate of the master list instead of operating on it directly , we are able to update the attributes of the duplicate object. As you might recall from Episode #484, Ruby's dup method copies an object's value, but not its frozen status.

However, when we try to append a new item to the new shopping list, we run into a new problem.

# ...
IceNine.deep_freeze(MASTER_LIST)
today_list = MASTER_LIST.dup

today_list.name = "July 4 Shopping"
today_list.items << "Fireworks" # ~> RuntimeError: can't modify frozen Array

MASTER_LIST.items
# =>

# ~> RuntimeError
# ~> can't modify frozen Array
# ~>
# ~> xmptmp-in27080Zkp.rb:14:in `<main>'

It seems that while the top level shopping list object is now mutable, the items array it refers to is still frozen.

today_list.frozen?              # => false
today_list.items.frozen?        # => true

Why is this? Well, we can find a clue in the fact that the items array for the master list, and the items array for the new copy, are actually the same object.

MASTER_LIST.items.object_id     # => 19843380
today_list.items.object_id      # => 19843380

If you watched Episode #486, this might look familiar. It stems from the fact that Ruby's dup, along with the similar clone operation, only create shallow copies. So, we have a new ShoppingList object. But it refers to the same items array as the original.

MASTER_LIST.object_id           # => 21577760
today_list.object_id            # => 21477300

MASTER_LIST.items.object_id     # => 21577780
today_list.items.object_id      # => 21577780

Fortunately, we also know from that episode that we can customize how an object is copied. We'll add an initialize_copy method to our ShoppingList class. In it, we'll take duplicates of the name and items attributes.

After this change, we have no trouble adding new items to the copied list.

require "ice_nine"
ShoppingList = Struct.new(:name, :items) do
  def initialize_copy(original)
    self.name = original.name.dup
    self.items = original.items.dup
  end
end

MASTER_LIST = ShoppingList.new("Master List", [
                                 "Bread",
                                 "Milk",
                                 "Beer"])
IceNine.deep_freeze(MASTER_LIST)
today_list = MASTER_LIST.dup

today_list.name = "July 4th Shopping"
today_list.items << "Fireworks"

today_list
# => #<struct ShoppingList
#     name="July 4th Shopping",
#     items=["Bread", "Milk", "Beer", "Fireworks"]>

Here's one other feature of the ice_nine gem that's worth knowing. If we require the ice_nine/core_ext/object library , we can then send the deep_freeze method directly to object .

require "ice_nine"
require "ice_nine/core_ext/object"

# ...
MASTER_LIST.deep_freeze
# ...

As with any extension to core Ruby classes, we should only use this with the knowledge and agreement of our whole team. And we should confine its use to our applications, avoiding using it in public Rubygems where the appearance of new object methods may be surprising, unwanted, or even in conflict with other extensions in the end-users application.

So now we know how to put an object in the deep freeze, freezing its entire object graph instead of just the object itself. This suggests a related question: what if we want to deeply copy an object? For instance, in order to avoid having to write a custom initialize_copy method like we just did? We'll tackle that question in an upcoming episode. Until then, happy hacking!

Responses