9
$\begingroup$

Would it be safe to introduce the freeze statement for freezing a mutable object into an immutable object?

Such statement would mix well into dynamically-typed languages such as Python or JavaScript. Let me demonstrate some scenarios.

The most basic scenario is when I want an object to be mutable only before some point. Freezing the object afterwards would prevent unexpected runtime bugs. (JS-esque pseudocode)

let foo = 0;
// Some operations involving foo
freeze foo;
// Now foo is immutable; every attempt to rebind foo shall be an error

You might argue that I could write const bar = foo; and work with bar afterwards, but still, this freeze statement can act as a syntactic sugar.

Furthermore, there are scenarios that only freeze can achieve. An example scenario is when a condition is choosing which object to freeze. (JS-esque pseudocode)

let foo = 0;
let bar = 1;
// Some operations involving foo and bar
const cond = /* A boolean value resulted from the operations */;
freeze cond ? foo : bar;

Another example scenario is when the object to be frozen is in a container. (Python-esque pseudocode):

foo = []
# Some operations involving foo
for item in foo:
    freeze item
# Now objects contained by foo are frozen, but foo itself can be modified

So why aren't languages doing this? Are there unexpected caveats I didn't notice?

$\endgroup$
8
  • 14
    $\begingroup$ I'm not sure what practical purpose it would serve to be able to dynamically choose what object to freeze. It doesn't make sense to mutate something that may or may not be mutable so the programmer would still not be able to mutate either option. $\endgroup$
    – mousetail
    Commented Feb 14 at 9:35
  • 5
    $\begingroup$ Note that Object.freeze is already a function on Javascript, though it works on objects not variables. Freezing variables seems pointless however $\endgroup$
    – mousetail
    Commented Feb 14 at 9:37
  • 19
    $\begingroup$ Your question conflates mutability of objects, with rebindability of names. These are different things; note how e.g. in JavaScript const foo = []; prevents rebinding like foo = [1, 2, 3]; but doesn't prevent mutating the array like foo.push(5);. In contrast, let bar = Object.freeze([]); is the opposite in both respects. $\endgroup$
    – kaya3
    Commented Feb 14 at 18:20
  • 4
    $\begingroup$ It's also not clear what you mean by "safe" in the title of your question. Do you mean type safety, memory safety, or something else? $\endgroup$
    – kaya3
    Commented Feb 14 at 18:24
  • 4
    $\begingroup$ "why aren't languages doing this?" – Do you mean something like ECMAScript's Object.freeze? Or something like Ruby's Object#freeze? How does your proposal differ from each of those? $\endgroup$ Commented Feb 15 at 10:57

6 Answers 6

15
$\begingroup$

The feature you describe would do two different things, both making the value of a variable immutable, and also prevent reassignment to a variable. Both of those things are individually already possible and combining them would create a whole lot of messy edge cases.

Immutable Variables

Both options are already possible. As you note, you can just rebind a let variable as const in Javascript:

let a=[];
const b=a;
// but note you can still change the value
b.push(12);

Python has no feature for immutable variables. You could argue it would benefit from such a feature.

Immutable Values

In Javascript you can create immutable values using Object.freeze(...):

let k = Object.freeze({"self": {"king": "henry"}});
k.baz = 15; // errors in strict mode

let m = k;
m.foo = "bar"; // also errors since it points to the same object

k.self.king = "Charles"; // fine, only the top level is protected

k = []; // fine

Outside of strict mode any attempt to modify will just do nothing but give no errors in JS

Python also has various mechanisms to prevent modifications, like tuples, frozensets, frozen dataclasses, named tuples, etc. They work in the same way.

Why combining the two is a bad idea

Convenience

First of all it would just be less convenient than the existing options.

const a = Object.freeze({...});

Is 1 line shorter than

let a = {...};
freeze a;

Recursion

You don't specify if freeze should act recursively or just prevent modifying the top level value. Both seem bad.

  • If you don't recurse, you'd need to individually freeze every property of a large object, adding to the inconvenience above. With Object.freeze at least you can do it inline
  • If you do recurse, you couldn't freeze anything that had a reference to something that you didn't want frozen. Does your object contain a reference to some HTML element? Well now your entire window object is frozen and you can't do anything anymore. Essentially it would effect things way outside the "scope" of the current statement leading to large scale unexpected effects.

