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:
@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:
@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.
@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.
@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:
@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:
@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:
@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 byprintln(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 forhost.nativeandfreestanding. 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: …")-guardedassumeInit()).
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:
@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 formreinterpretChecked[T, U], which returns aResult, 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:
- Does the function only look at the value? Write no verb (
reads). Addborrowon hot paths where zero-cost passing should be part of the contract. - Does it change the caller's value in place?
edits. - Does it keep a reference after it returns (in a field, a collection, a closure)?
shares. - 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 teston the interpreter andloup test --nativeas 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 runthreads take turns, so a race shows up only in a native build. - Keep
@unsafecode 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, anduniquemoves anddelare tracked (E_USE_AFTER_MOVE,E_USE_AFTER_TAKE,E_USE_AFTER_DEL). - Lifetimes catch most dangling references; plain
borrowescapes (E_BORROW_ESCAPE) are not checked yet. - Changing a list while a
forloop walks it is not caught; iterate over a fixed count or build a new list. Sendablestops most data races, but a thread spawned inside aforloop over a collection escapes the check; usesynchronizedclasses andMutex/Atomicfor shared state.- Indexes are bounds-checked (
IndexError); absent values areOption[T], and member access on anOptiondoes 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]andreinterpretare planned.@unsafemarks everything else, and onegrepfinds it.
For the same patterns in kernel and firmware code, see The Loup Freestanding Book, chapter 23: Memory safety and kernel patterns.