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 16: borrow, excl borrow and noescape

Chapter 15's verbs (reads, edits, takes, shares) say what a function does with its arguments. This chapter covers three lower-level keywords that say how an argument is held:

  • borrow — the callee uses the caller's reference for the duration of the call, with no reference-count traffic;
  • excl borrow — the same, plus a compile-time guarantee that nothing else borrows the value during the call;
  • noescape fn — a function-typed parameter that the callee only calls and never keeps.

They matter most for library code and hot paths in native binaries, where they let the compiler skip retain/release operations and heap allocations. The checks themselves are compile-time rules, so loup run reports exactly the same diagnostics as loup build --target native, in host.dual as well as host.native. The normative text is in the Language Reference §3-2.4.1 and §3-2.4.1.1, with the parameter grammar in §1-7.1.

borrow parameters

A tree walk is the classic place for borrow. Every recursive call passes a node down and gets a number back; nothing keeps the node:

@mode(host.native)

class Node {
    pub let name: string;
    pub var children: list[Node] = [];
    new(name: string) { this.name = name; }
}

func countNodes(borrow node: Node): int {
    var total = 1;
    for (child in node.children) {
        total = total + countNodes(borrow child);
    }
    return total;
}

func main(): void {
    let root = Node("src");
    let lib = Node("lib");
    safe {
        lib.children.push(Node("parse.lphc"));
        lib.children.push(Node("emit.lphc"));
        root.children.push(lib);
        root.children.push(Node("main.lphc"));
    }
    println(countNodes(borrow root));
    println(countNodes(root));
}

Listing 16-1: Counting the nodes of a tree through borrow parameters

5
5

borrow goes where a verb goes, in front of the parameter name. It tells the compiler that the caller's reference outlives the call, so a native build passes the pointer as it is: no retain on entry, no release on exit. In a recursive walk over thousands of nodes, that is thousands of atomic increments and decrements saved.

At the call site, borrow is written as a prefix (countNodes(borrow child)). The prefix is optional, as the last line of main shows; write it where you want the reader to see that the call only borrows.

A borrow parameter is read-only, like a reads parameter. The difference is emphasis: chapter 15 noted that a reads parameter of class type also avoids ARC traffic, but reads describes intent and leaves the lowering to the compiler, whereas borrow states the zero-cost passing as part of the signature.

Mistake: changing a borrowed value

Because a borrow is read-only, a helper that normalizes a list in place does not compile:

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

func normalize(borrow words: list[string]): void {
    for (i in 0..words.length()) {
        words[i] = words[i].toLowerCase();
    }
}

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

The message calls the parameter a reads borrow, because that is how the checker treats borrow. Its advice is right: a function that changes the caller's value takes it with edits, and the caller acknowledges it:

func normalize(edits words: list[string]): void {
    for (i in 0..words.length()) {
        words[i] = words[i].toLowerCase();
    }
}

func main(): void {
    var words = ["GET", "Post"];
    normalize(edits: words);
    println(words);
}

Listing 16-2: Changing the caller's list needs edits, not borrow

["get", "post"]

Known gap. The Language Reference says a plain borrow may not escape the call: storing it in a field or a collection, or returning a borrow of a local, is E_BORROW_ESCAPE (§3-2.4.8, §1-7.1). Loup 1.25.1 does not check this, so cache.push(node) with a borrow node parameter compiles. If a function keeps what it is given, declare the parameter shares (chapter 15); if a borrow must outlive the call, give it a lifetime (chapter 18).

excl borrow — an exclusive borrow

excl borrow is borrow plus an exclusivity rule: while the call runs, no other borrow or excl borrow of the same binding may be active. It applies to struct parameters and to unique values (chapter 17). Because a struct's fields are all values, an exclusive borrow of a struct really is exclusive: nothing else can reach the same data (§3-2.4.1.1).

The call site passes the binding without a prefix. The two-word prefix excl borrow c does not parse at a call site.

The checker catches two kinds of conflict. The simplest is one binding passed twice to the same call:

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

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

func syncPosition(excl borrow dst: Cursor, borrow src: Cursor): void {
    dst.pos = src.pos;
}

func main(): void {
    var c = Cursor(text = "GET /", pos = 0);
    syncPosition(c, c);
}
main.lphc:14:21: error[E_EXCL_BORROW_ALIAS]: E_EXCL_BORROW_ALIAS: 'c' is passed to `excl borrow` parameter 'dst' while the same binding is simultaneously borrowed by parameter 'src' of this call — an exclusive borrow admits no other active borrow

