I see — thanks! What is your end goal with that? Is it backing out changes or performance or ensuring that things don’t get frozen under foot?
Note that we intend to (as separate PEP) enable immutable objects to be shared by reference between subinterpreters. So even if this work would be mostly useful in the presence of concurrency, it is not only relevant with free-threading. I hope that makes sense.
No it doesn’t! And I could probably have been clearer in my long post, but I am not arguing that we need to capture this nor that it is important to do so — I was mostly trying to unpack some of the challenges and design considerations for a static type system for a feature like this.
I saw several posts talking about (something like) Foo vs ImmutableFoo and I wanted to point out that it is not as simple. If our goal of introducing ImmutableFoo is because we don’t want calls to mutating methods on variables typed Foo to fail, then we probably want the same kind of type consistency for ImmutableFoo.
I am in favour of freezing the type in-place the first time we freeze an object of type Foo to get rid of this problem. I think this leads to sensible code that is easy to reason about and easy to debug. In the probably rare case that we need to later mutate the type object, it probably makes more sense to make a copy of the type object at that point and then mutate the copy that to never freeze the type in-place to retain the ability to mutate the type. I’m banking on it being sensible for types to be immutable more often than it makes sense to actually mutate them (but granted — there are sensible such cases, but they are not so common).
If I understand you correctly, you are essentially proposing a kind-of copy constructor (but in the form of a function) that is guaranteed to return a deeply immutable object. Is that correct?
It depends. Some things are easier and some things are harder. For example, allowing additions to the type that essentially preserve subtyping will not break things, but it is harder to statically leverage the knowledge and safety type-check uses of the additions, at least if you are outside of the scope where the additions happen. Removing or redefining things is much harder. I mean it is possible to allow it but it requires quite heavy lifting for that to be sound.
If you have a pointer to an object that you know is the only pointer to that object in the system, then you can change the type of that object safely as no one else can observe the change and be surprised. Same thing holds for a type — a type object that only has a single pointer to it can be changed safely. But that’s a very strong restriction! There are other ways that involve tracking of the visibility of the type.
Not a copy. Just a factory. I’m not 100% clear on the use cases, but for the cases in which you typically do something like create x (using a factory f) and then freeze it, you may as well just have the factory create a frozen version of x to begin with?
(But you could also use such a frozen decorator to make a frozen copy constructor. That’s not the only thing you can do though.)
I think the proposal is more like a scoped construct like with. The idea is that everything created in that scope would be made immutable. That works if there if all global state is immutable, but if there is global mutable state, then there are two problems,
Global mutable objects could become reachable from the objects created during the scope, which leads to either
a. the underfoot type change, or
b. the invariant that immutable can only reach immutable is broken.
An object created during the scope is made visible through a global, this will then change to immutable during the scope, which again could be seen as an underfoot type change.
So ultimately, I think this would have the same static typing issues.
My proposal requires that objects and their trees can be searchable with dfs iff they are are incoming or outgoing from a foriegn interface. All such elements are on freezable so perfect, no worries!
I thought about this, sorry for not being clear. The arguments to a frozen-contructor would themselves have to be frozen before being passed in. That invariant would be checked by the decorator. If you don’t have frozen arguments, you can always use a frozen copy constructor to transform non-frozen arguments into frozen ones.
Also, this reflects a pattern that is in the excellently-designed (IMO) Equinox library whereby frozen dataclasses are constructed using a __post_init__ function that sees them as non-frozen. So you have all the freedom of treating the object as mutable during construction and all the safety of having a frozen object in the end.
Additionally, if the frozen-constructor decorator were available, I think Equinox would probably just use that instead of jumping through the hoops that it does.
But when you hash a frozen object of a currently unhashable mutable type you can’t reach the default __hash__ implementation because the type would have its __hash__ attribute overridden with None, which is how a type declares itself as unhashable today. I don’t think this mechanism can change or it would be a big breaking change.
I think a new __frozen_hash__ method as suggested by Fridtjof, with changes to abc necessary to make isinstance(obj, Hashable) work, would be a more viable approach.
To aid static typing for in-place object freezing, I suggest that we make freeze a soft keyword instead.
The keyword should act as an operator rather than a function, so that it is not overridable and can thus be reliably used by static type checkers to keep track of the mutability state of an object, much like how type narrowing is done.
For example:
lst = list(range(10))
freeze lst
lst.append(1) # type checker error: arg 0 of list.append must be mutable
Typesheds for built-ins must also be comprehensively updated to reflect the mutability of each parameter. For backwards compatibility, parameters should be presumed to be mutable unless otherwise annotated as immutable.
It’s about the possibility of backing out changes – worst-case scenario planning – but actually, I think there’s another, better, path to this, now that I’ve had more time to think.
I agree that the proposal has merit without free-threading. But free-threading builds are being published alongside non-free-threaded ones; officially a preview.
That tripped me up into thinking “if we want a developer preview, it can go in the free-threaded build!” That would make it very easy to consume, etc etc etc, but it’s not the best way for a variety of reasons.
A much better approach:
feature-flag the entire feature behind a flag set at CPython build time[1]
disable that flag for the published builds
officially declare the feature “a preview” until the entire system is built out
To state a detail explicitly: I would still have builds supply the freeze module when the flag is off (it makes writing compatible Python code much easier), but with the freeze method raising a NotImplementedError or similar, and the isfrozen method always returning False. Unpickling a frozen pickled object into an interpreter with the flag off would also fail.
The video game market is hyped up on things being “Early Access”, and this lets us have a Python feature in Early Access too!
I was already implicitly suggesting this anyway. ↩︎
These are all good questions, and here are my 2 cents:
type() on a frozen object should not change, since mutability as proposed is independent of type.
Likewise, isinstance() on a frozen object should not change, with the exception of isinstance(obj, Hashable) if the idea that a frozen object can be made hashable gets supported.
repr() on a frozen object should change, since the spirit of repr() is to return a string representation of an object that can ideally be eval’d back to the same value of the object, so something like repr(freeze([1, 2, 3])) == 'freeze([1, 2, 3])'.
bytes and frozen bytearray should practically behave identically (except in strict type checks) so it can be simply recommended in the docs that one ought to use a bytes as a constant if it has the entire value ready to go upon initialization, or use a frozen bytearray if its value has to be built over multiple steps. There isn’t going to be any real harm aside from efficiency penalties even if someone gets confused and chooses to use one over the other.
frozenset and frozen set are a similar story, but with an added caution in the docs that a frozenset is only shallowly frozen so a frozen set, or even a frozen frozenset (again, depending on initialization steps), should be used if it is to contain mutable objects.
tuple vs. frozen list should cause less confusion because they are conceptually different (tuples are heterogeneous data structures while lists are homogeneous sequences). But otherwise my points in 5. also apply.
Sorry, I mean if the code inside the factory method uses state that isn’t supplied by the arguments, but from importing a module, or the globals (nonlocal) of the current scope.
I like the idea of surfacing it generally in this form, and perhaps, the more general freeze we propose could be restricted in some code bases to be used in this way to facilitate static checking.
Our approach could certainly build this decorator, and we can experiment with it.
Speaking as a library author does this deep immutability feature mean that users are going to want to freeze random parts or all of a library and will then complain if that doesn’t work?
I’m assuming that if you deep freeze an object then you have to freeze its class then you have to freeze the module containing the class which then means that you have to freeze everything imported into the module which then means that you need to freeze all the modules containing those things and so on. It seems like this quite quickly adds up to an all or nothing freeze of the whole library and all its dependencies.
As an example of something that seems like it breaks immutability what happens if you freeze a function that is decorated with functools.lru_cache? Does the cache break after being made immutable?
A large portion of the PEP is actually spent in explaining the design choices that were made to mitigate the very problem you’re describing, such as a strict opt-in freezability policy (so a type from a C extension such as functools.lru_cache isn’t freezable unless you whitelist it, knowing that the rest of your code isn’t going to alter the cache after a freeze) and making immutable copies of cells, globals and builtins when freezing a function (so freezing a class doesn’t freeze the module just because the class has methods whose functions hold references to the module namespace).
I suggest that you read through the entire PEP and come back with more specific questions regarding the proposed mechanisms designed to avoid “ice-nining”.