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 25: Memory safety patterns

Chapters 15 to 20 introduced Loup's safety mechanisms one at a time: ownership verbs, borrows, unique, lifetimes, @unsafe and Sendable. This chapter goes the other way. It takes the classic memory-safety bugs of C and C++ one by one and shows, for each, which Loup mechanism addresses it, what the 1.25.1 compiler actually catches, and what is still your responsibility. Every diagnostic below is real; where a specified check is missing, the chapter shows the program that slips through.

Bug class Loup's defence Status in 1.25.1
Use after free, double free no manual free; ARC; unique move tracking; del checked at compile time
Dangling reference lifetimes, inferred struct lifetimes, unowned[~name] partly checked (chapter 18); plain borrow escapes are not
Iterator invalidation the specified loop-borrow rule (E_BORROW_LOOP_MUTATION) not checked
Data race Sendable, synchronized, Mutex/Atomic checked, with one gap
Out-of-bounds access bounds checks on every index checked at run time (IndexError)
Null dereference no null; Option[T] checked at compile time; a non-exhaustive match is caught only at run time
Uninitialized read definite assignment checked for class-typed locals
Partly built struct PartialInit[T] checked
Type punning reinterpret[T, U] with @unsafe ⚠ planned
Integer overflow checked arithmetic, @overflowWrapping partly; see chapter 3

Use after free and double free

C code frees memory explicitly, so it can free too early or twice. Loup has no free. An ordinary class instance lives as long as any reference to it (ARC, chapter 9); a unique value has exactly one owner and the compiler tracks where it goes (chapter 17). The one explicit end-of-life operation, del, is tracked like a move:

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

unique class TempFile {
    pub let path: string;
    new(path: string) { this.path = path; }
    func finalize(): void { println(i"removing {$ this.path $}"); }
}

func cleanup(f: TempFile): void {
    println(i"cleaning {$ f.path $}");
}

func main(): void {
    let scratch = TempFile("/tmp/build-7");
    del scratch;
    cleanup(scratch);
}
main.lphc:16: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)

Deleting twice is the same error at the second del. Do the work first, then end the value's life:

func main(): void {
    let scratch = TempFile("/tmp/build-7");
    cleanup(scratch);
    del scratch;
}

Listing 25-1: Ending a value's life after its last use

cleaning /tmp/build-7
removing /tmp/build-7

The other ways a unique value can be given away (assignment, takes:, insertion into a collection, capture by a closure) are tracked the same way: E_USE_AFTER_MOVE and E_USE_AFTER_TAKE, including across loop iterations (chapter 17).

Dangling references

Returning a pointer to a local variable is the C version. In Loup, references that must outlive a call carry a lifetime, and the compiler checks the cases chapter 18 covers: views returned or stored without a lifetime (E_INFERRED_LIFETIME_RETURN, E_INFERRED_LIFETIME_ESCAPE), back references that outlive their target (E_UNOWNED_LIFETIME_MISMATCH), and borrows captured by escaping closures, threads or async functions.

The specified check for a plain borrow that escapes (E_BORROW_ESCAPE) is not implemented, so this compiles although the Language Reference forbids it:

This code does not do what it should
@mode(host.native)

func greeting(): borrow string {
    let text = "hello, " + "world";
    return borrow text;
}

func main(): void {
    println(greeting());
}
hello, world

greeting returns a borrow of its own local. Do not write this: a borrow is a promise that someone else keeps the value alive, and here nobody does. Return the value itself (func greeting(): string), or, if the result really is part of an argument, declare a lifetime as in Listing 18-1.

Iterator invalidation

In C++, appending to a vector while iterating over it can reallocate the storage under the iterator. A Loup list does not crash, but changing it during a for loop is still a bug: the loop sees the elements you add.

This code does not do what it should
@mode(host.native)

func main(): void {
    var jobs = ["build", "test"];
    var steps = 0;
    for (job in jobs) {
        steps = steps + 1;
        if (steps > 6) {
            break;
        }
        jobs.push(job + ":retry");
    }
    println(steps);
    println(jobs.length());
}
7
8

