Duped into modifying a frozen hash

Kevin Murphy - Feb 20 '23 - - Dev Community

Flash Freeze

You can freeze a hash to prevent modifying its contents. The effect of freezing the hash is only one level deep. The values in the hash aren't frozen. As expected, I can't change the entire value of a key.

irb(main):001:0> h = { c: "three" }
=> {:c=>"three"}
irb(main):002:0> frozen = h.freeze
=> {:c=>"three"}
irb(main):003:0> frozen[:c] = "four"
(irb):13:in `<main>': can't modify frozen Hash: {:c=>"three"} (FrozenError)
Enter fullscreen mode Exit fullscreen mode

However, I can modify a string value in the frozen hash.

irb(main):001:0> h = { c: "three" }
=> {:c=>"three"}
irb(main):002:0> frozen = h.freeze
=> {:c=>"three"}
irb(main):003:0> frozen[:c].upcase!
=> "THREE"
irb(main):004:0> frozen
=> {:c=>"THREE"}
Enter fullscreen mode Exit fullscreen mode

I can also modify a hash inside the frozen hash.

irb(main):001:0> h = { a: { b: 2 } }
=> {:a=>{:b=>2}}
irb(main):002:0> frozen = h.freeze
=> {:a=>{:b=>2}}
irb(main):003:0> frozen[:a][:b] = 4
=> 4
irb(main):004:0> frozen
=> {:a=>{:b=>4}}
Enter fullscreen mode Exit fullscreen mode

And modify an array inside the frozen hash.

irb(main):001:0> h = { d: [4, 5, 6] }
=> {:d=>[4, 5, 6]}
irb(main):002:0> frozen = h.freeze
=> {:d=>[4, 5, 6]}
irb(main):003:0> frozen[:d].map!(&:even?)
=> [true, false, true]
irb(main):004:0> frozen
=> {:d=>[true, false, true]}
Enter fullscreen mode Exit fullscreen mode

I can modify an instance of an object inside a frozen hash.

irb(main):001:0> h = { a: User.new(first_name: "Alice") }
=> {:a=>#<User:0x000000010b50f8c8 @first_name="Alice">}
irb(main):002:0> frozen = h.freeze
=> {:a=>#<User:0x000000010b50f8c8 @first_name="Alice">}
irb(main):003:0> frozen[:a].first_name = "Carol"
=> "Carol"
irb(main):004:0> frozen
=> {:a=>#<User:0x000000010b50f8c8 @first_name="Carol">}
Enter fullscreen mode Exit fullscreen mode

The immutability isn't "deep". It's shallow. It doesn't nest down to lower levels. Adding deep freezing to Ruby itself has been discussed. There are also gems that you can use to recursively freeze objects.

The affect of freeze only being shallow isn't specific to a hash. It applies to other data structures and objects as well. However, I specifically want to talk about hashes because...

Frozen Rail(s) Shot

Rails includes a HashWithIndifferentAccess class. That class considers the keys :a and "a" to be the same key. It's part of ActiveSupport, and that also includes a core extension to the hash class. That lets you call .with_indifferent_access on a hash to transform it into a hash with indifferent access.

class Hash
  def with_indifferent_access
    ActiveSupport::HashWithIndifferentAccess.new(self)
  end
end
Enter fullscreen mode Exit fullscreen mode

We can use a HashWithIndifferentAccess like this.

irb(main):001:0> h = { a: 1 }
=> {:a=>1}
irb(main):002:0> h[:a]
=> 1
irb(main):003:0> h["a"]
=> nil
irb(main):004:0> indifferent = h.with_indifferent_access
=> {"a"=>1}
irb(main):005:0> indifferent[:a]
=> 1
irb(main):006:0> indifferent["a"]
=> 1
Enter fullscreen mode Exit fullscreen mode

You can also freeze a hash with indifferent access. It's like freezing a hash in the ways described above. It still only applies one level deep.

irb(main):001:0> h = { c: "three" }
=> {:c=>"three"}
irb(main):002:0> indifferent = h.with_indifferent_access
=> {"c"=>"three"}
irb(main):003:0> frozen = i.freeze
=> {"c"=>"three"}
irb(main):004:0> frozen[:c] = "four"
can't modify frozen ActiveSupport::HashWithIndifferentAccess: {"c"=>"three"} (FrozenError)
irb(main):005:0> frozen[:c].upcase!
=> "THREE"
irb(main):006:0> frozen
=> {"c"=>"THREE"}
Enter fullscreen mode Exit fullscreen mode

The Cold Never Bothered Me Anyway

HashWithIndifferentAccess provides another way you can accidentally allow changes to a frozen hash.

irb(main):001:0> frozen = { a: 1 }.freeze
=> {:a=>1}
irb(main):002:0> frozen[:a] = 2
(irb):2:in <main>': can't modify frozen Hash: {:a=>1} (FrozenError)
irb(main):003:0> indifferent = frozen.with_indifferent_access
=> {"a"=>1}
irb(main):004:0> indifferent[:a] = 2
=> 2
irb(main):005:0> indifferent
=> {"a"=>2}
Enter fullscreen mode Exit fullscreen mode

Here I created a frozen hash and could not modify it. I then called with_indifferent_access on it. The resulting HashWithIndifferentAccess could change - even with the original hash frozen.

Brain Freeze

Originally I guessed that maybe HashWithIndifferentAccess is using the dup method. That does not preserve the frozen status of what it's duplicating.

irb(main):001:0> frozen = { a: 1 }.freeze
=> {:a=>1}
irb(main):002:0> frozen.frozen?
=> true
irb(main):003:0> duplicated = h.dup
=> {:a=>1}
irb(main):004:0> duplicated.frozen?
=> false
Enter fullscreen mode Exit fullscreen mode

As an aside, clone will preserve the frozen status.

irb(main):001:0> frozen = { a: 1 }.freeze
=> {:a=>1}
irb(main):002:0> frozen.frozen?
=> true
irb(main):003:0> cloned = h.clone
=> {:a=>1}
irb(main):004:0> cloned.frozen?
=> true
Enter fullscreen mode Exit fullscreen mode

However, my intuition was wrong. dup doesn't play a role in the source code. The constructor for HashWithIndifferentAccess takes an argument. In our case it's our original frozen hash. It will pass that argument to its update method.

That will eventually iterate through each key, value pair in the original hash and write it to the HashWithIndifferentAccess:

other_hash.to_hash.each_pair do |key, value|
  if block && key?(key)
    value = block.call(convert_key(key), self[key], value)
  end
    regular_writer(convert_key(key), convert_value(value))
  end
end
Enter fullscreen mode Exit fullscreen mode

The regular_writer method is an alias for []=.

HashWithIndifferentAccess is also indifferent about the original hash's frozen status. It constructs a new hash, or hash-like object, setting its keys and values based on the original hash. Whether that original hash is frozen or not doesn't matter. The new hash-like object is not.

irb(main):001:0> frozen = { a: 1 }.freeze
=> {:a=>1}
irb(main):002:0> frozen.frozen?
=> true
irb(main):003:0> frozen.with_indifferent_access.frozen?
=> false
Enter fullscreen mode Exit fullscreen mode

The frozen status of the values do carry over though.

irb(main):001:0> frozen = { a: "not changing".freeze }.freeze
=> {:a=>"not changing"}
irb(main):002:0> indifferent = frozen.with_indifferent_access
=> {"a"=>"not changing"}
irb(main):003:0> indifferent[:a].upcase!
(irb):8:in `upcase!': can't modify frozen String: "not changing" (FrozenError)
Enter fullscreen mode Exit fullscreen mode

The string in the value of :a is still frozen, even after making it a hash with indifferent access.

Things Got Real Quiet Real Fast (Tenth Avenue Freeze-out)

Calling freeze on an object does give you certain (limited) immutability guarantees. Be particularly mindful of how you're interacting with a frozen object. In my case, calling h.freeze.with_indifferent_access left me thinking I was working with a frozen hash with indifferent access. I was wrong. Flipping the order, and calling h.with_indifferent_access.freeze does give me what I was expecting.

Stay cool!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .