freeze is shallow and raises FrozenError¶
Level: 201 · anyone who has seen # frozen_string_literal: true at the top of a file and wondered how far it reaches
One line: freeze makes one object immutable — every later mutation raises FrozenError, naming the object — but only that object: what it contains stays mutable unless you freeze it too (Ractor.make_shareable does that for you), integers, symbols and nil are born frozen, and the magic comment freezes every string literal in its file; Python has no freeze and gets immutability from the type — tuple, frozenset, str, MappingProxyType, dataclass(frozen=True) — with the same shallowness and one back door.
Any Ruby object can be frozen, and frozen? reports it. A frozen object rejects every change: << on a string, []= on a hash, a setter or instance_variable_set on an instance of your own class — all raise FrozenError, and the message says which object refused. Freezing is a property of the object, not of the variable, so a frozen array can still hold unfrozen strings that anyone may change; that is what "shallow" means, and Ractor.make_shareable exists to freeze a whole structure in one call because a Ractor may only share objects that cannot change. Some objects are frozen from birth — every Integer, Symbol, nil, true, false, Float and Range — and a string literal is the deliberate exception: in Ruby 4.0 it is chilled, mutable but flagged, unless the file opens with # frozen_string_literal: true. The program prints seven numbered groups; group 6 starts two child Rubies, one on a file with the comment and one without.
Verified output of freeze_and_frozen_error_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. freeze, frozen?, and what the copies do
s = "abc".dup; s.frozen? false
s.freeze.frozen? true
s.freeze.equal?(s) (freeze returns self) true
s.dup.frozen? false
s.clone.frozen? true
2. FrozenError names the object
s << "d" FrozenError: can't modify frozen String: "abc"
[1].freeze << 2 FrozenError: can't modify frozen Array: [1]
{a: 1}.freeze[:b] = 2 FrozenError: can't modify frozen Hash: {a: 1}
FrozenError.ancestors.take(3) [FrozenError, RuntimeError, StandardError]
3. freeze is shallow
nested = [[1]].freeze; nested.first << 2 [[1, 2]]
nested.first.frozen? false
nested << [3] FrozenError: can't modify frozen Array: [[1, 2]]
4. Ractor.make_shareable freezes all the way down
Ractor.shareable?(nested) (before) false
after make_shareable: nested.first.frozen? true
Ractor.shareable?(nested) (after) true
5. born frozen: immediates and ranges; a literal is not
1.frozen? true
:a.frozen? true
nil.frozen? true
true.frozen? true
1.5.frozen? true
(1..2).frozen? true
"lit".frozen? (chilled, not frozen) false
[].frozen? false
6. # frozen_string_literal: true, in a child ruby
$ ruby plain.rb
"lit".frozen? false
s << "!" "lit!"
$ ruby magic.rb (line 1 is the magic comment)
"lit".frozen? true
s << "!" FrozenError: can't modify frozen String: "lit"
7. a frozen object of your own class has no back door
c = Config.new.freeze; c.host = "x" FrozenError: can't modify frozen Config: #<Config>
c.instance_variable_set(:@host, "x") FrozenError: can't modify frozen Config: #<Config>
c.frozen? true
Reading the output¶
- Row 1.
freezereturns its receiver, sos.freeze.frozen?reads naturally.dupgives an unfrozen copy;clonecopies the frozen state along with the object. - Row 2. The message is the same shape for every class —
can't modify frozen String: "abc"— and names the object by itsinspect.FrozenErroris aRuntimeErrorand so aStandardError, which is why a barerescuecatches it. - Row 3.
[[1]].freezefreezes the outer array only. The inner array still accepts<<, and only the outer one raises. - Row 4.
Ractor.shareable?isfalsefor the frozen outer array while its contents are mutable;Ractor.make_shareablefreezes the inner array as well, and the structure becomes shareable. - Row 5.
1,:a,nil,true,1.5and1..2are frozen without anyone asking; a string literal is not — it is chilled, which the Ruby text library measures on its literals are chilled ↗ page — and an array literal is plainly mutable. - Row 6. The same program run twice by a child Ruby. Without the comment,
"lit".frozen?isfalseand<<works; with# frozen_string_literal: trueon line 1, the literal is frozen and<<raises. The comment reaches every literal in that file and no further. - Row 7. A frozen instance of your own class refuses its setter and
instance_variable_setalike; there is no way around a frozen object short ofdup.
Compared with Python¶
Python has no freeze: an object is immutable or not by type, so the twin's rows use tuple, frozenset, str, types.MappingProxyType and a frozen dataclass. copy.copy of a tuple returns the very same object, since there is nothing to protect by copying (row 1). The refusals are ordinary TypeErrors and AttributeErrors — plain Exceptions, with no frozen-specific class — whose messages this page does not key because CPython rewords them between releases (row 2). The shallowness is identical: a tuple holding a list lets the list grow (row 3), and there is no make_shareable — MappingProxyType is a read-only view of a dict whose values stay mutable and which reflects later changes to the dict, and collections.abc.Hashable is satisfied by ([1],) even though hash of it fails, the same one-level check (row 4). What is born immutable is hashable — numbers, strings, None, range — and a list is neither (row 5); there is no magic comment because every string literal is immutable already, and bytearray is the mutable string when you need one (row 6). The back door is row 7: dataclass(frozen=True) raises FrozenInstanceError (an AttributeError) from __setattr__, but object.__setattr__(c, "host", "x") goes straight past it — Python's frozen is a convention enforced by one method, Ruby's is a flag the interpreter checks on every mutation.
Verified output of freeze_and_frozen_error_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. no freeze: immutability comes from the type
t = (1, 2); hasattr(t, "freeze") False
type(t).__name__ tuple
copy.copy(t) is t (an immutable is not copied) True
list(t) (the mutable counterpart) [1, 2]
tuple(list(t)) == t True
2. the errors are TypeError or AttributeError
t[0] = 9 TypeError
frozenset({1}).add(2) AttributeError
MappingProxyType({"a": 1})["b"] = 2 TypeError
[c.__name__ for c in TypeError.__mro__[:3]] ['TypeError', 'Exception', 'BaseException']
3. a tuple is shallow too
nested = ([1],); nested[0].append(2) ([1, 2],)
type(nested[0]).__name__ (still mutable) list
nested[0] = [9] TypeError
4. no deep freeze; MappingProxyType is a read-only view
mp = MappingProxyType(d); mp["k"].append(2) [1, 2]
d["z"] = 0; dict(mp) (a view, not a copy) {'k': [1, 2], 'z': 0}
isinstance(([1],), Hashable) True
hash(([1],)) (shallow, like freeze) TypeError
5. born immutable: numbers, str, None, range; a list is not
isinstance(1, Hashable) True
isinstance("a", Hashable) True
isinstance(None, Hashable) True
isinstance(True, Hashable) True
isinstance(1.5, Hashable) True
isinstance(range(1, 3), Hashable) True
s = "lit"; s[0] = "L" (every str literal) TypeError
isinstance([], Hashable) False
6. no magic comment: a str literal is immutable already
s = "lit"; s += "!"; s (rebinds s) 'lit!'
ba = bytearray(b"lit"); ba += b"!"; ba bytearray(b'lit!')
7. dataclass(frozen=True) has a back door
c = Config(); c.host = "x" FrozenInstanceError
object.__setattr__(c, "host", "x"); c.host 'x'
issubclass(FrozenInstanceError, AttributeError) True
The dataclasses ↗ reference documents frozen=True and the FrozenInstanceError; the types ↗ reference documents MappingProxyType as a read-only view.
Try it¶
- Write
deep_freeze(obj)that freezesobjand, when it is anArrayor aHash, everything inside it, recursively. Apply it to{ list: [1, [2]], name: "n" }, printfrozen?for the hash, the array, the inner array and the string, then try to append to the inner array, append to the string and add a key, printing eachFrozenError. Finish withRactor.shareable?on the result. - Compare your
deep_freezewithRactor.make_shareableon a second copy of the same structure: do both leaveRactor.shareable?true? Then take a frozen string and printfrozen?fordup,cloneandclone(freeze: false)— which of the three is the way to get a mutable copy, and which keeps the frozen state on purpose?
Solution to 1
freeze_and_frozen_error_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: deep_freeze freezes an object and, for arrays and hashes, everything
# inside it, recursively -- what a plain freeze leaves mutable.
def deep_freeze(obj)
case obj
when Array then obj.each { |e| deep_freeze(e) }
when Hash then obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
end
obj.freeze
end
config = { list: [1, [2]], name: "n" }
deep_freeze(config)
puts " config.frozen? #{config.frozen?}"
puts " config[:list].frozen? #{config[:list].frozen?}"
puts " config[:list][1].frozen? #{config[:list][1].frozen?}"
puts " config[:name].frozen? #{config[:name].frozen?}"
attempts = {
"config[:list][1] << 3" => -> { config[:list][1] << 3 },
"config[:name] << \"!\"" => -> { config[:name] << "!" },
"config[:extra] = 1" => -> { config[:extra] = 1 }
}
attempts.each do |label, attempt|
attempt.call
puts " #{label.ljust(26)} worked"
rescue FrozenError => e
puts " #{label.ljust(26)} #{e.class}: #{e.message}"
end
puts " Ractor.shareable?(config) #{Ractor.shareable?(config)}"
Verified output of freeze_and_frozen_error_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
config.frozen? true
config[:list].frozen? true
config[:list][1].frozen? true
config[:name].frozen? true
config[:list][1] << 3 FrozenError: can't modify frozen Array: [2]
config[:name] << "!" FrozenError: can't modify frozen String: "n"
config[:extra] = 1 FrozenError: can't modify frozen Hash: {list: [1, [2]], name: "n"}
Ractor.shareable?(config) true
See also¶
- Strings are mutable;
<<keeps the object — why a string needs freezing at all, and what chilled means for a literal - A variable is a reference, not a box — the sharing that makes a frozen object worth having
dup,cloneand frozen state — row 1'sdup/clonedifference in depth, andclone(freeze: false)- Ractors share nothing — where row 4's
make_shareableearns its name StructandData—Datainstances are frozen from birth, the way Python's frozen dataclass wants to be- String literals are chilled, not frozen ↗ — the Ruby text library's page on row 5 and row 6: the warning, the flag and the comment, run five ways
- FrozenError ↗ and Ractor ↗ — the Ruby 4.0 docs for the error and for
make_shareable - A symbol is a name, not a string — A Ruby symbol is an immutable name of which exactly one object exists per spelling
- Constants are found lexically, then through ancestors — A bare constant name is resolved by the lexical nesting the code was written in (
Module.nesting), then… - Common error messages — Twenty-nine errors you will meet in your first month of Ruby, each with the code that raises it, the…