Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Properties

A property is a struct member that is read and written like a field (account.name, account.name = "Savings") but whose get and set accessors can run code. A property that needs somewhere to keep its value gets it from the compiler: its implied storage, reachable as field inside its accessors. You declare a separate private field only when you want one.

Declaring properties

A property is a field declaration followed by an accessor block instead of ;:

# A property is read and written like a field, but its `get` and `set`
# accessors can run code.
struct Account {
    # An auto property: implied storage, read and written by anyone.
    var owner: String { get; set; }

    # Read-only outside the struct: only Account's own code assigns it.
    var balance: long { get; }

    # A custom `set` over implied storage: `field` is the storage and
    # `value` is the new value.
    var name: String {
        get;
        set {
            assert(!value.is_empty, "an account name cannot be empty");
            field = value;
        }
    } = "unnamed";

    # Computed: no accessor is omitted and none uses `field`, so it takes
    # no space.
    var is_empty: bool {
        get {
            return balance == 0;
        }
    }

    # Explicit storage, when you want it.
    var _deposits: ulong;
    var deposits: ulong {
        get {
            return _deposits;
        }
    }

    # An initializer initializes the implied storage.
    var limit: long { get; set; } = 100;

    construct(this.owner = "") {}

    deposit(amount: long) {
        balance += amount;   # Account's own code may set a get-only property
        _deposits += 1;
    }
}

main(): int {
    var account = Account("Ann");
    assert(account.owner == "Ann", "a constructor parameter can set a property");
    assert(account.is_empty, "a computed property runs its get");

    account.owner = "Bob";
    account.name = "Savings";
    assert(account.name == "Savings", "the set stored the name");

    account.deposit(30);
    account.deposit(12);
    assert(account.balance == 42 && account.deposits == 2, "methods update the storage");
    assert(!account.is_empty, "the computed property follows the balance");

    # `account.balance = 0;` would not compile: 'balance' has no 'set'.

    account.limit += 50;   # a get, then a set
    assert(account.limit == 150, "compound assignment reads and writes");
    return 0;
}
  • get; and set; with no body are the defaults: get returns the storage, and set stores the new value in it. Either may have a body instead.
  • A property has implied storage when an accessor is omitted (get; or set;) or an accessor body uses field. Otherwise it is computed: it takes no space, and its get runs every time it is read.
  • An initializer after the accessors (var limit: long { get; set; } = 100;) initializes the storage, so it needs a property that has storage. It does not run set.
  • Inside an accessor, field is the storage, and inside set, value is the new value (a by-value parameter). Both are contextual: outside accessors they are ordinary names, so a local or a member called field or value elsewhere keeps working. Inside an accessor, a bare field always means the storage; reach a member of that name as this.field.
  • A property must have a get: { set; } alone is an error.
  • A property is declared with var and an explicit type; it cannot be const. There are no static properties.

Reading and writing

  • p.count calls get, and p.count = x calls set. A default accessor is not a call at all: an auto property ({ get; set; }) compiles to the same loads and stores as a field.
  • p.count += 1, p.count++ and the other compound assignments are a get and then a set. p is evaluated once.
  • A constructor parameter this.name assigns the property, through its set when it has one. A field initializer and the struct’s own field initialization do not.
  • Inside the struct’s methods, a property is reached by its bare name, like a field: balance += amount; is this.balance += amount;.
  • A property’s accessors are ordinary method calls for reference counting: get returns a temporary that owns one reference, and value is a by-value parameter, acquired on entry and released on exit.

Read-only properties

{ get; } alone makes a property read-only outside the struct: assigning it from other code is an error at the property’s name.

main.pep:6:7: error: cannot assign to 'balance': it has no 'set' accessor
    6 |     a.balance = 3;
      |       ^~~~~~~
main.pep:2:9: note: 'balance' declared here
    2 |     var balance: long { get; }
      |         ^~~~~~~

The struct’s own constructors and methods may assign a get-only property with implied storage; that writes the storage directly. An extension’s methods are not the struct’s own code: they cannot assign it either.

Properties in traits and extensions

A trait can require a property, var area: double { get; } or { get; set; }, with no accessor bodies. A struct meets the requirement with a property of the same name and type (auto or computed), or with a plain field. Reading or writing the property through a trait value calls the struct’s accessors.

An extension can add computed properties only, since it cannot add storage to the struct:

# A trait can require a property; a struct meets it with a property of the
# same name and type.
trait Shape {
    var area: double { get; }
    var label: String { get; set; }
}

struct Square {
    var side: double { get; set; } = 1.0;
    var area: double {
        get {
            return side * side;
        }
    }
    var label: String { get; set; } = "square";
}

extend Square with Shape;

# An extension can add computed properties, which need no storage.
extend Square {
    var perimeter: double {
        get {
            return side * 4.0;
        }
    }
}

describe(shape: Shape): String {
    shape.label = "seen";
    return $"{shape.label}: {shape.area}";
}

main(): int {
    var square = Square();
    square.side = 3.0;
    assert(square.area == 9.0, "the computed area follows the side");
    assert(square.perimeter == 12.0, "an extension's property reads like any other");
    assert(describe(square) == "seen: 9.0", "a trait value reaches the struct's accessors");
    assert(square.label == "seen", "the set through the trait reached the struct");
    return 0;
}

An extension property that would need storage is an error:

main.pep:5:9: error: an extension cannot add storage: property 'y' must be computed, with accessor bodies that do not use 'field'

An enum can declare computed properties too, under the same rule, and no set, since its values never change.

Properties of [Shared] structs

The fields of a [Shared] struct are const, so the storage of its properties is too:

  • A property with implied storage must be { get; }, and only the struct’s constructors assign it. A set, with or without a body, is an error at set, with a note at [Shared].
  • A computed get over the struct’s const fields is allowed, as any method is.

A [Shared] struct’s properties are get only, by design: an assignment x.p = v would read as a write to a struct whose fields cannot change. To change what a thread-safe member holds (an Atomic<int>, or a value behind a Mutex), declare a method that says what it does, such as store_hits(value) or add_name(name).

How properties compile

The compiler lowers a property to plain members: a field for its storage (named after the property when its get is the default, __field_name otherwise), a getter method __get_name() and, with a set, a setter __set_name(value). Every read and write of the property becomes a storage access or an accessor call once the receiver’s type is known. The accessor methods appear in debuggers and in profiles under those names.