Feature #21962
openAdd deep_freeze for recursive freezing
Description
Motivation¶
It is common to want some data structure to be immutable, e.g. to ensure it doesn't get mutated by multiple threads.
See the first section of #21665 for a more detailed motivation, the summary is there is long-standing demand for it (back to #17145) for many reasons.
One way/workaround to do this currently is Ractor.make_shareable but its name suggests a Ractor-specific purpose.
The functionality of deeply freezing is separate from Ractor and it is generally useful.
Ractor.make_shareable almost has the right semantics and has been used for years so this proposal largely follows its semantics rather than reinventing the wheel.
The semantics also match the ice_nine gem, which further illustrates these semantics work well in practice and are expected.
Examples¶
It freezes deeply:
config = {
db: { host: ENV["HOST"], ports: [5432] },
flags: [:a, :b]
}.deep_freeze
config.frozen? # => true
config[:db].frozen? # => true
config[:db][:host].frozen? # => true
config[:db][:ports].frozen? # => true
Modules and classes are not frozen by deep_freeze:
String.deep_freeze
String.frozen? # => false
It works for cyclic references:
a = []
a << a
a.deep_freeze
a.frozen? # => true
It calls .freeze on each object:
class Stats
def initialize(samples)
@samples = samples
@mean = nil
end
def mean
@mean ||= @samples.sum / @samples.size.to_f
end
def freeze
mean
super
end
end
s = Stats.new([1.0, 2.0, 3.0])
s.deep_freeze
s.mean # => 2.0, would raise FrozenError instead if overridden #freeze was not called
Background¶
#21665 relaunched the discussion about deep_freeze and it was discussed in a dev meeting.
@headius (Charles Nutter) has said he won't have time to look at it soon.
So I am proposing something concrete which hopefully can be approved as-is.
Design¶
- Proposed:
Kernel#deep_freeze. Alternative:Object.deep_freeze.-
Kernel#deep_freezeis consistent withKernel#freeze. -
[a, b, c].deep_freezeis nicer, more concise and more idiomatic thanObject.deep_freeze([a, b, c]). - The proposal focuses on semantics; the exact method name and owner can be decided separately.
-
- The return value is the receiver.
-
deep_freezenever freezes modules and classes and does not visit their internal references- Same as
Ractor.make_shareableandIceNine.deep_freeze - No unexpected breakage because a class, its ancestors, their constants, etc all got frozen.
- Very easy to explain in the documentation.
- If people want to freeze classes/modules (rare case) they can with
.freezeand have control and can decide whether to freeze the ancestors, constants, class variables, etc.
- Same as
-
deep_freezecallsfreezeand ensures every object is frozen- Same as
Ractor.make_shareableandIceNine.deep_freeze - The already well-known hook to e.g. compute lazy caches eagerly, etc
- We still check that
.freezedid freeze the object, and raise aRuntimeErrorif not (same behavior and error message asRactor.make_shareable).
- Same as
- Not a protocol of defining
deep_freezein many classes.- That does not work with circular references.
- No need to invent a complex protocol when there is an existing working one (calling
.freeze) which has proven to work well (by being used byRactor.make_shareable).
- Handling circular references by maintaining internally a set of visited objects to avoid walking circular references multiple times
- As done in
obj_traverse_iinractor.c. - No expectation for calling
deep_freezerepeatedly on the same object to be fast - An object flag wouldn't work for this, for the case that
freezeraises an exception.
- As done in
- If some
freezemethod raises an exception, the exception is propagated.- Same as
Ractor.make_shareableandIceNine.deep_freeze - Some objects visited before the exception may already be frozen, same as
Ractor.make_shareable(unavoidable because we need to callfreezeas we traverse the object graph).
- Same as
-
deep_freezedoes not stop at already-frozen objects, because they may still reference unfrozen objects. - No special treatment for shareable objects, they are frozen too.
I think this addresses all the points mentioned in the dev meeting discussion and in #21665 and in #17145.
The traversal and freezing behavior closely follows Ractor.make_shareable, without introducing new semantics.
Implementation¶
The implementation in CRuby would be shared with Ractor.make_shareable, ractor.c already provides rb_obj_traverse() which is a great fit to implement this.
From a high-level view the implementation works like this:
deep_freeze recursively traverses the reachable object graph from the receiver.
For each visited object:
- If it is a
ClassorModule, it is not frozen and its internal references are not traversed. - Otherwise, Ruby calls
.freezeon the object. - After calling
freeze, Ruby verifies that the object is frozen. If it is not frozen, an exception is raised (same behavior and error message asRactor.make_shareable). - The traversal keeps a visited set to avoid infinite recursion on cycles.
Non-goals¶
This proposal is focused on defining deep_freeze and nothing else:
- This proposal does not define a new protocol requiring classes to implement custom recursive logic.
- This proposal does not change
Ractor.make_shareable. - This proposal does not freeze classes or modules.
- This proposal does not attempt to define immutability as a broader language concept.
Compatibility and risk¶
- The proposal adds a new API and does not change existing
freezebehavior. - It intentionally reuses existing
freezeoverrides on user objects. - It intentionally does not freeze classes/modules to avoid surprising breakage.
- Its semantics are close to existing
Ractor.make_shareable, reducing implementation risk.
Relation to existing methods¶
-
freeze: freezes only the receiver (shallow). -
deep_freeze: recursively freezes reachable objects. -
Ractor.make_shareable: recursively freezes reachable objects except if they are already shareable. Special handling to make some objects shareable.