Personally I find the very conception of immutable variables very oxymoronic and illogical.
When "immutable variable" is a field, it doesn't work as immutable in some cases: at least in constructors and in deserialization logic. It prevents objects from delegating the initialization logic to some specialized methods and binders. It forces programmers to introduce some hard-to-maintain builder patterns and often breach this immutability with const_casts
or reflection.
When immutable variable is a local, it doesn't help much, because:
- Local scope it limited to one particular small function where all logic is observable in one screen,
- IDE can highlight all never-assigned-variables differently giving the whole mutability information without efforts of manually marking it up.
- The very existence of
const
declaration often leads to polluting company style guides with requirements to mark all occasionally-immutable-variables as consts. Forsing developers to add/remove this declarations every refactoring.
Even when const
variable declarations maintained correctly, they only prevent the modification of a reference itself, they do not prevent (in case of Java/Kotlin/Go) the modification of the referenced object.
In contrast Argentum does not support "const variables" it supports "immutable object instances".
This allows:
- to separate period of mutability from "eternity of immutable existence" for each object,
- to share objects across hierarchies (and across threads),
- to eliminate the boiler plate code supporting build pattern,
- to prevent memory leaks due circular dependencies.
Implementation
Each class can contain ordinary "mutating" methods and const methods:
class MyClass {
name = "";
setName(val String) { // this method is applicable to mutable objects only
name := val
}
-getName() String { // this method is applicable to both const and mutable objects
name
}
}
x = MyClass;
x.setName("asdf");
x.name := "qwert";
Initially all objects are created mutable. This allows to fill objects with data using all possible means - direct assignment, methods, factory - builders - binders.
At certain moment object can be turned immutable with the help of the prefix unary operator (freeze) "*".
This operator takes a temporary_lock pointer or a @composition pointer and returns an *aggregation_pointer to the frozen object. This object acts as a completely new object: it is not referenced with any lock_pointers or &weak_pointers.
It is immutable. Its fields cannot be assigned. Its mutating methods cannot be called.
Freeze operation affects all subtree of the object, if object contained @composition pointer fields, these sub-objects also get frozen and these field are read as a *aggregation pointers, if object contained &weak pointer fields they become &*frozen_weaks - pointers to frozen objects, if their targets are not frozen, they become unbound. So freeze operation excludes object from the world of mutable objects and transfer it to the world of immutable objects. And these two worlds are connected in one direction: mutable objects can reference immutable but not vise versa.
// previous example continued
f = *x; // now f is of type *MyClass
sys_log(f.name); // ok, it's allowed to read fields of the frozen objects
f.name := "zxcv"; // error, frozen object field cannot be modified
sys_log(f.getName()); // ok, method getName is marked with "-", it's applicable to frozen
f.setName("1234"); // error, mutating methods cannot be applied to object by *pointer
Frozen objects cannot be stored in temp or @composition pointers, because they allow to modify objects. Instead they are stored in *aggregation pointers, that can be both object fields and local variables.
Frozen objects can be easily shared between object hierarchies. They are not modifiable so they cannot cause any problems with shared state. That's why multiple *aggregation pointers can share ownership over the same object.
These pointers cannot produce loops. When objects are still mutable, they can't have loops. When objects get frozen they cannot be modified to convert tree into a graph with loops. With layered creation and freezing of objects it is possible to create DAG (directed acyclic graph) structures. So the only pointer able to create cycles is &weak pointer.
The "-method" can be called both on mutable and immutable objects. Inside these methods we can make no assumptions if this
-object is mutable or not, shared or not. That's why there is another type of pointers that exists only in local variables and function parameters - a conform pointer. It is declared as "-ClassName". It combines all limitations of temp-lock and *frozen pointers. This pointer disallows to modify object fields and call mutating methods, this pointer disallows shared ownership.
Classes can have three types of methods:
- Mutating methods, that can be called only on mutable instances by temp-lock or @composition pointers. In these methods
this
-pointer is a temp-lock pointer. - Frozen methods, declared as "*methodName", that can be called only on frozen objects by *aggregation pointers. In these methods
this
-pointer is a *aggregation pointer. - Conform methods, declared as "-methodName", that can be called on any objects. In these methods
this
-pointer is a -conform pointer.
Weak &pointers to mutable objects also have their &*frozen and &-conform counterparts. Their only difference is - on dereference with ?operator
they produce *aggregation and -conform pointers respectively.
Example
class Jpeg {
+ImageResource { // Jpeg implements ImageResource
decode() @Surface {...} // method that decodes JPG as a new Surface
}
data = *Blob // shared pointer, thus all copies of this Jpegs share the same data
fromBytes(*Blob bytes) this { data:=bytes }
}
background = Jpeg.fromBytes(readFile(backgrountFileName));
bg2 = @background; // now bg2 and background are two different objects sharing the
// same data
Different view of the same object
through different pointers
When object fields gets accessed by *aggregation pointer or -conform pointer their types assume limitations of this base pointer:
base pointer \ field type | @composition | &weak | *aggregation | &*weakToFrozen |
---|---|---|---|---|
temp_lock | temp_lock | &weak | *aggregation | &*weakToFrozen |
*aggregation | *aggregation read only | &*weakToFrozen read only | *aggregation read only | &*weakToFrozen read only |
-conform | -conform read only | &-weakToConform read only | -conform read only | &-weakToConform read only |
How and when pointers convert to each other
Although it looks a little entangled, but rules are simple:
- All &weaks are produced from their owning counterparts with &operator and dereferenced back with ?operator.
- All owning pointer can be copied with @operator to produce @composition pointer.
- All owning pointer can be frozen with *operator to produce *aggregation pointer.
- @composition pointers on read return temp_locks.
- All owning pointers can be converted to conform pointers.
Conclusion
Argentum has built-in support for all three types of object connections:
- composition
- aggregation
- association
Argentum uses these marked pointers to:
- Improve readability clarifying pointer roles
- Prevent breakage of immutability and single-ownership
- Automate dispose, copy, cross thread passing, etc. operations
- Organize data structures in a way that prevents memory leaks and races on shared state.
Syntax of operations and pointer declarations is lightweight and mnemonic
- * - freeze
- @ - copy
- & - weak
- ? - dereference with checking
This post finalizes the Argentum pointer types.