Project

General

Profile

Actions

Feature #21962

open

Add deep_freeze for recursive freezing

Feature #21962: Add deep_freeze for recursive freezing
1

Added by Eregon (Benoit Daloze) 2 days ago. Updated 2 days ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:125114]

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_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 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.

Related issues 2 (1 open1 closed)

Related to Ruby - Feature #21665: Revisit Object#deep_freeze to support non-Ractor use casesOpenActions
Related to Ruby - Feature #17145: Ractor-aware `Object#deep_freeze`RejectedActions

Updated by Eregon (Benoit Daloze) 2 days ago Actions #1

  • Related to Feature #21665: Revisit Object#deep_freeze to support non-Ractor use cases added

Updated by Eregon (Benoit Daloze) 2 days ago Actions #2

Updated by matheusrich (Matheus Richard) 2 days ago 1Actions #3 [ruby-core:125123]

I can't remember any Ruby methods with the deep_ pattern (looks like Prism defines deep_freeze and Gem::ConfigFile has deep_transform_config_keys), this is intuitive for me (probably because of Rails).

I'd love to see it in core!

Actions

Also available in: PDF Atom