The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  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 Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

Chapter 9: Classes and Objects

A struct (Chapter 7) is a value: two Point(x = 3, y = 4) are the same point, and assigning one copies it. A class describes an object with identity: two shopping carts holding the same items are still two carts, and assigning a cart to a second name gives you a second name for the same cart.

This chapter covers declaring classes, constructors, the modifier/safe rules for changing an object's state, inheritance, runtime type checks, static members, the ownership verbs on class parameters, cleanup with del, weak fields, and generic classes. The normative text is in the Language Reference §1-8 (classes) and §3-2.4.9–§3-2.4.13 (ownership verbs, modifier, safe, synchronized).

How class instances are managed. Every Loup mode presents ARC semantics for class instances: an instance is shared by reference and released when its last strong reference goes away (Language Reference §1-8.1.1). That holds for web.compiled and web.scripted too. On the Wasm target, instances are host-managed references, not Wasm GC objects; Wasm GC is a possible future implementation strategy, not what Loup 1.25 emits (Language Reference §3-1). If you have read older Loup material that said "web code is garbage-collected, so cycles are free", that is not how Loup works today; see "Reference cycles and weak" below.

Declaring a class

@mode(web.compiled)

class Counter {
    pub var count: int;

    new(initial: int) {
        this.count = initial;
    }

    pub modifier func increment(): void {
        this.count = this.count + 1;
    }

    pub func get(): int {
        return this.count;
    }
}

func main(): void {
    let clicks = Counter(0);
    safe {
        clicks.increment();
        clicks.increment();
    }
    println(clicks.get());

    let alias = clicks;          # a second name for the same object
    safe { alias.increment(); }
    println(clicks.get());
    println(clicks);
}

Listing 9-1: A class with a constructor, a mutating method, and a read-only method

2
3
Counter(count: 3)

The pieces:

  • class Counter { … } declares the class. With no : clause it extends Any, the root of every class hierarchy.
  • pub var count: int; is a field. Every class field is declared with let (assigned once, in the constructor) or var (reassignable). Fields are private to the class unless marked pub.
  • new(initial: int) { … } is the constructor. It has no return type, and a class has at most one. Inside it, this is the object being built. Write this.count, not a bare count.
  • pub modifier func increment() writes a var field, so it must be declared modifier. Calling a modifier method on an ordinary class instance must happen inside safe { … } or a safe func. The rule and its reasons are below.
  • pub func get() only reads, so it needs neither.
  • let alias = clicks; copies the reference, not the object. alias.increment() changes the object both names point at. Compare Chapter 7, where let b = a; on a struct made an independent copy.
  • println(clicks) renders the object structurally: the class name and each field.

Fields need let or var

If you come from TypeScript, a bare count: int; looks like a field declaration. In Loup it is an error:

This code does not compile
@mode(web.compiled)

class Counter {
    count: int;
}

func main(): void {
    println("never runs");
}
main.lpweb:4:5: error[E0001]: E_BARE_FIELD: class field `count` must be declared `let` or `var`; write `var count: ...` (mutable) or `let count: ...` (immutable)

The compiler wants you to say whether the field can change after construction. Pick var for a counter:

@mode(web.compiled)

class Counter {
    pub var count: int = 0;
}

func main(): void {
    println(Counter().count);
}
0

A field with a default value (= 0) does not need to be set in a constructor. This class has no new at all, so Loup generates a constructor for it (see "Constructors" below).

Fields are private unless pub

A field without pub can be read and written only inside the class's own methods and those of its subclasses (ruling FPRIV-1). A function in the same file cannot reach it:

This code does not compile
@mode(web.compiled)

class Account {
    var balance: int = 0;
}

func main(): void {
    let a = Account();
    println(a.balance);
}
main.lpweb:9:13: error[E0001]: member 'balance' of class 'Account' is private

Either mark the field pub, or keep it private and expose a method. The second is usually better for state you want to protect:

@mode(web.compiled)

class Account {
    var balance: int = 0;

    pub func current(): int {
        return this.balance;
    }
}

func main(): void {
    let a = Account();
    println(a.current());
}
0

Methods and module-level declarations without pub are private to the module (file) instead; there is no protected, pub(package) or pub(module) (Language Reference §1-8.2).

Constructors

A class with an explicit new(...) is built by calling the class name with that constructor's arguments, positionally or by name: Counter(0) or Counter(initial = 0).

A class with no new gets a generated constructor that takes each field by name, including inherited fields (ruling IMPCTOR-1). Fields with a default are optional:

