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 15: Ownership verbs

Every parameter in Loup carries a verb that says what the function will do with the argument: read it, change it in place, take it over, or keep a reference to it. The verb is part of the signature, and for three of the four verbs the caller repeats it at the call site, so you can see at a glance which calls can change your data. The compiler checks both ends.

This chapter covers the four parameter verbs (reads, edits, takes, shares), the modifier keyword for methods that change this, safe { } blocks and safe func, requires clauses, and synchronized classes. All of them are compile-time checks that work the same in every mode, so a program checked with loup run gets exactly the same diagnostics as one built with loup build --target native. The normative text is in the Language Reference §3-2.4.9 through §3-2.4.13.

The four verbs

Verb The function may The caller writes After the call the caller has
reads (the default) read the value nothing, or reads: the value, unchanged
edits read and change the value in place edits: the value, with the changes
takes own the value takes: nothing — the binding is consumed
shares keep a reference after it returns shares: the value, now also held elsewhere

The verb goes in front of the parameter name: func grow(edits xs: list[int]). The acknowledgement at the call site looks like a named argument with the verb as its label: grow(edits: xs).

reads — the default

A parameter with no verb is a reads parameter. The function can look at the value but not change it, and the caller keeps it.

@mode(host.native)

func requestMethod(line: string): string {
    let space = line.indexOf(" ");
    if (space < 0) {
        return line;
    }
    return line.substring(0, space);
}

func main(): void {
    let line = "GET /api/users HTTP/1.1";
    println(requestMethod(line));          # implicit reads
    println(requestMethod(reads: line));   # the same call, spelled out
    println(line);                         # still ours
}

Listing 15-1: A reads parameter needs no annotation at either end

GET
GET
GET /api/users HTTP/1.1

Writing reads explicitly is allowed on both sides and changes nothing; it is useful in a signature where the other parameters carry verbs. A reads parameter of a class type costs no ARC traffic: the callee borrows the caller's reference instead of retaining it.

"Read-only" is enforced. Here is a helper that tries to add a tag to the list it was given:

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

func addDefaultTag(tags: list[string]): void {
    if (not tags.contains("triaged")) {
        tags.push("triaged");
    }
}

func main(): void {
    var tags = ["billing"];
    addDefaultTag(tags);
    println(tags);
}
main.lphc:5:9: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 'tags' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (`push` called on it); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first

The message names the parameter, says why it is read-only (it has no verb), names the offending operation, and offers both fixes. The same error fires for assigning to a field of a reads struct or class parameter, and for calling a modifier method on a reads class parameter. Assigning to the parameter itself (tags = [...]) is a different error — parameters are immutable bindings — reported as cannot assign to immutable binding 'tags'.

edits — changing the caller's value in place

If the function's job is to change the caller's value, say so with edits:

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

func addDefaultTag(edits tags: list[string]): void {
    if (not tags.contains("triaged")) {
        tags.push("triaged");
    }
}

func main(): void {
    var tags = ["billing"];
    addDefaultTag(tags);
    println(tags);
}
main.lphc:11:19: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'tags' requires 'edits' verb acknowledgment at call site

Changing the signature is not enough: every caller must now acknowledge that the call changes its list. This is the point of the verb system — a reader of main sees which calls can modify tags without opening the callee. Add the acknowledgement:

@mode(host.native)

func addDefaultTag(edits tags: list[string]): void {
    if (not tags.contains("triaged")) {
        tags.push("triaged");
    }
}

func main(): void {
    var tags = ["billing"];
    addDefaultTag(edits: tags);
    addDefaultTag(edits: tags);    # already tagged: no change
    println(tags);
}

Listing 15-2: An edits parameter and its call-site acknowledgement

["billing", "triaged"]

The acknowledgement has to name the right verb. Writing takes: where the parameter is edits is a mismatch:

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

func addDefaultTag(edits tags: list[string]): void {
    tags.push("triaged");
}

