Ownership & Borrowing

Ownership rules

  1. Every managed value (string, struct with string fields) has exactly one owner
  2. When the owner goes out of scope, the value is cleaned up
  3. Assignment produces a deep copy (for dynamic strings)

What is managed

The compiler tracks ownership for:

  • string values (24-byte descriptor: ptr + len + cap)
  • Structs that contain string fields

Raw pointers, integers, slices, and other types are unmanaged — no automatic cleanup.

Scope-based cleanup

tape
fn process() {
    let greeting: string = "Hello, " + name;
    let data: string = read_file("input.txt");
    // both are freed automatically when this scope exits
}

The compiler emits string_drop in reverse declaration order at every scope exit point.

Parameters are borrows

Function parameters are borrowed — the callee does not free them:

tape
fn print_length(s: string) {
    io.print_i64(str.len(s));
    // s is NOT freed here — caller still owns it
}

let name: string = "hello";
print_length(name);
// name is still valid here

Return transfers ownership

Returning a managed local transfers ownership to the caller (the local’s drop is skipped):

tape
fn make_greeting(name: string) -> string {
    let result: string = "Hello, " + name;
    return result;  // no drop — ownership moves to caller
}

Copy on assignment

Assigning a managed variable to another produces a deep copy:

tape
let a: string = build_name();
let b: string = a;  // b is an independent deep copy

Static strings (literals, cap == 0) are shallow-copied. Dynamic strings (cap > 0) are heap-duplicated.

Reassignment drops the old value

When reassigning a managed variable, the old value is dropped before the new one is stored:

tape
var s: string = "first";
s = "second";  // "first" is freed, then "second" is assigned

Struct fields

Structs with string fields are managed as a whole. When the struct goes out of scope, each string field is dropped:

tape
struct Person {
    name: string;
    email: string;
}

let p = Person { name: "Alice"; email: "alice@example.com"; };
// when p goes out of scope, both name and email are freed

Slices (non-owning views)

Slices borrow data without ownership — no cleanup runs for them:

tape
fn first_word(s: []const u8) -> []const u8 {
    for (i in 0..s.len) {
        if (s[i] == 32 as u8) { return s[0..i]; }
    }
    return s;
}

The caller must ensure the source data outlives the slice.

Raw pointers

Raw pointers opt out of automatic cleanup entirely:

tape
let buf: *u8 = mem.alloc(4096);
defer mem.free(buf, 4096);
// you are responsible for cleanup

Last modified: