Ownership & Borrowing

Ownership rules

  1. Every managed value has exactly one owner
  2. When the owner goes out of scope, the value is cleaned up
  3. Assignment produces a deep copy

What is managed

The compiler tracks ownership for:

  • string values (24-byte descriptor: ptr + len + cap)
  • Structs that contain managed fields
  • Tagged unions with a managed field in any variant
  • ?T where T is managed
  • T or E kept whole in a variable (let r = parse(s);), when T or E is managed
  • Fixed arrays of managed values ([4]string, [N]Person)
  • Generated containers (Vec_T, Map_K_V from collections)

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 the drops in reverse declaration order at every scope exit point. Every block is a scope: a loop body’s locals are freed at the end of each iteration (and by break and continue), a branch’s when the branch ends.

Temporaries

A managed value that no variable holds is freed too:

tape
names.push("user-{id}");     // the push keeps a copy; this string is freed after the call
io.println(make_title());    // the returned string is freed after println returns
names.pop();                 // the popped string is freed: nobody kept it
let n: i64 = names.get(0).len;   // the copy get returns is freed after the statement

A temporary lives until the outermost call of its expression returns, so a view into it (trim(make())) is good inside that call but not afterwards.

A view of a part of a returned value would outlive it, so binding or storing one is a compile error; bind the value to a name first:

tape
let kind: []const u8 = nodes.get(k).kind;   // error: the copy get returns is freed after the statement
let node = nodes.get(k);
let kind: []const u8 = node.kind;           // fine: node holds it

A view reached through a pointer the call returns (nodes.get_ptr(k).kind) views what the pointer points to, not the temporary, and is not refused. So does a field that is itself a slice or a pointer (f().items): you get a copy of the reference, and what it points to isn’t the temporary’s.

A view a method gives of a returned value, such as .slice() of a vector a call returns, is the same: the vector is freed after the statement. A let or var initialized with it keeps the vector alive as long as itself; storing it anywhere that outlives the statement is a compile error, and that includes a component’s prop, which keeps its value until it is set again:

tape
let names = rows().slice();                 // fine: the vector lives as long as names
list.items = rows().slice();                // error: a temporary would be freed while the view uses it
Sidebar { items: rows().slice(); }          // error, in a view too: keep the vector in a var

Parameters are borrows

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

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

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

A by-value parameter (a string, struct, tagged union, fixed array, a slice’s own data pointer and length) keeps the value it was given for the whole call, even when the argument is a global the callee assigns:

tape
var g: string = "first";

fn f(s: string) -> i64 {
    g = "second" + "!";
    return s.len;   // 5
}

The compiler passes such a global as a copy, dropped after the call. It skips the copy when the callee (and everything it calls) assigns no global, and when the call returns a view or pointer, which then views the global itself: a function returning a view into its argument must not assign that global.

A view parameter is different: a string passed for a []const u8 (or *const u8) parameter is a view of the caller’s own bytes, never of a copy, global or not. The callee may keep it, and it stays valid as long as the variable holds that value:

tape
var own: string;
var kept: []const u8;

fn keep(v: []const u8) { kept = v; }   // views own's bytes

fn main() -> i32 {
    own = "abc" + "def";
    keep(own);
    return kept.len as i32;            // 6
}

Like any view, it does not survive the variable being reassigned: a callee that assigns that global must not read the view afterwards.

The same holds when the callee could change the caller’s variable another way, through a pointer or view the same call also passes (f(p, &p)), or one the caller keeps (let q = &p):

tape
fn f(p: Point, q: *Point) -> i64 {
    q.x = 100;      // the caller's p
    return p.x;     // still the value f was given
}

Such an argument is passed as a copy too; the ordinary call, a variable passed once, costs nothing.

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

Storing a local you no longer use

Storing a local into a struct copies it, but when the store is the local’s last use the compiler moves it instead (no copy, no drop): the value just changes owner.

tape
fn make_config() -> Config {
    let n: string = get_name();
    let p: string = get_path();
    return Config { name: n; path: p; };  // n and p move in: two allocations, not four
}

You never see a difference besides the saved work: it only happens when nothing reads the local afterwards. It applies to a struct literal or tagged-union constructor that is returned or declared (let c = Config { ... };), and to a local stored whole into a variable (let w = y;, w = y;), with the local declared in the same block; anything less certain (a literal passed to a call, a store in a nested branch or loop, a view of the local taken earlier) keeps the copy.

Handing a value over: @replace

Reading a field copies it. When you are done with a value in a place and want to hand it on (a lexer giving its token’s text to a node), replace it:

tape
node.name = @replace(&lexer.token.text);        // the text moves to the node; token.text is now ""
let first: string = @replace(names.get_ptr(0));
let items: collections.Vec_i64 = @replace(&st.items);   // st.items is an empty Vec
let tok: Value = @replace(tokens.get_ptr(i), Value.Eof);  // any type: say what stays behind

@replace(&place, value) gives you the value and leaves value in the place. With one argument it leaves the empty value, which a string ("") and a container (an empty Vec, Map or Set) have; for any other type, give the value to leave. The old value is not copied, only handed over, and the place stays a normal value, so there is nothing new to keep track of.

Arrays

A fixed array of managed values holds them inline and is a value like any other:

tape
var names: [3]string;                  // three empty strings
names[0] = "user-{id}";                // the old element is freed
var copy: [3]string = names;           // every element copied
names = ["a", make_name(), copy[0]];   // the old elements freed
for (n in names) { io.println(n); }    // each element in place, not copied
// names and copy are freed element by element at scope exit

Arrays inside structs and tagged-union variants are copied and freed with their owner.

Results kept whole

A T or E result can be kept in a variable and unwrapped later; the variable owns the payload — the success value, or a managed error:

tape
let r = parse(text);              // owns the parsed string, or the error's message
var s = r;                        // a copy, payload and all
let value: string = r or { "" };  // a copy of the payload; r still owns its own
// r, s and value are freed at scope exit

A result you drop on the floor is freed too: opt();, f() or {};, and an error nobody passes on. The error an or { } handler receives is freed when the handler ends, however it ends (falling through, return, break, continue), unless it returns it (return err; hands it to the caller). f() or return 7; frees the error before returning. The same holds for a ?T or E result.

tape
for (text in lines) {
    let s: string = parse(text) or {
        if (strict) { return 1; }     // err is freed before returning
        continue;                     // ...or before the next line
    };
}

fn relay(text: string) -> string or ParseErr {
    let t: string = parse(text) or { return err; };   // err moves to the caller
    return t;
}

Containers

A container from collections owns copies of its elements, which may themselves be managed (strings, structs with strings, tagged unions, other containers):

tape
var v: collections.Vec_string = collections.vec_string_new();
let s: string = "x{1}";
v.push(s);                       // v holds a copy; s is still yours
v.set(0, "y");                   // the old element is freed
let first: string = v.get(0);    // a copy for you
var w: collections.Vec_string = v;   // a deep copy: w has its own strings
for (e in w.iter()) { }          // each e is a copy, freed every iteration
// v, w, s, first are all freed at scope exit

set, remove and clear free the elements they replace or remove; pop and remove hand the element to you. The compiler calls the container’s Vec_string_copy and Vec_string_drop itself when you assign or when the variable goes out of scope; calling v.free() earlier is allowed and leaves an empty vector. See collections.

Tagged unions

A tagged union whose variants hold managed values is managed like a struct, and can even contain itself through a container:

tape
pub tagged Value {               // pub: the generated Vec_Value's functions are pub
    Null;
    Str { s: string; }
    Arr { items: Vec_Value; }    // @tape { collections.gen_vec(Value); }
}

var a: Value = Value.Str { s: "hi" };
var b: Value = a;                // deep copy of the current variant's payload
a = Value.Null;                  // the old string is freed

match binds payload fields by borrowing them: inside the arm they are the union’s own fields, freed with the union, not separately. Copy one (let keep: string = s;) to keep it.

Pointers and managed values

Through a raw pointer the rules are explicit, because the memory may not hold a value yet:

  • *p = v stores a copy of v and does not free what was there before
  • p.field = v frees the old field, like any field assignment
  • @drop(p) frees the managed value *p (it does nothing for plain values)

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.

Slicing a string, s[a..b], gives a []const u8 view of its bytes, and the same rule applies: the view must not outlive s. So that a slice never views a temporary that has no name to outlive it, slicing an array literal ([1, 2, 3][0..2]) or, in t1/t2, a temporary string (a call result, concatenation or interpolation) is a compile error. Bind it to a local first.

A temporary given to a call lives until the call’s statement ends. A function that keeps the view longer marks its parameter @keeps (Attributes), and then a temporary there is refused too:

tape
@tape { collections.gen_vec([]const u8); }

var words = vec_cslice_u8_new();
words.push(str.from_view("abc"));   // error: a temporary string would be freed
                                    // while 'push' keeps a view of it
let w = str.from_view("abc");
words.push(w);                      // fine: w outlives the statement
words.push("abc");                  // fine: a literal is static

A container of owned values takes a copy instead: Vec_string.push(make()) is fine.

Raw pointers

Raw pointers opt out of automatic cleanup entirely:

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

Can I segfault?

Yes — but only through code that says so. Tape has no borrow checker because everyday code never aliases memory, not because aliasing is checked.

What can’t go wrong. Managed values are never shared: assignment, storing into a struct, a union or a container deep-copy, parameters are borrowed for the duration of the call, and the compiler inserts every drop. Two variables never own the same buffer, so there is no use-after-free or double free between managed values, and no free to forget. The only way to point into a managed value is a slice — covered below.

What traps instead of crashing. In t1 and t2, array and slice indexing is bounds-checked: a[i] with i outside 0..a.len stops the program with index out of bounds and the source location, rather than reading or writing stray memory. Calling a null function pointer traps the same way (call through a null function pointer) instead of jumping to address 0. A *!T pointer can never be null — the compiler rejects assigning null or a nullable *T to it.

What can. Two things are explicitly yours to get right:

  • Raw pointers. mem.alloc / mem.free, &variable, and pointer casts behave like C. Freeing twice, using memory after mem.free, or keeping &local past its function will crash. defer mem.free(...) keeps cleanup next to the allocation; it does not make misuse impossible.
  • Slices. A []T view has no owner. If its source is freed or goes out of scope first, the slice dangles.

The trade is the same one Go and Zig make: the safe path is the default and costs a copy; the fast path is visible in the source — *T, []T, mem. — so you can find every place memory is managed by hand. In t0 (freestanding) everything is manual by design, and indexing is unchecked. Its tests can still catch a read past a buffer: tape test --checked checks t0’s indexing and slicing as t1’s, in the test build only.

Last modified: