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 18: Lifetimes and regions

A plain borrow (chapter 16) lives only as long as the call it was passed to. Sometimes a borrow has to travel further: a function returns a reference into one of its arguments, or a struct holds a view into a larger string. Loup handles these cases with lifetime labels: names for regions of the program that a borrowed reference is tied to. The compiler checks the labels and then erases them, so they cost nothing at run time.

This chapter covers lifetime parameters on functions, structs and enums, ~static, inferred struct lifetimes, unowned[~name] class fields, and which of the specified rules the 1.25.1 compiler enforces. The normative text is the Language Reference §3-2.4.2 to §3-2.4.4, §1-7.1 and §1-10.11. Lifetime syntax is available, and checked the same way, in every mode.

Lifetime parameters on functions

A function that returns one of its arguments without copying it returns a borrow, and the caller has to know which argument it borrows from:

@mode(host.native)

func longest[lifetime ~a](borrow[~a] x: string, borrow[~a] y: string): borrow[~a] string {
    if (x.length() >= y.length()) {
        return x;
    }
    return y;
}

func main(): void {
    let primary = "db1.internal.example.com";
    let fallback = "db2.example.com";
    println(longest(primary, fallback));
}

Listing 18-1: Returning a borrow tied to the arguments

db1.internal.example.com

The pieces:

  • [lifetime ~a] declares a region named ~a. Lifetime parameters go in the same square brackets as type parameters and may be mixed with them: [lifetime ~a, lifetime ~b, T]. Labels start with ~; Loup does not use Rust's 'a.
  • borrow[~a] x: string says x is a borrow anchored to region ~a.
  • The return type borrow[~a] string says the result is valid only as long as ~a, that is, as long as both arguments.

The caller passes ordinary values. The compiler works out the region from the arguments; nothing about regions appears at the call site.

~static is the built-in region that lasts for the whole program. Module-level let bindings, string literals and constants have it, and a borrow[~static] value can be passed wherever a shorter region is expected:

let SEPARATOR: string = " | ";

func framed[lifetime ~src](borrow[~src] text: string, borrow[~static] sep: string): string {
    return sep + text + sep;
}

func main(): void {
    let title = "build report";
    println(framed(title, SEPARATOR));
}

Listing 18-2: A ~static parameter

 | build report | 

~static is reserved and needs no declaration. Do not declare it as a parameter: the specification forbids [lifetime ~static], although 1.25.1 does not report it.

Structs that borrow

A struct can hold a borrowed field if it declares the region the field comes from. Here a Field is a view of one column of a CSV line; it records where the column starts and ends but does not copy the line:

struct Field[lifetime ~src] {
    borrow[~src] line: string;
    start: int;
    end: int;

    pub func text(): string {
        return this.line.substring(this.start, this.end);
    }
}

func field[lifetime ~s](borrow[~s] line: string, index: int): Field[~s] {
    var start = 0;
    var count = 0;
    var i = 0;
    while (i < line.length()) {
        if (line[i] == ",") {
            if (count == index) {
                return Field(line = line, start = start, end = i);
            }
            count = count + 1;
            start = i + 1;
        }
        i = i + 1;
    }
    return Field(line = line, start = start, end = line.length());
}

func main(): void {
    let row = "ada,1815,london";
    let year = field(row, 1);
    println(i"{$ year.text() $} ({$ year.start $}..{$ year.end $})");
    println(field(row, 2).text());
}

Listing 18-3: A struct with a lifetime parameter

1815 (4..8)
london

Field[~s] in field's return type ties the returned view to the line argument. Methods of a lifetime-parameterized struct can use its fields like any other struct's; the region is part of the type, not of each method.

A struct with a lifetime is never Sendable, because a region belongs to one thread's stack. Declaring implements Sendable on one is an error:

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

struct Field[lifetime ~src] implements Sendable {
    borrow[~src] line: string;
    start: int;
}

func main(): void {
}
main.lphc:1:1: error[E_SENDABLE_LIFETIME]: E_SENDABLE_LIFETIME: 'Field' carries a lifetime (a `[lifetime ~name]` parameter or a `borrow[~name]` field) and cannot declare `implements Sendable` (§2-1.8.1)

(Stage0 reports this one at the start of the file.) A lifetime-parameterized struct may also not contain itself through its fields (E_LIFETIME_CYCLE).

Inferred struct lifetimes

When a struct's only non-value fields are plain borrow fields, you may leave out the lifetime parameter. The compiler gives the struct an implicit region tied to the scope where the value is created (§3-2.4.4):

struct Span {
    borrow source: string;
    start: int;
    end: int;
}

func show(s: Span): string {
    return i"[{$ s.source.substring(s.start, s.end) $}]";
}

func main(): void {
    let request = "GET /index.html HTTP/1.1";
    let method = Span(source = request, start = 0, end = 3);
    let path = Span(source = request, start = 4, end = 15);
    println(show(method) + " " + show(path));
}

Listing 18-4: A struct whose lifetime is inferred

[GET] [/index.html]

The inferred region is enough as long as the struct stays where it was made. Two mistakes are caught.

Mistake: returning an inferred-lifetime struct

A function that builds a Span and returns it would hand the caller a view whose source the caller cannot see:

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

struct Span {
    borrow source: string;
    start: int;
    end: int;
}

func firstWord(borrow line: string): Span {
    let sp = line.indexOf(" ");
    return Span(source = line, start = 0, end = sp);
}

func main(): void {
    println(firstWord("GET /").end);
}
main.lphc:9:1: error[E_INFERRED_LIFETIME_RETURN]: E_INFERRED_LIFETIME_RETURN: function 'firstWord' returns 'Span', a struct with an inferred lifetime, without a lifetime annotation on its return type — give the struct a `[lifetime ~name]` parameter and return 'Span[~name]' (§3-2.4.4)

The message gives the fix, which is the shape of Listing 18-3: declare struct Span[lifetime ~src] with a borrow[~src] source field, and write func firstWord[lifetime ~s](borrow[~s] line: string): Span[~s].

Mistake: storing a view where it outlives its source

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

struct Span {
    borrow source: string;
    start: int;
    end: int;
}

func main(): void {
    var longest = Span(source = "", start = 0, end = 0);
    for (line in ["GET /", "POST /upload"]) {
        let s = Span(source = line, start = 0, end = line.length());
        if (s.end > longest.end) {
            longest = s;
        }
    }
    println(longest.end);
}
main.lphc:14:13: error[E_INFERRED_LIFETIME_ESCAPE]: E_INFERRED_LIFETIME_ESCAPE: 's' has an inferred lifetime (it borrows from 'line') and is stored in 'longest', declared in an enclosing block, which outlives it (§3-2.4.4)

line belongs to one loop iteration, and longest outlives the loop. Keep what you need as owned values instead: here, the length and the line itself (var longestLine = ""), assigned inside the if. Storing into a module-level var is reported the same way ("stored in the module variable …").

Closures, threads and async

A borrow with a label must not end up somewhere that can outlive its region. Three ways of escaping are checked. The first is a closure:

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 tagAll[lifetime ~p](borrow[~p] prefix: string, ids: list[int]): void {
    let show: fn(int): void = func(id: int): void {
        println(prefix + str(id));
    };
    each(ids, show);
}

func main(): void {
    tagAll("job-", [1, 2]);
}
main.lphc:10:5: error[E_BORROW_CLOSURE_ESCAPE]: E_BORROW_CLOSURE_ESCAPE: a closure capturing 'prefix: borrow[~p]' is bound to 'show', a plain `fn` binding — a lifetime-capturing closure may only be passed to a `noescape fn` parameter (§3-2.4.2 rule 3)

A plain fn variable could be stored or returned, taking prefix with it. Pass the lambda directly to the noescape fn parameter instead:

func tagAll[lifetime ~p](borrow[~p] prefix: string, ids: list[int]): void {
    each(ids, func(id: int): void {
        println(prefix + str(id));
    });
}

Listing 18-5: A closure that captures a labelled borrow goes to a noescape fn parameter

job-1
job-2

The second is a thread. A closure passed to Thread.spawn, Thread.spawnWith or ThreadPool.run may not capture a labelled borrow (E_BORROW_THREAD_CAPTURE: 'name: borrow[~a]' cannot be captured by the closure passed to Thread.spawn — the borrowed source may be gone before the thread runs). The third is async: an async func may not take a labelled borrow at all, because it can resume after its caller has returned:

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

async func checksum[lifetime ~a](borrow[~a] data: string): Task[int] {
    return data.length();
}

func main(): void {
}
main.lphc:3:34: error[E_BORROW_ASYNC]: E_BORROW_ASYNC: async function 'checksum' has the lifetime-labelled parameter 'data: borrow[~a]' — an async function may resume after its caller's frame has exited; take ownership instead (§3-2.4.2 rule 5)

Take the value as an ordinary parameter (data: string), as the message says.

Known gaps. Three of the specified region rules are not enforced by 1.25.1, so the compiler accepts programs the Language Reference rejects:

  • storing a borrow[~a] value in a location that does not carry ~a, such as a module-level var (rule 1);
  • returning a borrow[~a] value from a function whose return type carries a different label (rule 2);
  • storing a lifetime-parameterized struct in a class field (§1-10.11).

Also, the closure-capture check does not see a captured borrow that is used only inside an i"…" interpolated string. Write lifetimes as if these checks were in place.

Enums with lifetimes

An enum can carry borrowed payloads in the same way. A tokenizer that returns words without copying them:

enum Token[lifetime ~src] {
    Word(borrow[~src] string),
    Number(int),
}

func describe[lifetime ~s](t: Token[~s]): string {
    return match t {
        Token.Word(w) => i"word '{$ w $}'",
        Token.Number(n) => i"number {$ n $}",
    };
}

func main(): void {
    let input = "retry";
    println(describe(Token.Word(input)));
    println(describe(Token.Number(3)));
}

Listing 18-6: An enum with a borrowed payload

word 'retry'
number 3

