Feature #21665
openRevisit Object#deep_freeze to support non-Ractor use cases
Description
Proposal: Introduce Object#deep_freeze (or similar name) to freeze an entire object graph¶
I would like to re-propose the addition of Object#deep_freeze as a way to explicitly freeze an entire object graph. This proposal was rejected some years ago after being brought up in https://bugs.ruby-lang.org/issues/17145. The proposal was rejected in favor of making Ractor-specific methods like Ractor.make_shareable.
There are a number of reasons why I believe deep_freeze is still an important addition:
- Rubyists have been requesting a way to deep freeze an object graph for many years (decades?), far longer than Ractor has existed.
- Immutable objects are the safest way to safe concurrency, with or without parallel threading or Ractor.
- In fact, deep freezing has utility completely unrelated to concurrency, such as to guarantee that a large graph of objects will not be modified in the future.
- In the absence of
deep_freeze, users have been forced to implement the behavior themselves, rely on third-party libraries, or callRactor.make_shareableeven if they never intend to use Ractor. - The existing
Ractor.make_shareableprimarily does a deep freeze internally.
Given the steady move toward making immutability the norm in Ruby, it seems clear to me that deep freezing is a feature that is long overdue.
Revisiting arguments for rejecting deep_freeze:¶
A number of reasons were given in #17145 for preferring the Ractor.make_shareable method and rejecting deep_freeze. I address those here:
One concern about the name "freeze" is, what happens on shareable objects on Ractors.
For example, Ractor objects are shareable and they don't need to freeze to send beyond Ractor boundary.
As mentioned above, deep freezing has utility completely separate from Ractors and concurrency. It is a frequently-requested and very useful feature to add. I think we should treat this as a standalone feature, and treat enhancements for Ractors as a separate concern.
I also want to introduce Mutable but shareable objects using STM (or something similar) writing protocol (shareable Hash). What happens on deep_freeze?
Five years later, I believe this has not yet happened. A potential future optimization for Ractor should not be justification to reject a useful feature today.
If users implement their code using primarily immutable objects now, it's unlikely that they will want those same objects to be mutable in the future (this applies to deep freezing as well as make_shareable).
A dynamic call to freeze causes extra calls, and needs checks that it was indeed frozen.
So for efficiency I think it would be better to mark as frozen internally without a call to freeze on every value.
I agree with the concerns about dynamic calls to freeze and overridden versions of the method. It may make more sense to implement this as a utility method, like Object.deep_freeze(obj) (a non-overridable class utility method).
This is essentially what has been implemented within Ractor.make_shareable today.
Maybe the author don't want to care about Ractor.
The author want to declare "I don't touch it". So "deep_freeze" is better.
This was actually given as a justification for a deep_freeze method versus something like Object#to_shareable, and yet what we ended up with was a method that requires users know about Ractor. I believe there should be a deep_freeze method that has nothing to do with Ractor.
And users on JRuby and TruffleRuby already can get full parallelism today without Ractor. They do not care about Ractor, but they definitely care about deep freezing.
I don't like anything with "ractor" in the name, that becomes not descriptive of what it does and IMHO looks weird for e.g. gems not specifically caring about Ractor.
This is a large part of my justification for revisiting this proposal. Users should not have to care about or want to use Ractor just so they can deep freeze an object graph, because it has utility far beyond Ractor.
I implemented Object#deep_freeze(skip_shareable: false) for trial.
https://github.com/ko1/ruby/pull/new/deep_freeze
There's already a prototype of this, though I suspect this logic essentially became Ractor.make_shareable in the end.
I believe it would be acceptable to implement Ractor.make_shareable by calling deep_freeze since there's largely no difference in visible behavior (other than Ractor-specific optimizations like marking a whole graph as shareable).
How about first having deep_freeze that just freezes everything (except an object's class)?
This is a good proposal. I believe it is what 99% of users currently calling make_shareable actually want, and again there's utility well beyond Ractor and concurrency scenarios.
So we could mark as deeply frozen first, and remember to undo that if we cannot freeze some object.
However, is there any object that cannot be frozen? I would think not.
The majority of uses of make_shareable I have seen are called exactly once on a graph of objects. It does not seem to be typical to repeatedly call make_shareable.
I understand the desire to have a shareable bit for Ractor optimization, but that is a separate feature from deep freezing an object graph. There are many cases where we will only call deep_freeze once to ensure a graph is fully frozen before publishing it for other code to see, and most of these cases will not try to re-deep-freeze that graph.
Ractor's need to "double-check" shareability is orthogonal to the discussion about deep freezing and should not be justification for rejecting deep_freeze.
@Eregon (Benoit Daloze) brought up concerns about not calling the custom freeze method on user types, since they may want to eagerly cache some data.
I believe that discussion is out of scope. deep_freeze would be defined to only free the objects that are directly walkable from a root object, and only setting frozen bits. A new overridable method could be introduced that deep_freeze would call if present, but otherwise it should just do fast-path object freeze flag setting.
@marcandre (Marc-Andre Lafortune):
Looking at def freeze in the top ~400 gems, I found 64 in sequel gem alone, and 28 definitions in the rest ๐ .
This comment provides a breakdown of custom freeze methods and the reasons they are implemented. Again, I believe this is out of scope for the discussion at hand. Forcing objects to "prepare for deep freezing" is a separate consideration that will be very library-specific, since every library may want to prepare in a different way. But they all want the ability to recursively mark objects as frozen, which is a runtime-level feature.
We discussed about the name "deep_freeze", and Matz said deep_freeze should be only for freezing, not related to Ractor. So classes/module should be frozen if [C].deep_freeze. This is why I proposed a Object#deep_freeze(skip_shareable: true) and Ractor.make_shareable(obj).
Avoiding classes and modules when deep freezing seems like a reasonable option to me. Naming could make this behavior clear, but again I believe 99% of users just want a plain old object deep_freeze.
And this is again conflating two separate concerns:
- deep freezing
- marking an entire graph as shareable
These are โ and should be โ two separate features. The deep freezing feature should not depend on setting shareability bits, since shareability is only meaningful in the context of Ractors.
So naming issue is reamained?
Object#deep_freeze (matz doesn't like it)
Object#deep_freeze(skip_sharable: true) (I don't know how Matz feel. And it is difficult to define Class/Module/... on skip_sharable: false)
Ractor.make_shareable(obj) (clear for me, but it is a bit long)
Ractor.shareable!(obj) (shorter. is it clear?)
Object#shareable! (is it acceptable?)
... other ideas?
I outline some alternatives below.
Alternative forms:¶
@matz (Yukihiro Matsumoto) didn't like deep_freeze five years ago. How do you feel about it now, @matz (Yukihiro Matsumoto)?
Some alternatives with justification:
- Object.deep_freeze(obj)
This would make sense to avoid users being able to override the deep_freeze behavior, and would make it feel more like a global utility method with special behavior.
- Object#freeze(obj, deep: true)
- Object#freeze(obj, recursive: true)
These work within the existing freeze method and still convey intent, but may break APIs that don't expect to receive keyword arguments.
And there are some alternative names, which may work as either instance methods or class methods:
freeze_recursivefreeze_allfreeze!-
freeze_reachable_objects(long but a variation of this might address concerns about not freezing classes and modules)
Updated by ioquatix (Samuel Williams) about 7 hours ago
- Related to Feature #18035: Introduce general model/semantic for immutability. added
Updated by schneems (Richard Schneeman) about 3 hours ago
I think this is generally useful. I hit a bug in syntax_suggest that took me about 4 hours to track down that boiled down to contents of an array being shared in an unintuitive way so their mutation wasn't cleanly isolated.
On the naming suggestions: I like deep_freeze as a name, in the US there's a concept of having a separate dedicated freezer, or at a kitchen they have walk-in freezers. These are commonly called "deep freeze" as in "put this sauce in the deep freeze." But maybe we want the API to be similar to the current freeze method i.e. start with "freeze" such as "freeze_all". Or possibly we introduce a module with some methods like Freeze.all(). We could use a different name as the concept, the core idea of freezing something is to make it immutable, to hold it in place. It could be immutable or another analogy like pin or pin_all (though this concept of "pinning" has connotations in other languages like Rust where it relates to memory access guarantees.)
Or possibly, we could invert the problem by making the "freeze" method mean "deep freeze", and introducing a new method like "freeze_surface", "freeze_lite", "freeze_no_recurse", or "freeze_shallow". I personally think it's a little surprising for someone to learn that calling "freeze" does not actually make the object immutable. This approach would take longer, and require a deprecation process. So maybe it's not the best short-term fix, but I think it's worth keeping in mind for the longer term. Perhaps we introduce an explicit "deep" api and an explicit "shallow" api now and that would make it easier to deprecate or change plain "freeze" behavior in the future.
Updated by headius (Charles Nutter) about 2 hours ago
ยท Edited
the core idea of freezing something is to make it immutable, to hold it in place. It could be immutable or another analogy
The immutable name is an interesting concept but maybe more in the domain of #18035 than deep_freeze. One benefit immutable would have is that it could potentially be used to return a new collection that's optimized for immutability.
# a normal mutable array instance
ary = [1,2,3]
# an immutable array that's packed and optimized
imut = ary.immutable
In any case, though, immutable seems like something that would have to be there from birth, so I don't know how well it applies to freezing (making an object immutable long after birth).
Or possibly, we could invert the problem by making the "freeze" method mean "deep freeze"
I like the idea of making freeze actually deep freeze reachable objects... but that's probably too big a leap for even Ruby 4.0.
I don't have a strong preference between any of the proposed names. Everyone immediately knows what deep_freeze is (in the English-speaking "west" anyway) so that's still my preference, and I'm leaning heavily toward it being a class method so it can't be overridden or replaced under normal circumstances.
On that note, I really don't like make_shareable because "make" is so ambiguous as a verb. Is it producing some new shareable object (as in "make into a shareable")? No, it's actually modifying the given object ("make it be shareable"). Perhaps this is too off topic, but make_shareable would probably be better as share as in Ractor.share(obj). That implies it's transitioning the object from some private, unshareable format into a shareable format so it can be shared.
Similarly, the freeze prefixed names might feel better to some folks as a strong verb form for the first part of the name.
Other ideas expanding on the ideas of immutability, active verbs, and altering the objects in place...
-
Object.seal(obj)as in sealing it so no further changes can be made -
Object.finish(obj)as in finishing the object after it has completed all its mutations -
Object.harden(obj)as in hardening the object so it can't be changed -
Object.crystallize(obj)because this is Ruby after all
Actually, I kind of like seal because it doesn't need to imply that things like classes would be frozen and it has some precedent in other languages (Java libraries can declare that a namespace is "sealed" which means no further classes can be loaded in that namespace.)
But deep_freeze still probably wins on recognizability.