Properties are a special way of storing and accessing data. They usually act exactly like fields, but you can also define custom getters and setters to get/set a property’s value in a special way.
The normal way of defining a property is:
property Foo someFoo;
This defines a property called someFoo of type Foo, with default a default getter and setter. The default getter and setter simply assign and retrieve a backing variable. To access this property (assuming it is in a class Bar), we treat it as if it were a field:
Bar bar = new Bar(); Foo oldFoo = bar.someFoo; bar.someFoo = createNewFoo();
Custom Getters and Setters
Sometimes, it is useful to do some processing in the getter or the setter of a property. In fact, the whole reason for having properties is so that we can override this behaviour if we want to. Here’s how we do it in Plinth:
property string name getter { return name; } setter(newName) { logNewName(newName); name = newName; };
Inside the getter and setter, the references to name
actually refer to the backing variable for the property. But they don’t have to be used. In fact, you can declare a property without a backing variable:
string actualName; unbacked property string name getter { return actualName; } setter(newName) { actualName = newName; };
In this case, you must define a custom getter and setter, because the default ones cannot be used.
A property can also be abstract, and properties in interfaces are abstract by default. Abstract properties are always unbacked, and have no implementation for their getter and setter. Any types inheriting an abstract property must always define it, even if they do not provide custom getters and setters, as the backing variable must be defined in order to generate the default getter and setter.
Immutability and Properties
By default, properties have a normal setter and an immutable getter. This allows you to read the variable from an immutable context (e.g. inside an immutable method), but modify it only from a normal (mutable) context.
However, this behaviour can be changed using modifiers. If you want a mutable getter, it can be marked as such using the mutable modifier, but the property will no longer be readable from an an immutable context:
property uint x mutable getter { ++x; return x; }; immutable uint getThriceX() { return x * 3; // error: x cannot be read from an immutable context }
Another thing you can do is mark a property’s setter as immutable. This allows you to call the setter from inside an immutable method:
unbacked property uint y immutable setter(value) { stderr::println("y = " + value); } getter { return 17; };
Of course, it is usually impossible to alter anything from inside an immutable function, and a setter is no exception. The only way to modify something from within an immutable function in plinth is to mark the variable being modified as mutable
. So we could use our own backing variable and mark it as mutable
, or we could mark the property itself as mutable
, which accomplishes the same thing. Making a property mutable only affects the backing variable (so mutable unbacked
properties are impossible). We can define a property with an immutable setter as follows:
mutable property uint z immutable setter immutable getter; immutable void quadrupleZ() { z *= 4; // works, because the getter and setter are both immutable }
Here, we declare the property’s setter as immutable. This allows the setter to be called from an immutable context, such as quadrupleZ()
. We also declare the getter as immutable for consistency, but this is not necessary since getters are immutable by default.
Returning from a Getter
Forget about properties for a moment. When you read from a field, the value is immutable if the thing you are accessing it on is immutable. For example:
class Foo { Foo next; } // in some method: #Foo myFoo = new Foo(); Foo nextFoo = myFoo.next; // error: myFoo.next is a #Foo, so it can't convert to a Foo
This happens because Plinth has deep immutability, meaning that accessing data on an immutable object results in more immutable data.
The same is true when we use properties: reading a property on an immutable object results in an immutable piece of data. However, reading a property on a normal object results in a normal piece of data. For example:
class Foo { property Foo next; } // in some method: #Foo myFoo = new Foo(); Foo nextFoo = myFoo.next; // error: myFoo.next is a #Foo, so it can't convert to a Foo Foo otherFoo = new Foo(); Foo otherNextFoo = otherFoo.next; // fine, otherFoo is not immutable, so neither is otherFoo.next
This is fine so far, but now imagine that the getter is a normal method:
class Foo { Foo next; immutable Foo getNext() { return next; // error: next is a #Foo in this context, which can't convert to a Foo } }
As you can see, it is not possible for an immutable method to return one of its fields without the result being immutable.
So how do getters work in this situation? They cheat.
A property’s getter can return values “against contextual immutability”. This means that if it returns a value which is only immutable because it was accessed inside an immutable context, then returning that value works. For example:
class Foo { Foo realNext; unbacked property Foo next setter(value) { realNext = value; } immutable getter { if true { // this works, because although realNext is immutable in this context, // immutable getters can return values against contextual immutability return realNext; } #Foo otherValue = new Foo(); return otherValue; // error: otherValue is explicitly immutable, not just contextually immutable }; }
Initialisers and Backing Variables
As with fields, properties can have initialisers. Initialisers simply call the setter with some value, for example:
property uint x = 45 setter(value) { stdout::println("new x=" + value); x = value; };
This will print out “new x=45” during initialisation.
However, there is a subtlety about initialisation which involves the property’s backing variable: accessing the old value from the setter:
property string y = "hello, world!" setter(value) { string old = y; // error: y may not have been initialised stdout::println("old y=" + old + " new y=" + value); y = value; };
If the property’s type does not have a default value (e.g. null or zero), then the backing variable must be initialised before it is read. This means the setter cannot access the old value, since before the setter is called for the first time, the backing variable’s value is undefined.
There is a way around this, but it requires slightly more work. It involves using a custom backing variable and an unbacked property:
string backingStr = "initial"; unbacked property string str = "hello, world!" setter(value) { string old = backingStr; stdout::println("old=" + old + " new=" + value); backingStr = value; } getter { return backingStr; };
This will print "old=initial new=hello, world!"
during initialisation.
In theory, it would be possible to design the language to allow access to the backing variable from outside the getter and setter. However, the confusion this would cause as to whether or not the setter is called in a given assignment makes it much clearer to use a custom backing variable if access to the old value is needed.