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.
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.
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
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
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!