From: "Eregon (Benoit Daloze) via ruby-core" Date: 2026-11-12T08:22:00+00:00 Subject: [ruby-core:127435] [Ruby Feature#21962] Add deep_freeze for recursive freezing Issue #21962 has been updated by Eregon (Benoit Daloze). Some concrete use cases: Here are some real-world examples of deep freezing objects other than Array, Hash and primitives. They are user-defined objects, Structs, ASTs, config objects and so on. ### Value objects which deep-freeze themselves * [dry-struct](https://github.com/dry-rb/dry-struct/blob/HEAD/lib/dry/struct/value.rb): `Dry::Struct::Value` is defined as `def self.new(*) = ::IceNine.deep_freeze(super)`, so every instance (a user-defined struct, whose attributes may be other structs) is deeply frozen. * [adamantium](https://github.com/dkubb/adamantium/blob/HEAD/lib/adamantium/freezer.rb) is the "immutable object" mixin used across many gems (anima, yardstick, sql, axiom-types, ...). Its default freezer is `IceNine.deep_freeze!(value)`, applied to instances and memoized values. * [ddd_sample_app_ruby](https://github.com/paulrayner/ddd_sample_app_ruby/blob/HEAD/domain/cargo/itinerary.rb): domain value objects (`Itinerary`, `Leg`, `Location`, `RouteSpecification`, `Delivery`, ...) all end their `initialize` with `IceNine.deep_freeze(self)`. ### Configuration objects shared between threads/Ractors * [amqp-client](https://github.com/cloudamqp/amqp-client.rb/blob/HEAD/lib/amqp/client/configuration.rb) builds a `Configuration` holding a `MessageCodecRegistry` of parser/coder objects and calls `Ractor.make_shareable(copy)` on it. That is effectively a `deep_freeze` of a custom object graph. * [modern-ruby](https://github.com/modern-project/modern-ruby/blob/HEAD/lib/modern/app.rb): `@configuration = IceNine.deep_freeze(DeepDup.deep_dup(configuration))`, where `configuration` is a `Modern::Configuration` object. It does the same for the API descriptor object graph. * [otto](https://github.com/delano/otto/blob/HEAD/lib/otto/core/freezable.rb) hand-rolls `Otto::Core::Freezable#deep_freeze!`, recursing over `instance_variables` and Hash/Array/Set. It is used for its security config objects. ### ASTs / trees of node objects * Prism itself: `Prism.parse(source, freeze: true)` deeply freezes the whole result (nodes, `Location`, `Source`, comments, tokens). Because there is no `deep_freeze` in core, this needs hand-written `Source#deep_freeze` and `Token#deep_freeze` plus freezing threaded through the whole deserializer (`templates/lib/prism/serialize.rb.erb`). * [juno](https://github.com/peoplemiau1/juno/blob/HEAD/src/juno/frontend/ast.rb): hand-written `AST::Node#deep_freeze!` so the AST "can be shared read-only across threads". * [kumi](https://github.com/amuta/kumi/blob/HEAD/lib/kumi/core/analyzer/structs/input_meta.rb): a `Struct` with a custom recursive `deep_freeze!` over its `children`. * [mahuta](https://github.com/mtrense/mahuta/blob/HEAD/lib/mahuta/node.rb): `Node#deep_freeze!`, which actually only freezes one level of children. This shows how easy it is to get these manual implementations wrong. ### Others * [x-ruby](https://github.com/sferik/x-ruby/blob/HEAD/x-core/lib/x/core/response.rb): `X::Response` and its `ParsedBody` are explicitly designed to support being "frozen deeply, as `Ractor.make_shareable` freezes one". * [etengine](https://github.com/quintel/etengine/blob/HEAD/app/models/etsource/loader.rb): `IceNine.deep_freeze!(area_attr.with_indifferent_access)` on cached data. That is a `HashWithIndifferentAccess`, a Hash subclass. The common pattern is that people either depend on `ice_nine`, abuse `Ractor.make_shareable` purely for deep freezing, or write their own incomplete `deep_freeze!` (otto, juno, kumi, mahuta, Prism). A core `deep_freeze` would replace all of these with a single correct implementation that handles cycles, all core types and ivars. ---------------------------------------- Feature #21962: Add deep_freeze for recursive freezing https://bugs.ruby-lang.org/issues/21962#change-119881 * Author: Eregon (Benoit Daloze) * Status: Open ---------------------------------------- ### 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: ```ruby 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`: ```ruby String.deep_freeze String.frozen? # => false ``` It works for cyclic references: ```ruby a = [] a << a a.deep_freeze a.frozen? # => true ``` It calls `.freeze` on each object: ```ruby 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](https://github.com/ruby/dev-meeting-log/blob/master/2025/DevMeeting-2025-11-13.md#feature-21665-revisit-objectdeep_freeze-to-support-non-ractor-use-cases-headius). @headius 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_freeze` is consistent with `Kernel#freeze`. * `[a, b, c].deep_freeze` is nicer, more concise and more idiomatic than `Object.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_freeze` never freezes modules and classes and does not visit their internal references * Same as `Ractor.make_shareable` and `IceNine.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 `.freeze` and have control and can decide whether to freeze the ancestors, constants, class variables, etc. * `deep_freeze` calls `freeze` and ensures every object is frozen * Same as `Ractor.make_shareable` and `IceNine.deep_freeze` * The already well-known hook to e.g. compute lazy caches eagerly, etc * We still check that `.freeze` did freeze the object, and raise a `RuntimeError` if not (same behavior and error message as `Ractor.make_shareable`). * Not a protocol of defining `deep_freeze` in 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 by `Ractor.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_i` in `ractor.c`. * No expectation for calling `deep_freeze` repeatedly on the same object to be fast * An object flag wouldn't work for this, for the case that `freeze` raises an exception. * If some `freeze` method raises an exception, the exception is propagated. * Same as `Ractor.make_shareable` and `IceNine.deep_freeze` * Some objects visited before the exception may already be frozen, same as `Ractor.make_shareable` (unavoidable because we need to call `freeze` as we traverse the object graph). * `deep_freeze` does 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](https://github.com/ruby/dev-meeting-log/blob/master/2025/DevMeeting-2025-11-13.md#feature-21665-revisit-objectdeep_freeze-to-support-non-ractor-use-cases-headius) 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 `Class` or `Module`, it is not frozen and its internal references are not traversed. * Otherwise, Ruby calls `.freeze` on 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 as `Ractor.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 `freeze` behavior. * It intentionally reuses existing `freeze` overrides 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. -- https://bugs.ruby-lang.org/ ______________________________________________ ruby-core mailing list -- ruby-core@ml.ruby-lang.org To unsubscribe send an email to ruby-core-leave@ml.ruby-lang.org ruby-core info -- https://ml.ruby-lang.org/mailman3/lists/ruby-core.ml.ruby-lang.org/