func main(): void {
    var tags = ["billing"];
    addDefaultTag(takes: tags);
}
main.lphc:9:26: error[E0001]: E_VERB_MISMATCH: call-site verb 'takes' does not match parameter 'tags' declared as 'edits'

edits on structs

Structs are values: assigning one copies it (chapter 7). edits is how a function works on the caller's struct without copying it. A small tokenizer keeps its position in a Cursor and lets helper functions move it forward:

struct Cursor {
    text: string;
    var pos: int;
}

func skipSpaces(edits c: Cursor): void {
    while (c.pos < c.text.length() and c.text[c.pos] == " ") {
        c.pos = c.pos + 1;
    }
}

func readWord(edits c: Cursor): string {
    let start = c.pos;
    while (c.pos < c.text.length() and c.text[c.pos] != " ") {
        c.pos = c.pos + 1;
    }
    return c.text.substring(start, c.pos);
}

func main(): void {
    var c = Cursor(text = "  GET   /index.html", pos = 0);
    skipSpaces(edits: c);
    let method = readWord(edits: c);
    skipSpaces(edits: c);
    let path = readWord(edits: c);
    println(i"{$ method $} {$ path $} (stopped at {$ c.pos $})");
}

Listing 15-3: Helpers that advance a struct cursor through edits

GET /index.html (stopped at 19)

Without edits, skipSpaces could only return a new Cursor for the caller to store back. With it, the helpers read like methods, and the call sites still show that c changes.

edits on class instances

A class instance is shared by reference, so edits on a class parameter means "this function writes to that object". In 1.25.1 writing a field of a class instance also needs a safe context (covered below), and a function with an edits class parameter does not get one automatically:

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

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

func deposit(edits a: Account, amount: int): void {
    a.balance = a.balance + amount;
}

func main(): void {
    let acct = Account();
    deposit(edits: acct, 25);
    println(acct.balance);
}
main.lphc:8:5: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'balance' 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 compiler treats a write to a pub var field from outside the class like a call to a modifier method, which is why the message talks about "modifier method 'balance'". Declare the function safe func:

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

safe func deposit(edits a: Account, amount: int): void {
    a.balance = a.balance + amount;
}

func main(): void {
    let acct = Account();
    deposit(edits: acct, 25);
    deposit(edits: acct, 17);
    println(acct.balance);
}

Listing 15-4: Writing a class field through an edits parameter

42

Spec and toolchain differ. The Language Reference (§3-2.4.12) says a function with an edits parameter is implicitly a safe func, so Listing 15-4 should not need the word safe. Stage0 1.25.1 still requires it. Writing safe func is correct under both readings.

What edits does not check yet

The Language Reference also specifies alias checks for edits: passing the same value as two edits arguments (E_EDITS_ALIAS), or as an edits and a reads argument of one call (E_EDITS_READS_ALIAS), is an error. The 1.25.1 compilers do not report either, and they also accept an edits: acknowledgement on a plain reads parameter. Do not rely on the compiler to catch these: a call like appendAll(edits: xs, xs) that iterates one list while growing the other compiles, and loops for ever.

takes — handing a value over

A takes parameter receives ownership. The caller's binding is consumed at the call, and the compiler rejects any later use of it. A log writer that flushes a batch and is done with it:

func flush(takes batch: list[string]): int {
    for (line in batch) {
        println(i"write: {$ line $}");
    }
    return batch.length();
}

func main(): void {
    var pending = ["boot", "ready"];
    let written = flush(takes: pending);
    println(i"{$ written $} lines flushed");
    pending = ["idle"];    # a var may be given a fresh value
    println(pending);
}

Listing 15-5: Passing a batch to a takes parameter

write: boot
write: ready
2 lines flushed
["idle"]

Forget the acknowledgement and the compiler asks for it, just as it does for edits:

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

func flush(takes batch: list[string]): int {
    return batch.length();
}