Write the variant payload positionally, as above, or as Word(text: borrow[~src] string). The form with the qualifier before a payload name, Word(borrow[~src] text: string), does not parse. Interfaces may declare lifetime parameters too (interface Renderer[lifetime ~src], §1-11.4).

unowned[~name] fields

A child that points back at its parent is the classic reference cycle (chapter 9). weak breaks the cycle at run time. unowned[~name] is the compile-time alternative: a non-owning reference whose validity the compiler proves from scope nesting, so it needs neither a count nor a runtime check (§3-2.4.3).

class TreeNode[lifetime ~owner] {
    pub let name: string;
    pub unowned[~owner] var parent: TreeNode[~owner]?;

    new(name: string, parent: TreeNode[~owner]?) {
        this.name = name;
        this.parent = parent;
    }

    pub func path(): string {
        var result = this.name;
        var cur = this.parent;
        while (cur != None) {
            let node = cur.unwrap();
            result = node.name + "/" + result;
            cur = node.parent;
        }
        return result;
    }
}

func main(): void {
    let root = TreeNode("srv", None);
    let www = TreeNode("www", Some(root));
    let page = TreeNode("index.html", Some(www));
    println(page.path());
}

Listing 18-7: Parent links as unowned[~owner] fields

srv/www/index.html

The class declares the region ([lifetime ~owner]) and the field names it. unowned fields are a host.native and freestanding feature; elsewhere they are refused (unowned references are only valid in @mode(host.native) or @mode(freestanding) builds). Every value stored in the field must come from a scope that contains the holder's; here all three nodes are declared in the same block of main.

Three mistakes are caught. The first is a bare unowned field, written the way earlier editions of the specification allowed:

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

class Node {
    pub let name: string;
    unowned var parent: Node?;
    new(name: string) { this.name = name; this.parent = None; }
}

func main(): void {
    println(Node("srv").name);
}
main.lphc:5:5: error[E_UNOWNED_NO_LIFETIME]: E_UNOWNED_NO_LIFETIME: `unowned` field `parent` has no lifetime annotation — write `unowned[~name] var parent: …`, naming the class's `[lifetime ~name]` parameter the referent must outlive

A labelled field in a class that does not declare the label: E_UNOWNED_CLASS_NO_PARAM: field 'parent' is unowned[~owner]but 'Node' does not declare[lifetime ~owner] (§3-2.4.3). And, most usefully, a referent that does not live long enough:

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

class TreeNode[lifetime ~owner] {
    pub let name: string;
    pub unowned[~owner] var parent: TreeNode[~owner]?;

    new(name: string, parent: TreeNode[~owner]?) {
        this.name = name;
        this.parent = parent;
    }
}

func main(): void {
    let page = TreeNode("index.html", None);
    if (page.name.endsWith(".html")) {
        let dir = TreeNode("www", None);
        safe { page.parent = Some(dir); }
    }
    println(page.name);
}
main.lphc:17:16: error[E_UNOWNED_LIFETIME_MISMATCH]: E_UNOWNED_LIFETIME_MISMATCH: 'dir' is declared in a scope nested inside the one declaring 'page', so it does not outlive the `unowned[~owner]` field 'parent' it is assigned to (§3-2.4.2 rule 7)

dir dies at the end of the if block while page lives on, so page.parent would dangle. Declare the parent in a scope at least as wide as the child's. When the relationship cannot be expressed by scope nesting (nodes created in a loop and linked later, graphs with no clear owner), use weak instead.

weak unowned[~name]
Safety checked at run time: reads None after the referent is freed proven at compile time from scope nesting
Cost a side-table lookup per read (as specified) none
Modes the hosted modes host.native and freestanding
Use when lifetimes are dynamic the parent's scope visibly contains the child's

Known gap. weak fields are lowered as strong references by the native code generator in 1.25.1 (chapter 9), so they do not break cycles in a native binary yet. unowned[~name] needs no runtime support and is not affected.

Lifetime erasure

All lifetime labels are removed after type checking. The compiled program contains no lifetime metadata, and labels cannot be inspected at run time. Adding lifetimes never changes what a program does or how fast it runs; it only changes which programs compile.

Summary

  • [lifetime ~a] declares a region; borrow[~a] T ties a parameter, return value, struct field or enum payload to it; ~static is the whole program.
  • A struct or enum that borrows declares its regions (struct Field[lifetime ~src]), or, for borrow-only structs, gets an inferred one that may not be returned (E_INFERRED_LIFETIME_RETURN) or stored in a wider scope (E_INFERRED_LIFETIME_ESCAPE).
  • Labelled borrows may not escape through plain fn closures (E_BORROW_CLOSURE_ESCAPE), threads (E_BORROW_THREAD_CAPTURE) or async signatures (E_BORROW_ASYNC); lifetime-carrying types are not Sendable.
  • unowned[~name] fields give compile-time-checked back references (E_UNOWNED_LIFETIME_MISMATCH and friends).
  • Rules 1 and 2 of §3-2.4.2 and the class-field rule are not enforced yet.
  • Chapter 19 covers @unsafe and calling C, where the compiler's checks end.