The intent was one retry per original job. Without the break, the loop would never end, because each retry it appends is itself visited. The specification makes this a compile-time error (E_BORROW_LOOP_MUTATION, §3-2.4.8); 1.25.1 does not report it. Decide up front how many elements you are iterating over:

func main(): void {
    var jobs = ["build", "test"];
    let originals = jobs.length();
    for (i in 0..originals) {
        jobs.push(jobs[i] + ":retry");
    }
    println(jobs);
}

Listing 25-2: Iterating over a fixed count while appending

["build", "test", "build:retry", "test:retry"]

Building a new list and assigning it afterwards works too, and is often clearer.

Data races

Two threads that write the same memory without coordination corrupt it. Loup's rule is that a thread closure may capture only Sendable values (chapter 20), and an ordinary class is not Sendable. The compiler enforces the rule, with one gap worth knowing: when the thread is started inside a for loop over a collection, the capture is not checked.

This code does not do what it should
@mode(host.native)

class Cache {
    var entries: dict[string, int] = {};

    pub modifier func put(key: string, value: int): void {
        this.entries[key] = value;
    }

    pub func size(): int {
        return this.entries.length();
    }
}

func main(): void {
    let cache = Cache();
    var workers: list[Thread] = [];
    for (prefix in ["a", "b", "c"]) {
        workers.push(Thread.spawn(func(): void {
            for (i in 0..100) {
                safe { cache.put(prefix + str(i), i); }
            }
        }));
    }
    for (w in workers) {
        w.join();
    }
    println(cache.size());
}
300

Under loup run this prints the right answer, because the interpreter runs one thread at a time. In a native binary three threads write one dictionary at the same time: a data race, with lost entries or a crash as the possible results. Move the Thread.spawn out of the loop body (into a helper function, say) and the compiler reports E_NON_SENDABLE_CAPTURE: captured variable 'cache' of type 'Cache' does not implement Sendable. The fix is to make the class safe to share. A synchronized class takes a per-instance lock in every modifier method (chapter 15):

synchronized class Cache implements Sendable {
    var entries: dict[string, int] = {};

    pub modifier func put(key: string, value: int): void {
        this.entries[key] = value;
    }

    pub modifier func size(): int {
        return this.entries.length();
    }
}

func main(): void {
    let cache = Cache();
    var workers: list[Thread] = [];
    for (prefix in ["a", "b", "c"]) {
        workers.push(Thread.spawn(func(): void {
            for (i in 0..100) {
                cache.put(prefix + str(i), i);
            }
        }));
    }
    for (w in workers) {
        w.join();
    }
    println(cache.size());
}

Listing 25-3: A cache that several threads may write

300

size is a modifier too, so that it reads under the lock, and the calls need no safe { } block because the lock, not a compile-time proof, provides the exclusion. For a single number, an Atomic[int] field is cheaper than a lock (chapter 20).

Out-of-bounds access

Every index into a list, string or bytes is checked. An index past the end throws instead of reading or writing neighbouring memory:

This code throws at run time
@mode(host.native)

func main(): void {
    let ports = [80, 443, 8080];
    let choice = 3;
    println(ports[choice]);
}
Uncaught IndexError: list index 3 out of range (len 3)
  at main (main.lphc:6:13)

A native binary prints the same exception as a single line and exits with status 1. Check the index where it comes from outside the program, or catch IndexError (chapter 12):

func portAt(ports: list[int], choice: int, fallback: int): int {
    if (choice < 0 or choice >= ports.length()) {
        return fallback;
    }
    return ports[choice];
}

func main(): void {
    let ports = [80, 443, 8080];
    println(portAt(ports, 3, 80));
}

Listing 25-4: Validating an index before using it

80

For list, get(i) behaves like [i] and throws; for dict, get(key) returns an Option.

Absent values instead of null

Loup has no null (undefined identifier 'null'), and a class-typed binding cannot hold None (cannot assign 'None' to binding 'v' of type 'User'). A value that may be missing has type Option[T], and the compiler stops you from using it as if it were there:

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

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

func findUser(users: list[User], id: int): Option[User] {
    if (id < 0 or id >= users.length()) {
        return None;
    }
    return Some(users[id]);
}

