The Loup Native Book — contents
  1. Chapter 1: Getting started
  2. Chapter 2: Native mode overview
  3. Chapter 3: Variables and numerics
  4. Chapter 4: Functions
  5. Chapter 5: Control flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and pattern matching
  9. Chapter 9: Classes and ARC
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error handling
  13. Chapter 13: Modules and packages
  14. Chapter 14: Template fences
  15. Chapter 15: Ownership verbs
  16. Chapter 16: borrow, excl borrow and noescape
  17. Chapter 17: unique ownership
  18. Chapter 18: Lifetimes and regions
  19. Chapter 19: @unsafe and calling C
  20. Chapter 20: Concurrency with OS threads
  21. Chapter 21: Async on native
  22. Chapter 22: Desktop UI
  23. Chapter 23: SIMD and GPU
  24. Chapter 24: Building and distributing
  25. Chapter 25: Memory safety patterns

Chapter 9: Classes and ARC

Structs (chapter 7) are values. Classes are the other half of Loup's data model: objects with identity, shared by reference, whose lifetime is managed by automatic reference counting (ARC). This chapter covers class declarations, constructors, visibility, mutation through modifier methods, inheritance, and what ARC does in a native program, including the places where Loup 1.25.1 does not yet behave as specified.

Declaring a class

A class has fields, an optional constructor named new, and methods. Every field is declared let (set once, in the constructor or by its initializer) or var (changeable later). Fields and methods are private to the class unless marked pub (ruling FPRIV-1). Inside the class, members are reached through this:

@mode(host.native)

class Account {
    pub let owner: string;
    var balance: int = 0;

    new(owner: string) {
        this.owner = owner;
    }

    pub func describe(): string {
        return i"{$ this.owner $}: {$ this.balance $}";
    }

    pub modifier func deposit(amount: int): void {
        this.balance = this.balance + amount;
    }
}

func main(): void {
    let acct = Account("ada");
    safe {
        acct.deposit(50);
        acct.deposit(25);
    }
    println(acct.describe());
}

Listing 9-1: A class with a private field and a modifier method

ada: 75

Account("ada") calls the constructor. There is at most one new per class. deposit writes a var field, so it is declared modifier, and calling it on acct happens inside a safe { } block. Both rules are explained below and in full in chapter 15.

Mistake: a field without let or var

Fields need a keyword. A bare name: Type, as in TypeScript or in a Loup struct, is rejected:

This code does not compile
@mode(host.native)

class Endpoint {
    host: string;
}

func main(): void {
    println(Endpoint(host = "db").host);
}
main.lphc:4:5: error[E0001]: E_BARE_FIELD: class field `host` must be declared `let` or `var`; write `var host: ...` (mutable) or `let host: ...` (immutable)

Mistake: reading a private field

balance has no pub, so only the class itself can read it:

This code does not compile
@mode(host.native)

class Account {
    pub let owner: string;
    var balance: int = 0;

    new(owner: string) {
        this.owner = owner;
    }
}

func main(): void {
    let acct = Account("ada");
    println(acct.balance);
}
main.lphc:14:13: error[E0001]: member 'balance' of class 'Account' is private

Expose what callers need through a method (describe() in Listing 9-1) or mark the field pub. Keeping var fields private and changing them only through modifier methods keeps every change to an object's state inside the class.

Generated constructors

A class without new gets a constructor generated from its fields, which takes them by name; fields with initializers are optional (ruling IMPCTOR-1):

class Endpoint {
    pub let host: string;
    pub let port: int = 443;
    pub var retries: int = 3;
}

func main(): void {
    let api = Endpoint(host = "api.example.com");
    let db = Endpoint(host = "db.local", port = 5432);
    println(i"{$ api.host $}:{$ api.port $} retries={$ api.retries $}");
    println(i"{$ db.host $}:{$ db.port $}");
}

Listing 9-2: A generated constructor

api.example.com:443 retries=3
db.local:5432

A required field left out is reported as missing required field 'host' in 'Endpoint' constructor call.

Classes are references

Assigning a class instance does not copy it. Both bindings refer to the same object, and a change made through one is visible through the other:

func main(): void {
    let mine = Account("ada");
    let alias = mine;
    safe { alias.deposit(40); }
    println(mine.describe());
}

Listing 9-3: Two references to one object

ada: 40

Compare Listing 7-2, where assigning a struct made an independent copy. Use a class when a thing has identity and several parts of the program must see the same instance (a connection, a cache, a node in a graph); use a struct for plain data.

Changing an object: modifier and safe

Because an object can be reached through several references, Loup controls who may change it. A method that writes a var field must be declared modifier (otherwise E_MODIFIER_REQUIRED), and calling a modifier method on an ordinary reference-counted instance must happen in a safe context: a safe { } block or a safe func. The same applies to assigning a pub var field from outside the class:

This code does not compile
@mode(host.native)

class Account {
    pub let owner: string;
    pub var label: string = "";
    new(owner: string) { this.owner = owner; }
}

func main(): void {
    let acct = Account("ada");
    acct.label = "savings";
    println(acct.label);
}
main.lphc:11:5: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'label' called on ARC receiver of type 'Account' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`

The message treats the field write as a call to a modifier named after the field. Write safe { acct.label = "savings"; }. A function that receives an object to change takes it as edits and is declared safe func, as in chapter 15's Listing 15-4. Chapter 15 explains what the safe context is for and which parts of the check 1.25.1 implements.

Inheritance

A class extends at most one base class, written after a colon. The subclass constructor calls the base constructor with super(...), and a method that replaces a base method must say override:

class Animal {
    pub let name: string;
    new(name: string) { this.name = name; }
    pub func speak(): string { return "..."; }
    pub func intro(): string { return i"{$ this.name $} says {$ this.speak() $}"; }
}

class Dog : Animal {
    pub let breed: string;
    new(name: string, breed: string) {
        super(name);
        this.breed = breed;
    }
    pub override func speak(): string { return "woof"; }
}

func main(): void {
    let rex = Dog("rex", "labrador");
    println(rex.intro());
    let animals: list[Animal] = [Animal("generic"), rex];
    for (a in animals) {
        println(a.speak());
    }
}

Listing 9-4: A subclass with an override

rex says woof
...
woof

Calls are dispatched on the object's actual class, so intro(), defined in Animal, calls Dog's speak(). Leaving out override is an error, so a subclass cannot replace a base method by accident:

This code does not compile
@mode(host.native)

class Animal {
    pub let name: string;
    new(name: string) { this.name = name; }
    pub func speak(): string { return "..."; }
}

class Dog : Animal {
    new(name: string) { super(name); }
    pub func speak(): string { return "woof"; }
}

func main(): void {
    println(Dog("rex").speak());
}
main.lphc:11:5: error[E_OVERRIDE_REQUIRED]: method `speak` on class `Dog` shadows a parent method on `Animal` and must be marked `override` (E_OVERRIDE_REQUIRED)

super.method() calls the base version of a method. For behaviour shared by unrelated classes, use interfaces (chapter 10).

Static members

A static field or method belongs to the class rather than to an instance:

class RequestIds {
    static var next: int = 1;

    pub static func issue(): int {
        let id = RequestIds.next;
        RequestIds.next = RequestIds.next + 1;
        return id;
    }
}

func main(): void {
    println(RequestIds.issue());
    println(RequestIds.issue());
}

Listing 9-5: A static counter

1
2

Automatic reference counting

Every class instance carries a reference count. In a native binary, the compiler inserts the bookkeeping: storing a reference in a new binding, a field or a collection adds one; a binding going out of scope, being overwritten, or being passed to del removes one. When the count reaches zero, the object's finalize() method runs (if it has one), the references it holds in its fields are released in turn, and the memory is freed (Language Reference §1-8.1.1, §3-2.4). There is no garbage collector and no pause: an object goes away at the moment its last reference does.

Several features exist to avoid reference-count traffic where it is not needed. A parameter without a verb (reads) borrows the caller's reference without counting it (chapter 15); borrow makes that explicit (chapter 16); and a unique value has exactly one owner and is moved instead of counted (chapter 17).

finalize() and del

A class that owns an outside resource can declare func finalize(): void to release it. del name; calls finalize() immediately and makes the binding unusable (Language Reference §1-6.11):

class TempFile {
    pub let path: string;

    new(path: string) {
        this.path = path;
        println(i"create {$ path $}");
    }

    func finalize(): void {
        println(i"delete {$ this.path $}");
    }
}

func main(): void {
    let scratch = TempFile("/tmp/report.part");
    println(i"writing {$ scratch.path $}");
    del scratch;
    println("done");
}

Listing 9-6: Releasing a resource with del

create /tmp/report.part
writing /tmp/report.part
delete /tmp/report.part
done

Using the binding after del is caught at compile time:

This code does not compile
@mode(host.native)

class TempFile {
    pub let path: string;
    new(path: string) { this.path = path; }
    func finalize(): void { println(i"delete {$ this.path $}"); }
}

func main(): void {
    let scratch = TempFile("/tmp/report.part");
    del scratch;
    println(scratch.path);
}
main.lphc:12:13: error[E_USE_AFTER_DEL]: E_USE_AFTER_DEL: use of binding 'scratch' after `del` — the binding is uninitialized once it has been passed to `del` (§1-6.11)

del runs finalize() even if other references to the object exist, so make finalize() safe to call more than once. For a resource whose lifetime matches a block, with (chapter 5) is usually clearer than del.

Where loup run differs. The interpreter does not run finalize() when a reference count reaches zero; it runs it only for del (roadmap finding F4). In Listing 9-6, if you remove the del, a native binary still deletes the file when scratch goes out of scope at the end of main, but loup run never prints the "delete" line. Do not test cleanup that depends on scope exit with loup run; use del or with, which behave the same everywhere.

Breaking cycles with weak

Reference counting cannot free a cycle: if a parent holds its children and each child holds its parent, every count stays above zero after the last outside reference is gone. The specified answer is a weak reference, which does not count and reads as None once its target has been freed. A weak field has an Option type, and you assign it with Some(...):

class TreeNode {
    pub let label: string;
    pub var children: list[TreeNode] = [];
    pub weak var parent: TreeNode?;

    new(label: string) { this.label = label; }
}

safe func attach(edits parent: TreeNode, edits child: TreeNode): void {
    child.parent = Some(parent);
    parent.children.push(child);
}

func main(): void {
    let docs = TreeNode("docs");
    let intro = TreeNode("intro");
    attach(edits: docs, edits: intro);
    match intro.parent {
        Some(p) => println(i"{$ intro.label $} is under {$ p.label $}"),
        None => println("no parent"),
    }
}

Listing 9-7: A tree with weak parent links

intro is under docs

Assigning a plain object to a weak field is a type error (cannot assign 'TreeNode' to 'Option[TreeNode]'), which differs from the Language Reference's examples; write Some(parent).

Known gap. In Loup 1.25.1 weak (and unowned) fields are accepted and type-checked, but a native binary stores them as ordinary strong references, so a parent/child cycle like the one above is never freed (roadmap finding F2), and the interpreter never frees anything at a count of zero. In both, a weak reference never becomes None:

This code does not do what it should
func detachedLeaf(): TreeNode {
    let root = TreeNode("root");
    let leaf = TreeNode("leaf");
    attach(edits: root, edits: leaf);
    return leaf;    # nothing outside this function refers to root any more
}

func main(): void {
    let leaf = detachedLeaf();
    match leaf.parent {
        Some(p) => println(i"leaf still sees {$ p.label $}"),
        None => println("the parent was freed"),
    }
}
leaf still sees root

Under the specified semantics root is freed when detachedLeaf returns and the program prints "the parent was freed". Keep declaring back-references weak, which states the design and will be correct when F2 is fixed, but for a long-running native program that creates and drops many such structures, also break the links yourself (clear children or parent) when you discard a structure.

Chapter 18 covers unowned[~name], the specified compile-time alternative to weak.

local and shared classes

A plain class is a local class: an object meant to be used by one thread. A shared class is meant to be passed between threads, and every field it declares must be Shareable (Language Reference §1-8.1.1, runtime §2-2.2). Strings and growable collections are not:

This code does not compile
@mode(host.native)

shared class Stats {
    pub var requests: int = 0;
    pub var paths: list[string] = [];
}

func main(): void {
    let s = Stats();
}
main.lphc:5:5: error[E_SHARED_FIELD_NOT_SHAREABLE]: E_SHARED_FIELD_NOT_SHAREABLE: field `Stats.paths` is not Shareable (`list[T]` is a growable collection and is never Shareable (§2-2.2) — use `SharedArray[T]` or `SharedQueue[T]`) — every field of a `shared class` must satisfy `core.Shareable` (§2-2.2)

The specified way to create a shared instance is shared Stats(), which yields a SharedRef[Stats]. A native build accepts it, but in 1.25.1 loup run stops with internal interpreter limitation: unsupported instruction: NewSharedObject. Chapter 20 covers sharing objects between threads, including the synchronized classes introduced in chapter 15.

⚠ Planned. arc let and arc var, which mark a binding as explicitly reference-counted for readers and for the --require-explicit-arc lint, are specified (Language Reference §3-2.4.8) but do not parse in 1.25.1: arc let n = Node(1); is error[E0003]: expected ';'. A plain let or var holding a class instance is already reference-counted.

Summary

  • Class fields are declared let or var (E_BARE_FIELD otherwise) and are private unless pub; new(...) is the constructor, or the compiler generates one that takes the fields by name.
  • Class instances are shared by reference; structs are copied.
  • Methods that write var fields are modifier, and calling them (or writing a field) on an ordinary instance needs safe { } or a safe func (E_SAFE_CONTEXT_REQUIRED).
  • class Dog : Animal inherits; super(...) calls the base constructor; replacing a method requires override (E_OVERRIDE_REQUIRED).
  • Native binaries free an object and run finalize() when its count reaches zero; loup run runs finalize() only on del. del finalizes immediately, and a later use is E_USE_AFTER_DEL.
  • weak var parent: T? is assigned with Some(x). In 1.25.1 weak references are kept strong, so cycles leak; break them by hand in long-running programs.
  • shared class fields must be Shareable; arc let is ⚠ planned.
  • Chapter 10 covers interfaces.