@mode(web.compiled)

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

class Savings : Account {
    pub let rate: float = 0.02;
}

func main(): void {
    let checking = Account(owner = "ann");
    let savings = Savings(owner = "bo", balance = 100);
    println(checking);
    println(savings);
}

Listing 9-2: Generated constructors fill fields by name, inherited fields included

Account(owner: "ann", balance: 0)
Savings(owner: "bo", balance: 100, rate: 0.02)

Leaving out a field that has no default is an error, because the object would be incomplete:

This code does not compile
@mode(web.compiled)

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

func main(): void {
    let a = Account(balance = 5);
    println(a);
}
main.lpweb:9:13: error[E0001]: missing required field 'owner' in 'Account' constructor call

Pass owner = "…", or give the field a default.

Changing an object's state: modifier and safe

Because class instances are shared by reference, a method that changes a field changes it for every holder of that reference. Loup makes such methods visible in two places: on the declaration (modifier) and at the call (safe). The rules are in the Language Reference §3-2.4.11 and §3-2.4.12.

First, a method that writes a var field must say so:

This code does not compile
@mode(web.compiled)

class Counter {
    pub var count: int = 0;

    pub func increment(): void {
        this.count = this.count + 1;
    }
}

func main(): void {
    let c = Counter();
    c.increment();
    println(c.count);
}
main.lpweb:7:9: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: writing to `var` field 'count' of `self` in method 'increment' of class 'Counter' requires the method to be declared as `modifier`

Add modifier, and the compiler moves on to the call site:

This code does not compile
@mode(web.compiled)

class Counter {
    pub var count: int = 0;

    pub modifier func increment(): void {
        this.count = this.count + 1;
    }
}

