Software Developer

Duped into modifying a frozen hash

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)

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"}

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}}

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]}

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">}

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

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

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"}

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}

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

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

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

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

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)

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!