func main(): void {
    var pending = ["boot", "ready"];
    println(flush(pending));
}
main.lphc:9:19: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'batch' requires 'takes' verb acknowledgment at call site

The more interesting mistake is using the binding after handing it over:

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

func flush(takes batch: list[string]): int {
    return batch.length();
}

func main(): void {
    var pending = ["boot", "ready"];
    let written = flush(takes: pending);
    println(i"flushed {$ written $} of {$ pending.length() $}");
}
main.lphc:10:43: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'pending' was consumed by a `takes` parameter and cannot be used again

After takes:, pending holds nothing the compiler will let you read. Either read what you need before the call (let total = pending.length();), or, if the binding is a var, give it a new value first, as Listing 15-5 does. Since 1.24 only takes consumes a binding; reads, edits and shares never do.

For ordinary class instances, takes hands over the caller's reference count instead of adding one. For unique values (chapter 17) it is a true move with no reference counting at all.

Known gap. In 1.25.1 the use-after-take check covers calls to functions, but not calls to methods: after pool.giveBack(takes: conn), a later read of conn compiles. Keep takes arguments to methods as the last use of the binding.

shares — keeping a reference

shares says the function will keep a reference to the argument after it returns — store it in a field, a collection, or a closure. Both sides then hold the value. An event bus that records every event it publishes:

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

class Bus {
    pub var history: list[Event] = [];
}

safe func publish(edits bus: Bus, shares event: Event): void {
    bus.history.push(event);
}

func main(): void {
    let bus = Bus();
    let deploy = Event("deploy");
    publish(edits: bus, shares: deploy);
    publish(edits: bus, shares: Event("rollback"));
    println(deploy.name);                 # the caller still has it
    println(bus.history.length());
}

Listing 15-6: A shares parameter that is stored past the call

deploy
2

shares must be acknowledged at the call site like edits and takes; omitting it is E_VERB_REQUIRED. On a unique value, shares gives up uniqueness for good (chapter 17).

Known gap. The Language Reference says that storing or returning a plain reads parameter beyond the call is E_BORROW_ESCAPE. That check is not implemented in 1.25.1: bus.history.push(event) also compiles when event has no verb. Write shares whenever a function keeps what it is given; it documents the contract and keeps your code correct once the check lands.

modifier — methods that change this

The verbs describe parameters. For the receiver of a method, Loup uses one keyword: a class method that writes any var field of this must be declared modifier. A small inventory:

class Inventory {
    var counts: dict[string, int] = {};

    pub modifier func add(item: string, n: int): void {
        let have = this.counts.get(item).unwrap_or(0);
        this.counts.set(item, have + n);
    }

    pub func count(item: string): int {
        return this.counts.get(item).unwrap_or(0);
    }
}

func main(): void {
    let stock = Inventory();
    safe {
        stock.add("bolt", 40);
        stock.add("nut", 25);
        stock.add("bolt", 10);
    }
    println(i"bolts: {$ stock.count("bolt") $}, washers: {$ stock.count("washer") $}");
}

Listing 15-7: modifier marks the methods that change the object

bolts: 50, washers: 0

Scanning the class, you can see which methods change state without reading their bodies. The compiler holds you to it — forget modifier and the method no longer compiles:

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

class Meter {
    var reading: int = 0;

    pub func tick(): void {
        this.reading = this.reading + 1;
    }
}

func main(): void {
    let m = Meter();
}
main.lphc:7:9: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: writing to `var` field 'reading' of `self` in method 'tick' of class 'Meter' requires the method to be declared as `modifier`

modifier applies to class methods only. A struct is a value, so a struct method may assign the fields of its own copy freely, and marking it modifier is an error:

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

struct Meter {
    var reading: int;

    modifier func tick(): void {
        this.reading = this.reading + 1;
    }
}