func main(): void {
    let c = Counter();
    c.increment();
    println(c.count);
}
main.lpweb:13:5: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'increment' called on ARC receiver of type 'Counter' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`

A safe context is where Loup checks that a mutation does not happen through a reference somebody else is also using. You provide one in either of two ways:

@mode(web.compiled)

class Counter {
    pub var count: int = 0;

    pub modifier func increment(): void {
        this.count = this.count + 1;
    }
}

safe func incrementTwice(edits c: Counter): void {
    c.increment();
    c.increment();
}

func main(): void {
    let c = Counter();
    safe { c.increment(); }          # 1. a safe block around the call
    incrementTwice(edits: c);        # 2. a safe func, which takes the requirement on itself
    println(c.count);
}

Listing 9-3: Calling a modifier method from a safe block and from a safe func

3

The same rule covers assigning a pub var field from outside the class: c.count = 5; must also be inside safe { … }. Stage0's message for that case calls the field a "modifier method", but the fix is the same.

Not checked yet. Inside a safe context, the Language Reference also forbids calling a modifier while another alias of the receiver is live (E_SAFE_ALIAS_MUTABLE). Loup 1.25 does not check that yet: in Listing 9-1, alias.increment() compiles even though clicks is still in scope. Treat the rule as a design constraint you follow by hand.

Inheritance

A class extends at most one parent class, written after a colon. The subclass constructor calls the parent's constructor with super(...), and a method that replaces a parent method is marked override:

@mode(web.compiled)

class Shape {
    pub let name: string;

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

    pub func area(): float {
        return 0.0;
    }

    pub func describe(): string {
        return i"{$ this.name $} with area {$ this.area() $}";
    }
}

class Circle : Shape {
    pub let radius: float;

    new(radius: float) {
        super("circle");
        this.radius = radius;
    }

    pub override func area(): float {
        return 3.14159 * this.radius * this.radius;
    }
}

class Rect : Shape {
    pub let w: float;
    pub let h: float;

    new(w: float, h: float) {
        super("rect");
        this.w = w;
        this.h = h;
    }

    pub override func area(): float {
        return this.w * this.h;
    }
}

func main(): void {
    let shapes: list[Shape] = [Circle(1.0), Rect(2.0, 3.0)];
    for (s in shapes) {
        println(s.describe());
    }
}

Listing 9-4: A small shape hierarchy with virtual dispatch

circle with area 3.14159
rect with area 6

describe() is written once, in Shape, and calls this.area(). Dispatch is virtual: each object runs its own class's area(), even through a variable of type Shape.

Leave out override and the compiler refuses, because an accidental name clash with a parent method is a common source of bugs:

This code does not compile
@mode(web.compiled)

class Shape {
    pub func area(): float {
        return 0.0;
    }
}

class Square : Shape {
    pub let side: float = 1.0;

    pub func area(): float {
        return this.side * this.side;
    }
}

func main(): void {
    println(Square().area());
}
main.lpweb:12:5: error[E_OVERRIDE_REQUIRED]: method `area` on class `Square` shadows a parent method on `Shape` and must be marked `override` (E_OVERRIDE_REQUIRED)

Writing pub override func area() fixes it. The reverse mistake, override on a method with nothing to override, is E_OVERRIDE_NO_TARGET.

Two more inheritance tools:

  • super.method(args) calls the parent's version from inside an override.
  • final class cannot be extended (E_FINAL_EXTEND), and final func cannot be overridden.
@mode(web.compiled)

class Logger {
    pub func format(msg: string): string {
        return "[log] " + msg;
    }
}

class TimedLogger : Logger {
    pub override func format(msg: string): string {
        return "12:00 " + super.format(msg);
    }
}

func main(): void {
    let log: Logger = TimedLogger();
    println(log.format("cart saved"));
}
12:00 [log] cart saved

⚠ Planned. The Language Reference (§1-8.3) lets an abstract class declare body-less methods (abstract func area(): float;) that subclasses implement without override. Stage0 1.25.1 rejects the body-less declaration ("method 'area' may not return a value on all code paths"). Until it lands, give the base method a body as in Listing 9-4, or describe the contract with an interface (Chapter 10).

Checking an object's runtime type: is and as?

x is T asks whether x's runtime class is T or a subclass of it. The Language Reference (§1-4.7) says the compiler then narrows x to T inside the if. Loup 1.25 does not narrow yet, so the TypeScript-style code below fails:

This code does not compile
@mode(web.compiled)

func label(s: Shape): string {
    if (s is Circle) {
        return i"circle, r = {$ s.radius $}";
    }
    return s.name;
}

func main(): void {
    println(label(Circle(2.5)));
}
main.lpweb:5:33: error[E0001]: type 'Shape' has no member 'radius'

Inside the if, s still has the static type Shape. Convert it explicitly with as?, which gives Some(value) when the runtime type matches and None otherwise:

@mode(web.compiled)

class Shape {
    pub let name: string;
    new(name: string) { this.name = name; }
}

class Circle : Shape {
    pub let radius: float;
    new(radius: float) { super("circle"); this.radius = radius; }
}

class Rect : Shape {
    new() { super("rect"); }
}

func label(s: Shape): string {
    match s as? Circle {
        Some(c) => { return i"circle, r = {$ c.radius $}"; },
        None => { return s.name; },
    }
}

func main(): void {
    println(label(Circle(2.5)));
    println(label(Rect()));
    println(Rect() is Shape);
}

Listing 9-5: as? turns a runtime type check into an Option

circle, r = 2.5
rect
true

Two cautions for Loup 1.25:

  • The Language Reference treats instanceof as a synonym of is. In Stage0 1.25.1, instanceof in an expression crashes the parser (a Rust panic, exit status 101). Write is.
  • x as T (without ?) is specified to throw TypeError on a mismatch. Stage0 does not check: the cast succeeds and the program fails later, at the first field access, with an internal interpreter error. Use as? and handle None.

Static members

static fields and methods belong to the class rather than to an instance. A common use is a named factory; another is a per-class counter:

@mode(web.compiled)

class Ticket {
    static var issued: int = 0;
    pub let number: int;

    new() {
        Ticket.issued = Ticket.issued + 1;
        this.number = Ticket.issued;
    }

    pub static func count(): int {
        return Ticket.issued;
    }
}

class Temperature {
    pub static let FREEZING_K: float = 273.15;
    pub let kelvin: float;

    new(kelvin: float) {
        this.kelvin = kelvin;
    }

    pub static func fromCelsius(c: float): Temperature {
        return Temperature(c + Temperature.FREEZING_K);
    }

    pub func celsius(): float {
        return this.kelvin - Temperature.FREEZING_K;
    }
}

func main(): void {
    let a = Ticket();
    let b = Ticket();
    println(i"tickets {$ a.number $} and {$ b.number $}, {$ Ticket.count() $} issued");

    let room = Temperature.fromCelsius(21.5);
    println(room.kelvin);
}

Listing 9-6: A static counter and a static factory method

tickets 1 and 2, 2 issued
294.65

Static members are reached through the class name: Ticket.count(), Temperature.FREEZING_K.

Ownership verbs on class parameters

A parameter's verb says what a function may do with an argument (Language Reference §3-2.4.9). The same four verbs apply to every type; with classes they matter most, because the argument is a reference to an object the caller still holds.

Verb The function may Caller writes
reads (the default when no verb is written) read the object f(cart)
edits change the object f(edits: cart)
takes keep or consume it; the caller may not use it afterwards f(takes: cart)
shares keep another reference to it f(shares: cart)
@mode(web.compiled)

class Cart {
    pub var items: list[string] = [];
    pub var total: int = 0;

    pub modifier func add(name: string, cents: int): void {
        this.items.push(name);
        this.total = this.total + cents;
    }

    pub func count(): int {
        return this.items.len();
    }
}

func summary(cart: Cart): string {               # reads, implicitly
    return i"{$ cart.count() $} items, {$ cart.total $} cents";
}

safe func addGiftWrap(edits cart: Cart): void {
    cart.add("gift wrap", 300);
}

func checkout(takes cart: Cart): int {
    return cart.total;
}

func main(): void {
    let cart = Cart();
    safe {
        cart.add("book", 1500);
        cart.add("pen", 200);
    }
    println(summary(cart));
    addGiftWrap(edits: cart);
    println(summary(cart));
    let paid = checkout(takes: cart);
    println(i"paid {$ paid $}");
}

Listing 9-7: reads, edits and takes on a class parameter

2 items, 1700 cents
3 items, 2000 cents
paid 2000

Each verb has a mistake that goes with it. The listings below use the Cart class from Listing 9-7 (hidden at the end of each listing).

Changing an object you only reads. A parameter with no verb is read-only:

This code does not compile
@mode(web.compiled)

func applyCoupon(cart: Cart): void {
    safe { cart.add("coupon", -100); }
}

func main(): void {
    let cart = Cart();
    applyCoupon(cart);
    println(cart.total);
}
main.lpweb:4:12: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 'cart' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (`modifier` method `add` called on it); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first

The message tells you the fix: declare the parameter edits cart: Cart and call applyCoupon(edits: cart).

Forgetting the acknowledgement at the call site. With edits declared, a plain addGiftWrap(cart) is refused. The caller has to write the verb too, so that a reader of main can see which calls change cart:

This code does not compile
@mode(web.compiled)

safe func addGiftWrap(edits cart: Cart): void {
    cart.add("gift wrap", 300);
}

func main(): void {
    let cart = Cart();
    addGiftWrap(cart);
    println(cart.total);
}
main.lpweb:9:17: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'cart' requires 'edits' verb acknowledgment at call site

Write addGiftWrap(edits: cart);.

edits without safe func. The Language Reference (§3-2.4.12) says a function with an edits parameter is implicitly a safe func. Stage0 1.25.1 does not apply that rule yet, so leaving off safe fails at the first modifier call inside:

This code does not compile
@mode(web.compiled)

func addGiftWrap(edits cart: Cart): void {
    cart.add("gift wrap", 300);
}

func main(): void {
    let cart = Cart();
    addGiftWrap(edits: cart);
    println(cart.total);
}
main.lpweb:4:5: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'add' called on ARC receiver of type 'Cart' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`