func main(): void {
    let users = [User("ada"), User("grace")];
    let u = findUser(users, 5);
    println(u.name);
}
main.lphc:18:13: error[E0001]: cannot access member 'name' on Option[User] directly; unwrap or pattern-match first

Handle both cases with match, or use ?. and ?? for a default:

func main(): void {
    let users = [User("ada"), User("grace")];
    match findUser(users, 1) {
        Some(u) => println(u.name),
        None => println("no such user"),
    }
    println(findUser(users, 5)?.name ?? "unknown");
}

Listing 25-5: Handling a missing value

grace
unknown

Two run-time failures remain possible. unwrap() on None throws UnwrapError: called unwrap() on a None value, so keep it for values you have just checked. And a match on an Option that leaves out the None arm compiles in 1.25.1, although matches are specified to be exhaustive; it throws MatchError: No matching arm for value in match expression when the value is None. Always write both arms.

Uninitialized values

A var may be declared without a value and assigned later, but it must be assigned on every path before it is read. For class-typed variables the compiler checks this:

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

class Config {
    pub let port: int;
    new(port: int) { this.port = port; }
}

func main(): void {
    var cfg: Config;
    if (runtime.args().length() > 0) {
        cfg = Config(8080);
    }
    println(cfg.port);
}
main.lphc:13:13: error[E_USE_BEFORE_ASSIGN]: E_USE_BEFORE_ASSIGN: `cfg` is read before it has been assigned on every path reaching this point. A `var` of class type declared without an initializer holds no instance — assign one (`cfg = T(...);`) before reading it, or declare it with its initializer
  note: §1-6.2: a `var` declared without an initializer must be assigned before its value is read. The §1-10.1.1 default-initialization exception covers struct-typed locals only, not `cfg`'s class type

Assign on the other path too, or compute the value with an if expression and bind it with let:

func main(): void {
    let cfg = if (runtime.args().length() > 0) { Config(8080) } else { Config(80) };
    println(cfg.port);
}

Listing 25-6: Initializing on every path with an if expression

80

Known gap. The same check is not applied to variables of primitive type yet: var port: int; followed by println(port) compiles and prints an empty line. Give scalar variables an initial value.

⚠ Planned. MaybeUninit[T] (§3-3.17), the explicit wrapper for memory a C function or a device fills in, is specified for host.native and freestanding. 1.25.1 has only a non-generic placeholder class of that name in the prelude, with none of the specified API (MaybeUninit.uninit(), rawPtr(), an @unsafe("init: …")-guarded assumeInit()).

Building a struct field by field

C code that fills a struct one field at a time can forget a field. When a struct has to be filled incrementally, for example a protocol header assembled from several sources, PartialInit[T] tracks which fields have been set, at compile time (§3-3.17a):

@repr(C)
struct UdpHeader {
    var srcPort: uint16;
    var dstPort: uint16;
    var length: uint16;
    var checksum: uint16;
}

func header(src: uint16, dst: uint16, payload: uint16): UdpHeader {
    var h = PartialInit[UdpHeader].begin();
    h.set(.srcPort, src);
    h.set(.dstPort, dst);
    h.set(.length, payload + 8);
    h.set(.checksum, 0);
    return h.complete();
}

func main(): void {
    let h = header(5353, 53, 32);
    println(i"{$ h.srcPort $} -> {$ h.dstPort $}, {$ h.length $} bytes");
}

Listing 25-7: Assembling a header with PartialInit

5353 -> 53, 40 bytes

.srcPort is a field reference, valid only as the first argument of set. Leave a field out and complete() is rejected:

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

@repr(C)
struct UdpHeader {
    var srcPort: uint16;
    var dstPort: uint16;
    var length: uint16;
    var checksum: uint16;
}

func header(src: uint16, dst: uint16): UdpHeader {
    var h = PartialInit[UdpHeader].begin();
    h.set(.srcPort, src);
    h.set(.dstPort, dst);
    return h.complete();
}

func main(): void {
    println(header(5353, 53).dstPort);
}
main.lphc:15:12: error[E_PARTIAL_INIT_INCOMPLETE]: E_PARTIAL_INIT_INCOMPLETE: `.complete()` called with 2 unset field(s): length, checksum

