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:
@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:
@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:
@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:
@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:
@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:
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:
@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 letandarc var, which mark a binding as explicitly reference-counted for readers and for the--require-explicit-arclint, are specified (Language Reference §3-2.4.8) but do not parse in 1.25.1:arc let n = Node(1);iserror[E0003]: expected ';'. A plainletorvarholding a class instance is already reference-counted.
Summary
- Class fields are declared
letorvar(E_BARE_FIELDotherwise) and are private unlesspub;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
varfields aremodifier, and calling them (or writing a field) on an ordinary instance needssafe { }or asafe func(E_SAFE_CONTEXT_REQUIRED). class Dog : Animalinherits;super(...)calls the base constructor; replacing a method requiresoverride(E_OVERRIDE_REQUIRED).- Native binaries free an object and run
finalize()when its count reaches zero;loup runrunsfinalize()only ondel.delfinalizes immediately, and a later use isE_USE_AFTER_DEL. weak var parent: T?is assigned withSome(x). In 1.25.1 weak references are kept strong, so cycles leak; break them by hand in long-running programs.shared classfields must be Shareable;arc letis ⚠ planned.- Chapter 10 covers interfaces.