Write safe func addGiftWrap(...), as in Listing 9-7. That spelling is also correct under the specified rule, so it will keep working.

Using an object after takes. takes hands the object over. The caller's binding is consumed, and using it again is an error:

This code does not compile
@mode(web.compiled)

func checkout(takes cart: Cart): int {
    return cart.total;
}

func main(): void {
    let cart = Cart();
    let paid = checkout(takes: cart);
    println(paid);
    println(cart.total);
}
main.lpweb:11:13: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'cart' was consumed by a `takes` parameter and cannot be used again

Read everything you need from the cart before handing it to checkout, or have checkout return what you need afterwards (here, it already returns the total).

synchronized classes

A synchronized class guards every instance with a runtime lock, so its modifier methods can be called without a safe context (Language Reference §3-2.4.13). In the browser, Loup code runs on one thread, so the lock matters only for code that is shared with multi-threaded host builds. It is shown here because you will meet it in libraries:

@mode(web.compiled)

synchronized class HitCounter {
    var hits: int = 0;

    pub modifier func hit(): void {
        this.hits = this.hits + 1;
    }

    pub modifier func total(): int {     # even a read of a `var` field takes the lock
        return this.hits;
    }
}

func main(): void {
    let page = HitCounter();
    page.hit();                      # no safe {} needed: the lock provides exclusivity
    page.hit();
    println(page.total());
}
2