The other is a borrow taken by an earlier statement that is still held. Here peek borrows the cursor, and its result is bound to ch before skip asks for exclusive access:

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

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

func peek(borrow c: Cursor): string {
    return c.text[c.pos];
}

func skip(excl borrow c: Cursor): void {
    c.pos = c.pos + 1;
}

func main(): void {
    var c = Cursor(text = "GET /", pos = 0);
    let ch = peek(c);
    skip(c);
    println(ch);
}
main.lphc:19:10: error[E_EXCL_BORROW_ALIAS]: E_EXCL_BORROW_ALIAS: 'c' is passed to `excl borrow` parameter 'c' while an earlier shared borrow of it is still held by 'ch' — an exclusive borrow admits no other active borrow. A borrow ends when its result is discarded; here it is still bound.

The rule is conservative: ch holds a copied string, but the checker treats the result of a borrow call that is still bound as still borrowing. End the borrow before taking the exclusive one, either by not binding the result or by binding it in an inner block:

func main(): void {
    var c = Cursor(text = "GET /", pos = 0);
    if (c.pos == 0) {
        let ch = peek(c);
        println(i"first character: {$ ch $}");
    }
    skip(c);
}

Listing 16-3: The shared borrow ends with its block, so the exclusive one is allowed

first character: G

Known gap: changes made through excl borrow stay local

The specification describes excl borrow as in-place mutation of the caller's struct. Under loup run in 1.25.1 the callee changes a copy instead, and the caller never sees the change:

This code does not do what it should
struct Cursor {
    text: string;
    var pos: int;
}

func skip(excl borrow c: Cursor): void {
    c.pos = c.pos + 1;
}

func main(): void {
    var c = Cursor(text = "GET /", pos = 0);
    skip(c);
    skip(c);
    println(c.pos);
}
0

The program should print 2. Until this is fixed, use edits for a function that must change the caller's struct, as Listing 15-3 does. edits is specified to be exclusive as well, although its alias check (E_EDITS_ALIAS) is not implemented yet (chapter 15).

Mistake: excl borrow on a class

excl borrow is refused on a class, a primitive, an enum or Any, because an ordinary class instance may have other references that the compiler cannot see:

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

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

func deposit(excl borrow a: Account, amount: int): void {
    println(a.balance + amount);
}

func main(): void {
    deposit(Account(), 10);
}
main.lphc:7:14: error[E_EXCL_BORROW_NOT_STRUCT]: E_EXCL_BORROW_NOT_STRUCT: `excl borrow` parameter 'a' has type `Account` — only struct types and `unique` types may be exclusively borrowed; use `edits` to exclusively borrow a class instance

The message names the fix: an ordinary class instance is changed through edits (with safe func, as in Listing 15-4). A unique class instance (chapter 17) has exactly one owner, so excl borrow is accepted there, and its changes are visible to the caller because the instance is a reference:

unique class Arena {
    pub var used: int = 0;
}

func reserve(excl borrow a: Arena, bytes: int): void {
    a.used = a.used + bytes;
}

func main(): void {
    let arena = Arena();
    reserve(arena, 64);
    reserve(arena, 16);
    println(arena.used);
}

Listing 16-4: excl borrow of a unique class instance

80

Two more restrictions apply to excl borrow, and both are checked: it may not appear in an async func signature (E_EXCL_BORROW_ASYNC), and a closure passed to Thread.spawn, Thread.spawnWith or ThreadPool.run may not capture it (E_EXCL_BORROW_THREAD_CAPTURE, chapter 20).

noescape fn — closures that stay in the call

A function value normally has to live on the heap: the callee might store it, return it, or hand it to a thread. A parameter declared noescape fn(...) promises that the callee only calls it during its own execution. The native compiler can then pass the closure without retaining it and, when its captures allow, keep it entirely on the caller's stack (§3-2.4.1).

Here is a retry helper. It calls tryOnce up to limit times and never keeps it:

@mode(host.native)

func withRetries(limit: int, tryOnce: noescape fn(int): bool): int {
    for (n in 1..limit + 1) {
        if (tryOnce(n)) {
            return n;
        }
    }
    return -1;
}