For most structs the generated constructor is simpler and just as safe: UdpHeader(srcPort = …, …) with a field left out is missing required field.

Reinterpreting bits

⚠ Planned. reinterpret[T, U](value) (§3-3.18), which reinterprets a value's bits as another type of the same size under @unsafe("transmute: …"), and its checked form reinterpretChecked[T, U], which returns a Result, are accepted by the 1.25.1 parser but not implemented: both return their argument unchanged, with the source type. Do not use them yet.

Integer overflow

int arithmetic wraps silently in 1.25.1 on every path, although the specification says it should throw, and the fixed-width types wrap in native binaries but throw under loup run. Chapter 3 has the details and the @overflowWrapping annotation for code that wants wrapping on purpose. Until checked int arithmetic lands, test the bounds of counters that could get large.

The @unsafe audit

Everything above is either checked or plainly marked as unchecked. The remaining claims the compiler cannot verify are the ones you write down yourself with @unsafe("category: reason") (chapter 19). They are found with one search:

grep -rnE '@?unsafe\(' src/

Each hit should name its category (threadSafe:, escapingBorrow:, sendable:, asm:, …) and say what makes the claim true. In code review, treat a new hit as a request to check that reason.

Translating C and C++ habits

C / C++ Loup
T* that owns its object a class instance (ARC), or a unique let binding
std::unique_ptr<T> unique let / unique var, or a unique class
std::shared_ptr<T> an ordinary class instance
std::weak_ptr<T> a weak var field (strong in native builds for now) or unowned[~name]
const T& parameter a parameter with no verb (reads), or borrow
T& parameter that is changed edits
T&& / std::move takes and takes:
a pointer that is stored shares and shares:
std::mutex plus the data it guards a synchronized class, or a Mutex field
std::atomic<int> Atomic[int]
std::optional<T> / nullable pointer Option[T]
std::variant an enum with payloads
extern "C" extern native
memcpy, reinterpret_cast ⚠ planned (native.mem, reinterpret)

Choosing how to pass a value

For each parameter:

  1. Does the function only look at the value? Write no verb (reads). Add borrow on hot paths where zero-cost passing should be part of the contract.
  2. Does it change the caller's value in place? edits.
  3. Does it keep a reference after it returns (in a field, a collection, a closure)? shares.
  4. Does it take the value over, so the caller is done with it? takes.

For a return value: return owned values by default; return borrow[~a] T only when the result is part of an argument and copying it matters.

For a class: a plain class by default; a unique class when there must never be two references; a synchronized class (or Mutex/Atomic fields with implements Sendable) when threads share it.

Testing what the compiler cannot check

  • Run the test suite on both engines: loup test on the interpreter and loup test --native as native binaries (chapter 24). The differences between them (overflow, finalization timing, real parallelism) are exactly where interpreter-only testing misses bugs.
  • Write tests that run threads hard against shared objects. Under loup run threads take turns, so a race shows up only in a native build.
  • Keep @unsafe code small and test it directly; it is the part of the program the compiler has agreed not to check.

Summary

  • Use-after-free and double-free cannot be written: there is no free, ARC keeps shared objects alive, and unique moves and del are tracked (E_USE_AFTER_MOVE, E_USE_AFTER_TAKE, E_USE_AFTER_DEL).
  • Lifetimes catch most dangling references; plain borrow escapes (E_BORROW_ESCAPE) are not checked yet.
  • Changing a list while a for loop walks it is not caught; iterate over a fixed count or build a new list.
  • Sendable stops most data races, but a thread spawned inside a for loop over a collection escapes the check; use synchronized classes and Mutex/Atomic for shared state.
  • Indexes are bounds-checked (IndexError); absent values are Option[T], and member access on an Option does not compile.
  • Class-typed variables must be assigned before use (E_USE_BEFORE_ASSIGN); scalars are not checked yet.
  • PartialInit[T] checks field-by-field construction; MaybeUninit[T] and reinterpret are planned.
  • @unsafe marks everything else, and one grep finds it.

For the same patterns in kernel and firmware code, see The Loup Freestanding Book, chapter 23: Memory safety and kernel patterns.