func main(): void {
    var m = Meter(reading = 0);
}
main.lphc:6:5: error[E_MODIFIER_ON_STRUCT]: E_MODIFIER_ON_STRUCT: `modifier` keyword on method 'tick' of struct 'Meter' is not allowed — `modifier` applies only to class methods

Drop the keyword and the struct method works on any var binding (var m = Meter(reading = 0); m.tick();).

safe contexts and safe func

A class instance can be reachable through several references at once. To keep "who may change this object, and when" under the compiler's control, a call to a modifier method on an ordinary (reference-counted) instance must happen inside a safe context. Listing 15-7 put its add calls in a safe { } block; without it:

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

class Meter {
    var reading: int = 0;
    pub modifier func tick(): void { this.reading = this.reading + 1; }
    pub func value(): int { return this.reading; }
}

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

There are two fixes, and the message names both. A safe { } block anchors a safe context for a few statements; a safe func is a safe context for its whole body, so it can call modifier methods freely:

class Meter {
    var reading: int = 0;
    pub modifier func tick(): void { this.reading = this.reading + 1; }
    pub func value(): int { return this.reading; }
}

safe func tickN(edits m: Meter, n: int): void {
    for (i in 0..n) {
        m.tick();
    }
}

func main(): void {
    let m = Meter();
    safe { m.tick(); }        # anchored inline
    tickN(edits: m, 4);       # the safe func provides its own context
    println(m.value());
}

Listing 15-8: safe { } and safe func

5

The same rule applies inside a class: a non-modifier method that calls a modifier method on this gets E_SAFE_CONTEXT_REQUIRED too. Make the calling method a modifier as well — a modifier body is already a safe context for this.

Two kinds of receiver need no safe context: a unique class instance (chapter 17), whose single owner is proven at compile time, and a synchronized class instance (below), whose lock is taken at run time.

Spec and toolchain differ. The Language Reference (§3-2.4.12) specifies more than 1.25.1 checks. A safe func is supposed to be callable only from a safe context, so that the requirement propagates up the call stack like async; Stage0 lets any function call a safe func, as main does in Listing 15-8. Inside a safe context the compiler is also supposed to reject a modifier call while another alias of the receiver is live (E_SAFE_ALIAS_MUTABLE); that check is not implemented. A setter (pub set timeout(...)) is specified as an implicit modifier, but a setter call outside safe { } compiles today.

requires clauses and distinct()

A requires clause after the parameter list states contracts that span several parameters. The verb form gives a parameter its verb, exactly as writing it inline would:

func moveAll(dst: list[int], src: list[int]) requires edits(dst): void {
    for (x in src) {
        dst.push(x);
    }
}

func main(): void {
    var queue = [1, 2];
    let incoming = [3, 4];
    moveAll(edits: queue, incoming);
    println(queue);
}

Listing 15-9: requires edits(dst) is the same as edits dst

[1, 2, 3, 4]

⚠ Planned. The rest of requires is specified (Language Reference §3-2.4.10) but not yet checked: distinct(a, b), which promises that two buffers do not overlap and lets the LLVM backend mark them noalias; its errors E_DISTINCT_ALIAS and E_DISTINCT_NOT_READS_EDITS; the call-site acknowledgements edits(dst) and distinct(dst, src) written among the arguments; and requires on methods. Both compilers parse a distinct(...) contract and then ignore it, so a call that passes the same list twice is accepted. Write the clause if it documents your intent, but do not count on it.

synchronized classes

safe contexts are a compile-time proof, and the proof stops at a thread boundary. An object that several threads change needs a run-time lock, and a synchronized class provides one: every modifier method takes the instance's lock on entry and releases it on return (or when an exception unwinds). Callers need no safe { } block.

Sharing an ordinary class with a thread is rejected outright:

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

class HitCounter {
    var hits: int = 0;
    pub modifier func record(): void { this.hits = this.hits + 1; }
}

func main(): void {
    let counter = HitCounter();
    let worker = Thread.spawn(func() {
        safe { counter.record(); }
    });
    worker.join();
}
main.lphc:10:18: error[E_NON_SENDABLE_CAPTURE]: E_NON_SENDABLE_CAPTURE: captured variable 'counter' of type 'HitCounter' does not implement Sendable — every value captured by a closure passed to Thread.spawn must be Sendable
  note: v1.20 §2-1.8.1: classes are never implicitly Sendable. Declare `class HitCounter implements Sendable` (every `var` field must then be Mutex[T], RwLock[T] or Atomic[T]), or pass the data as a Sendable value.

The note offers Mutex/RwLock/Atomic fields (chapter 20). The other way is to make the class synchronized and declare it Sendable; the lock is what makes its var fields safe to share:

@mode(host.native)

synchronized class HitCounter implements Sendable {
    var hits: int = 0;

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

    pub modifier func total(): int {
        return this.hits;
    }
}

func main(): void {
    let counter = HitCounter();
    let a = Thread.spawn(func() {
        for (i in 0..1000) { counter.record(); }
    });
    let b = Thread.spawn(func() {
        for (i in 0..1000) { counter.record(); }
    });
    a.join();
    b.join();
    println(counter.total());
}

Listing 15-10: A synchronized counter shared by two threads

2000

Notice that total is a modifier although it only reads. In a synchronized class the lock is held only inside modifier methods, so an ordinary method that reads a var field could see a half-finished write. The compiler rejects it:

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

synchronized class HitCounter {
    var hits: int = 0;
    pub modifier func record(): void { this.hits = this.hits + 1; }
    pub func total(): int { return this.hits; }
}

func main(): void {
    let counter = HitCounter();
    counter.record();
    println(counter.total());
}
main.lphc:6:36: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: reading `var` field 'hits' of `self` in non-`modifier` method 'total' of `synchronized` class 'HitCounter' is unsynchronized; declare the method `modifier` so the read occurs under the lock

Fields declared let never change, so ordinary methods may read them. A pub var field on a synchronized class draws the warning E_PUB_VAR_ON_SYNCHRONIZED, because code outside the class could write it without the lock.

A few facts about running Listing 15-10:

  • Under loup run the interpreter runs threads under a global lock, so the program is correct but not parallel. A native binary uses real OS threads and the per-instance lock.
  • The lock is not reentrant: a modifier that calls another modifier on the same this would deadlock. The specified diagnostic for this, E_SYNCHRONIZED_REENTRANT, is not reported by 1.25.1, so avoid the pattern yourself.
  • Each modifier call takes and releases a lock. For a counter on a hot path, an Atomic[int] field is cheaper; chapter 20 compares the options.

Summary

  • A parameter's verb says what the function does with it: reads (default) reads, edits changes in place, takes consumes, shares keeps a reference.
  • edits, takes and shares must be acknowledged at the call site (grow(edits: xs)); a missing acknowledgement is E_VERB_REQUIRED, a wrong one E_VERB_MISMATCH.
  • Changing a reads parameter is E_READS_MUTATION; using a binding after takes: is E_USE_AFTER_TAKE.
  • A class method that writes a var field is a modifier (E_MODIFIER_REQUIRED otherwise); struct methods never are (E_MODIFIER_ON_STRUCT).
  • Calling a modifier method, or writing a field, on an ordinary class instance needs safe { } or a safe func (E_SAFE_CONTEXT_REQUIRED); unique and synchronized receivers do not.
  • requires edits(x) works; distinct() and the other requires contracts are planned.
  • A synchronized class implements Sendable is the simplest object that several threads can change; its reads of var fields must be modifier methods too.
  • Several specified checks (E_EDITS_ALIAS, E_BORROW_ESCAPE, E_SAFE_ALIAS_MUTABLE, E_SYNCHRONIZED_REENTRANT, use-after-take through methods) are not implemented in 1.25.1; the text above says where.

Chapter 16 looks at borrow, excl borrow and noescape fn, the lower-level keywords underneath the verbs.