func main(): void {
    var log: list[string] = [];
    let tries = withRetries(5, func(n: int): bool {
        log.push(i"attempt {$ n $}");
        return n == 3;
    });
    println(log);
    println(i"succeeded after {$ tries $} attempts");
}

Listing 16-5: A retry helper that takes a noescape fn

["attempt 1", "attempt 2", "attempt 3"]
succeeded after 3 attempts

The caller writes an ordinary lambda; nothing changes at the call site.

Mistake: a lambda without a return type

If you leave out the lambda's return type, the compiler infers Any, which does not match the parameter's type:

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

func each(items: list[int], body: noescape fn(int): void): void {
    for (x in items) {
        body(x);
    }
}

func main(): void {
    var total = 0;
    each([1, 2, 3], func(x: int) {
        nonlocal total;
        total = total + x;
    });
    println(total);
}
main.lphc:11:21: error[E0001]: argument 2 of 'each': expected 'noescape fn(int): void', found 'fn(int): Any'

Write func(x: int): void { … }. The same applies to any function-typed parameter, noescape or not.

Mistake: keeping a noescape function

The compiler checks the promise. A button that stores its click handler is the natural way to break it:

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

class Button {
    pub var onClick: fn(): void = func(): void {};
}

safe func setHandler(edits b: Button, handler: noescape fn(): void): void {
    b.onClick = handler;
}

func main(): void {
    let ok = Button();
    setHandler(edits: ok, func(): void { println("saved"); });
    ok.onClick();
}
main.lphc:8:5: error[E_NOESCAPE_ESCAPE]: E_NOESCAPE_ESCAPE: a `noescape fn` value may be called only within the call frame it was passed into, so it cannot be stored in a field

setHandler really does keep the function, so noescape is the wrong promise. Declare the parameter as a plain fn(): void; the closure then lives on the heap and is reference-counted like any other value:

safe func setHandler(edits b: Button, handler: fn(): void): void {
    b.onClick = handler;
}

Listing 16-6: A handler that is stored takes a plain fn

saved

E_NOESCAPE_ESCAPE also reports returning the value ("cannot be returned from a function"), capturing it in a closure bound to a plain fn variable, and handing it to Thread.spawn ("whose thread outlives the frame"). Passing it to a parameter of plain fn type is a type error (expected 'fn(): void', found 'noescape fn(): void').

Known gap. Pushing a noescape value into a collection (b.handlers.push(handler)) is not reported by 1.25.1, although it is an escape. Treat it as one.

Putting them together

A library function that iterates with a callback can combine both keywords: the list is borrowed, the callback does not escape, and each element is borrowed into the callback. Verbs and borrow may appear in function types too (§1-7.1):

func forEach[T](borrow items: list[T], body: noescape fn(borrow T): void): void {
    for (item in items) {
        body(borrow item);
    }
}

func main(): void {
    var longest = "";
    forEach(["pear", "banana", "fig"], func(borrow word: string): void {
        nonlocal longest;
        if (word.length() > longest.length()) {
            longest = word;
        }
    });
    println(longest);
}

Listing 16-7: A borrowed list and a non-escaping callback

banana

As specified, a native build of this loop retains nothing: not the list, not the closure, not the elements.

Choosing between verbs and these keywords

  • Application code: use the verbs. reads already avoids ARC traffic for class parameters, and edits is how a function changes its caller's value.
  • Library code on hot paths: write borrow where you want zero-cost passing to be part of the contract, and noescape fn for callbacks that are only called.
  • Exclusive access to a struct: excl borrow documents and checks exclusivity, but use edits when the caller must see the changes (see the known gap above).
  • Anything that keeps what it is given: shares for values, a plain fn type for functions.

Summary

  • borrow passes the caller's reference without retain/release; the parameter is read-only (E_READS_MUTATION), and the call-site borrow prefix is optional.
  • excl borrow adds exclusivity for struct and unique parameters: a second borrow in the same call, or a still-bound earlier borrow, is E_EXCL_BORROW_ALIAS; a class parameter is E_EXCL_BORROW_NOT_STRUCT. Under loup run changes to an excl borrow struct do not reach the caller yet, so use edits for that.
  • noescape fn parameters may only be called; storing, returning, capturing or spawning them is E_NOESCAPE_ESCAPE. Lambdas passed to them need an explicit return type.
  • E_BORROW_ESCAPE for plain borrows is specified but not checked in 1.25.1.
  • Chapter 17 introduces unique, single ownership without reference counting.