Inside a synchronized class, every method that touches a var field, even only to read it, must be a modifier, so that the access happens under the lock. A plain func total() here is rejected with E_MODIFIER_REQUIRED ("reading var field 'hits' … is unsynchronized"). For the same reason, keep the mutable fields private: the compiler warns (E_PUB_VAR_ON_SYNCHRONIZED) about a pub var, because reading or writing it from outside would bypass the lock.

unique values in web code

unique marks a value with exactly one owner: assigning it moves it (Language Reference §3-2.4.7). The Language Reference allows both unique let bindings and whole unique class declarations in every mode, but Stage0 1.25.1 accepts unique class only in host.native and freestanding:

This code does not compile
@mode(web.compiled)

unique class Session {
    pub let user: string;
    new(user: string) { this.user = user; }
}

func main(): void {
    unique let s = Session("ann");
    println(s.user);
}
main.lpweb:3:1: error[E_UNIQUE_CLASS_ARC_BINDING]: E_UNIQUE_CLASS_ARC_BINDING: `unique class 'Session'` is only valid in `@mode(native)` or `@mode(freestanding)`

(The message still spells the native mode by its retired name; the current name is host.native.) In web code, declare an ordinary class and put unique on the binding instead:

@mode(web.compiled)

class Session {
    pub let user: string;
    new(user: string) { this.user = user; }
}

func main(): void {
    unique let s = Session("ann");
    let current = s;                 # moves the session; `s` is no longer usable
    println(current.user);
}
ann

Reading s.user after the move would be E_USE_AFTER_MOVE. Chapter 20 returns to moves and borrowing.

Cleanup: del and finalize()

A class that holds something that must be released (a connection, a subscription, a timer) declares a finalize() method. Declaring it makes the class Finalizable. del x runs finalize() at that point and ends the binding (Language Reference §1-6.11, §1-8.9):

@mode(web.compiled)

class Subscription {
    pub let topic: string;

    new(topic: string) {
        this.topic = topic;
        println(i"subscribed to {$ topic $}");
    }

    pub func finalize(): void {
        println(i"unsubscribed from {$ this.topic $}");
    }
}

func main(): void {
    let prices = Subscription("prices");
    println("rendering prices");
    del prices;
    println("done");
}

Listing 9-8: del runs finalize() deterministically

subscribed to prices
rendering prices
unsubscribed from prices
done

After del, the name is gone:

This code does not compile
@mode(web.compiled)

class Subscription {
    pub let topic: string;
    new(topic: string) { this.topic = topic; }
    pub func finalize(): void { println("unsubscribed"); }
}

func main(): void {
    let prices = Subscription("prices");
    del prices;
    println(prices.topic);
}
main.lpweb:12:13: error[E_USE_AFTER_DEL]: E_USE_AFTER_DEL: use of binding 'prices' after `del` — the binding is uninitialized once it has been passed to `del` (§1-6.11)

Under the specified ARC rules, finalize() also runs when the last strong reference goes away, for example when a function that created the object returns. On the Loup 1.25 interpreter it does not: an object that goes out of scope without del is never finalized. Call del for any cleanup that must happen. (A with block, Chapter 5, also gives scope-bound cleanup, but only for types that implement Scoped[T].)

Reference cycles and weak

Under ARC semantics, two objects that hold strong references to each other keep each other alive. A tree whose children point back to their parent is the usual example. The specified fix is to make the back-pointer weak: it does not keep its target alive, and reads as None once the target is released (Language Reference §1-8).

@mode(web.compiled)

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

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

func main(): void {
    let menu = TreeNode("menu");
    let item = TreeNode("settings");
    safe {
        item.parent = Some(menu);
        menu.children.push(item);
    }
    println(menu.children.len());
    match item.parent {
        Some(p) => println(i"parent: {$ p.label $}"),
        None => println("parent released"),
    }
}

Listing 9-9: A weak back-pointer from child to parent

1
parent: menu

The weak field has an optional type (TreeNode?) and is assigned Some(menu).

What works and what does not in web modes today:

  • weak fields compile and run in web.compiled and web.scripted, on the interpreter and in a --target browser Wasm build. The Language Reference disagrees with itself here: the mode matrix (§1-2.6) lists weak for both web modes, while §3-1.2 says the Wasm target has no zeroing side table for it. Follow the matrix, but note the next point.
  • On the interpreter, a weak field is not cleared when its target's last strong reference goes away; it behaves like a strong reference. Write weak where the design calls for it, and do not rely on reading None.
  • Closure capture lists (func() [weak self] { … }) are refused in web modes: "capture lists are only allowed in @compiled files targeting --target native".

⚠ Planned. arc let / arc var (advisory pinning, Language Reference §1-8) are in the mode matrix for web modes but do not parse in Loup 1.25 (error[E0003]: expected ';').

Generic classes

A class can take type parameters in square brackets, like the built-in list[T] and dict[K, V]:

@mode(web.compiled)

class Stack[T] {
    var items: list[T] = [];

    pub modifier func push(value: T): void {
        this.items.push(value);
    }

    pub modifier func pop(): Option[T] {
        if (this.items.len() == 0) {
            return None;
        }
        return Some(this.items.pop());
    }

    pub func size(): int {
        return this.items.len();
    }
}

func main(): void {
    let undo: Stack[string] = Stack();
    safe {
        undo.push("type 'a'");
        undo.push("type 'b'");
        undo.push("delete line");
    }
    println(undo.size());
    safe {
        println(undo.pop().unwrap_or("nothing to undo"));
        println(undo.pop().unwrap_or("nothing to undo"));
    }
    println(undo.size());

    let empty: Stack[int] = Stack();
    safe { println(empty.pop().unwrap_or(-1)); }
}

Listing 9-10: A generic undo stack

3
delete line
type 'b'
1
-1

Give the binding its full type (let undo: Stack[string] = Stack();). In Stage0 1.25.1, explicit type arguments on the constructor call (Stack[string]()) are not applied: the call type-checks against an unresolved parameter and fails with messages such as expected 'T0', found 'string'. Chapter 11 covers type parameters and their bounds.

A worked example: an observable store

Here is a class you might write in a web app: a store that holds a value and notifies listeners when it changes. It combines a generic class, a list of function values, modifier methods and a closure that updates an outer variable, which must declare it nonlocal (closures are covered in Chapter 4).

@mode(web.compiled)

class Store[T] {
    var value: T;
    var listeners: list[fn(T): void] = [];

    new(initial: T) {
        this.value = initial;
    }

    pub func get(): T {
        return this.value;
    }

    pub modifier func subscribe(listener: fn(T): void): void {
        this.listeners.push(listener);
    }

    pub modifier func set(next: T): void {
        this.value = next;
        for (listener in this.listeners) {
            listener(next);
        }
    }
}

func main(): void {
    let cartSize: Store[int] = Store(0);
    var renders = 0;
    safe {
        cartSize.subscribe(func(n: int): void {
            println(i"badge shows {$ n $}");
        });
        cartSize.subscribe(func(n: int): void {
            nonlocal renders;
            renders = renders + 1;
        });
        cartSize.set(1);
        cartSize.set(3);
    }
    println(i"current: {$ cartSize.get() $}, renders: {$ renders $}");
}

Listing 9-11: A generic observable store

badge shows 1
badge shows 3
current: 3, renders: 2

The store has identity (every part of the app that holds it sees the same value), its listener list is changed in place, and every change goes through a modifier method. That is the profile of a class rather than a struct. The reactive signal type planned for browser UI (Chapter 16) follows the same pattern.

Try it yourself

  1. Add a pub modifier func remove(name: string, cents: int) to Cart and a safe func that applies a discount through an edits parameter. Then call it without edits: and read the diagnostic.
  2. Give Shape a perimeter() method and override it in Circle and Rect. Remove one override keyword and read the error.
  3. Add an unsubscribe to Store that removes the most recently added listener, and check with renders that it stops being called.

Summary

  • A class has identity; assigning or passing an instance shares the same object. Every mode, the web modes included, presents ARC semantics for class instances.
  • Fields are declared with let or var (a bare field is E_BARE_FIELD) and are private to the class unless pub.
  • new(...) is the constructor; without one, Loup generates a constructor that takes the fields by name.
  • A method that writes a field is a modifier, and calling it on an ordinary instance requires safe { … } or a safe func.
  • Single inheritance with : Parent, super(...), super.method(), and mandatory override. Body-less abstract methods are planned.
  • Use is and as? for runtime type checks. Loup 1.25 does not narrow after is, and instanceof crashes the Stage0 parser.
  • Parameter verbs: reads by default, edits and takes acknowledged at the call site. Stage0 needs an explicit safe func for a function that mutates through edits.
  • del runs finalize() deterministically; the interpreter does not finalize objects that simply go out of scope.
  • weak fields compile in web modes but are not cleared on the interpreter; capture lists and arc let are not available.
  • Generic classes take [T]; annotate the binding (let s: Stack[int] = Stack();).