Freezing expressions

If you run:

function k() { return [5,3,2] };
freeze k();

What should happen?

There seems to be a very limited type of expressions where your freeze would actually make sense, leading to a lot of special casing. Other similar operators like delete work properly for all expressions.

Conditionally freezing variables

You mention this code in your example:

let foo = 0;
let bar = 1;
// Some operations involving foo and bar
const cond = /* A boolean value resulted from the operations */;
freeze cond ? foo : bar;

However, immutability is just a tool to help a programmer avoid mistakes. It is still the programmers responsibility to avoid mutating the variable or the program will crash. So if you want to avoid crashes, you don't mutable even a possibly immutable object. Thus this would effectively just prevent the programmer from mutating both foo and bar so you might as well freeze both.

If you later branch and do either something with foo or with bar you might as well move the re-declaration inside the condition.

$\endgroup$
4
  • 2
    $\begingroup$ Regarding Python, it has no runtime feature for immutable variables, but there is Final for type checkers to verify functional immutability ahead of time. And in my experience, that is usually good enough unless you’re computing variable names at runtime given that there is no way in the language to leverage write-once semantics for a variable (as compared to a language like Erlang). $\endgroup$ Commented Feb 15 at 2:36
  • 2
    $\begingroup$ Regarding the last part, I can imagine some algorithm along the lines of DFS where you conditionally freeze e.g. a node in a graph after visiting it, as a way of asserting that the algorithm won't visit (and mutate) that node again. Or for freezing names as opposed to objects, consider a merge function with two index variables; you have a loop which on each iteration increments one or the other variable, and then you freeze that variable if it has reached its limit, to assert that it won't be incremented again. $\endgroup$
    – kaya3
    Commented Feb 15 at 23:09
  • $\begingroup$ k.foo = "bar"; do you mean m.foo = "bar"? $\endgroup$
    – njzk2
    Commented Feb 17 at 11:36
  • 1
    $\begingroup$ They could also use this syntax (with the freeze keyword): const obj = freeze {...}, maybe to help distinguish itself from JavaScript. $\endgroup$ Commented Feb 17 at 16:08
9
$\begingroup$

This already exists in Haskell's vector and array packages. In fact, they not only have freeze, they also have thaw.

Taking vector as an example, there are two types involved: one for immutable vectors and one for mutable vectors. You can only perform mutations on the mutable vectors inside IO or ST. And, as you'd expect, mutations are not possible on immutable vectors.

So, for instance, maybe you want to implement some algorithm using mutation to modify an vector. But later you want to use this vector in pure code. Maybe you get the original vector from pure code as well. You could write:

import Data.Vector as V
import Data.Vector.Mutable

inPlaceAlgorithm :: IOVector Int -> IO ()
inPlaceAlgorithm v = ...

buildVector :: Int -> Vector Int
buildVector n = ...

timesTwo :: Vector Int -> Vector Int
timesTwo = V.map (*2)

main :: IO ()
main = do
  let immutableVec = buildVector 20

  mutableVec <- thaw immutableVec
  inPlaceAlgorithm mutableVec

  newImmutableVec <- freeze mutableVec
  print (timesTwo newImmutableVec)

In this particular situation, the answer to your question is: Yes, freeze is safe (and, in fact, so is thaw). This answer might be different in a different situation, though, depending on the specifics of the language.

This is slightly different than what you describe because it is making copies. There is an unsafe version that does not make a copy. You could probably avoid the copying while maintaining safety by building freeze into the language and then doing a static check to see if the variable is ever mutated after it's frozen. I don't think this would be less safe than the safe Haskell freeze.

However, if you do that you will run into an issue with the conditional freezing you mention: you'll have to treat both choices as frozen at compile-time. This problem seems unavoidable for conditional freezing.

Though you specifically refer to dynamically typed languages, the static type system of Haskell is actually the main thing that is making this safe (in this situation).

Showing correctness

In a statically typed setting, here is one approach to proving that an approach to freezing is safe:

  1. Define the syntax and typing rules for the language, including freeze
  2. Define an operational semantics for the language, being careful to not include any rules that would involve mutating something that's frozen
  3. Prove that the operational semantics really never results in something immutable being mutated
  4. Prove type soundness

If the operational semantics does not include a rule for mutating something that's frozen, then type soundness implies that immutable things will never be mutated due to undefined behavior (in particular due to the progress theorem).

$\endgroup$
6
$\begingroup$

An important question which is often overlooked in language design is whether a variable or field of a container type should be treated as encapsulating the identity of a container, its contents, or in some rare cases, both. There are generally two ways a variable or field (which I'll collectively, "entity") may encapsulate a container's contents:

  1. The entity may hold the only reference in the universe to a mutable container.

  2. The entity may hold a shareable reference to an immutable container.

An action which makes a container immutable, but has no other effect, would not be as useful as one which, given either of the above, will yield a shareable reference to an immutable container having the same content. Such action could be accomplished in any of three ways:

  1. Return the original reference, if it was already a shareable reference to an immutable container.

  2. Produce a new immutable container with contents copied from the original.

  3. Make the original container immutable, and return a reference to it.

Scenario #3 would only be safe in situations where an implementation could guarantee that no references to the original could exist--possibly in other threads--in ways that would be usable to mutate the container. If a language has a distinct "non-shareable reference" type, it could support a construct to create a mutable object and a non-shareable reference to it, pass that reference to some code that could use it but not persist it, freeze the object, and then yield a shareable reference. I'm not sure what syntax would be best for this, but it would not take the form of a stand-alone "freeze" command. If a language doesn't have a means of representing non-shareable references, a facility to have one type definition create a mutable container type, an immutable container type, and virtual functions which can be used on either type to create shareable immutable clones or non-shareable mutable ones, would also probably be safer and more useful than a "freeze".

$\endgroup$
5
  • $\begingroup$ Immediately thought "what happens to existing non-const aliases" and this answer was the only one to recognize the problem. $\endgroup$
    – Ben Voigt
    Commented Feb 16 at 22:46
  • $\begingroup$ @BenVoigt: It's very easy for a language like Java to treat all references the same, but doing that makes it hard to avoid copying objects that will never end up being mutated, or constructing new immutable versions of objects which are about to be discarded. Producing a new "edited" immutable object by making a mutable copy of an object, applying required changes, and freezing it, is much cleaner and easier than trying to having to manually specify every attribute which is supposed to be passed through unchanged, but if the language doesn't recognize "unshareable references"... $\endgroup$
    – supercat
    Commented Feb 16 at 23:00
  • $\begingroup$ ...it's hard to guard against non-const aliases. Further, while I didn't mention it, recognizing different kinds of references makes it possible for a language to meaningfully supply equality-check and cloning methods, though two of each should be specified, to handle the kinds of references that might be used to identify an object. $\endgroup$
    – supercat
    Commented Feb 16 at 23:02
  • $\begingroup$ What about shared references to mutable containers (and unshared references to immutable containers, though those are boring)? $\endgroup$
    – Bergi
    Commented Feb 17 at 2:31
  • $\begingroup$ @Bergi: Mutable objectrs to which shared references exist generally have one "owning" entity, whose state encapsulates the state of the object, and other entities which either observe the state, or put data into the entity for the benefit of its owner. In some cases, it may make sense for the owning entity to freeze an entity, but owning vs. non-owning entities add issues which go beyond freezing. $\endgroup$
    – supercat
    Commented Feb 17 at 4:11
5
$\begingroup$

The basic idea that it should be possible to lock data from modification is sound.

It is often the case that data structures have to undergo a progressive building process, during which time elements may be modified freely and the whole structure passes through a series of intermediate states, but after which there should then be constraints on modification.

One problem with the freeze idea though, is that in real application designs, underlying constraints on modification are often more fine-grained than applying to a whole structure at once, and the shape of the constraints may vary more than once during the lifetime of a particular structure. It is also often the case that only certain values or state transitions are constrained, rather than a simple dilemma of whether to allow all modification of a single field or allow none.

Secondly, the idea proceeds from the wrong assumption that programmers mostly just forget about the existence of specific and very simple constraints.

In fact, most errors arise because programmers either become confused at the analysis stage about the constraints which ought to apply (that is, they become confused about specifying what should be frozen and when), or having devised a set of constraints that are non-trivial and dynamical in their shape, and which are too complicated to be enforced by the compiler, they become confused about their context and therefore proceed to program in violation of those constraints.

Programming language solutions that solve only the simple cases of a known problem, whilst leaving the programmer still to wrestle alone with the most complicated cases, are often highly pernicious, since they often introduce only additional fuss and complexity around the simple cases which the programmer could already manage without support, but which fuss and complexity then causes the complex cases to be starved of mental resources when those were already cases with which the programmer was struggling to cope.

It's also very common that any kind of constraint on the mutability of existing structures, but with no constraint on the construction of new structures, just leads to a proliferation of "snakeskin-shedding" where essentially the same data goes through a mutation process by way of constant reconstruction of the structures which hold it. This introduces additional complexity in the code, compared to direct mutation of an existing structure, but rarely has any benefit like what it's proponents imagine to offset the hassle.

$\endgroup$
4
$\begingroup$

Introduce a mutation qualifier for entire objects or individual fields

In static languages this would be unwise, as this would introduce an error similar to NullPointerException in basically any part of code. Newer languages are moving away from these types of runtime errors.

Instead of introducing a frozen statement, it would be better to default all objects as non-mutating, init only, basic types and user composite types, and optionally marking any parameter as being mut in functions that mutating the object is necessary or expected.

The reason for this is that mutating objects are few and short lived, as they would be transformed in non-mutating versions soon, or that eternal mutating objects should be few anyway.

class List      // Immutable version
{ }

class List mut  // Mutating version
{
    public List operator mut(List mut)
    {} // Manual conversion to immutable, may be auto generated
}

def Add( mut list List , T elem ) mut List
{ } // Add can only be defined for mutating lists

def GetEnumerator( mut coll Collection ) UnstableEnumerator
{ } // UnstableEnumerator can throw

def GetEnumerator ( coll Collection ) StableEnumerator
{ } // Stable enumerator is notrow!

mut var list = List(); // Will pick the mutable version
list.Add( 1 ).Add( 2 ).Add( 4 );

var yogurt = list; // mut var to var, call mut/const operator

yogurt.Add( 8 );  // Compile error

def UserDefinedOnlyTraverse( list List )
{
    list[0] *= 2; // Compile error
}

This is a form of mutability overloading. That is, to use the same type name for mutable and immutable versions. But this would need support from language to annotate parameters, properties and variables to choose or restrict that version is to be used.

$\endgroup$
1
$\begingroup$

In mousetail's accepted answer, they mention that there are two major ways of thinking about freezig. You can freeze a variable or you can freeze the data.

If you're thinking about freezing a variable, it's worth looking into all of the nuances of C++'s const keyword. They indeed freeze a variable, and there's even a const_cast that lets you "thaw" a variable too. There's quite a lot of content on just how complicated const is, and const doesn't even provide guarantees, just convenience. If I have a const variable, that doesn't make it immutable, it just means I am not permitted to mutate it. Another function with a non-const reference to the same data can change it. I expect any "freeze" keyword you construct which acts on variables will have at least as many nuances as const did, and likely have even more.

Applying "freeze" to values has been done in many languages. I've even written one myself with that functionality. It's a tricky thing though. It's typically extremely hard to follow frozen-ness through a Turing complete language, so you have to do some extra effort at runtime. Typically this either means having a flag on every object that might be not-frozen which is checked on every mutating operation, or it means copying the data from one datastructure to another.

One major concern is whether there is only one concept of "frozen" which makes sense in your language. C++ ran into this one with const, and it's sibling, mutable. One camp of programmers wanted const to mean "does not change any memory," and the other wanted it to mean "effectively does not change the state of an object." The latter would permit effects like caching which don't change the result of operations but may change the runtime cost of them. This was implemented in the language via the mutable keyword. This debate rages today. In fact, it has even more significance now, because the language now has support for threads built into the language. "Does not change any memory" can easily be shown to be threadsafe in lots of circumstances, while "effectively does not change the state" can introduce subtle race cases.

In other words, if you give a mouse a cookie, they'll probably ask for some milk. If you give them frozen, they'll probably ask for a half dozen other related semantics.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .