Ownership & Borrowing
Ownership rules
- Every managed value has exactly one owner
- When the owner goes out of scope, the value is cleaned up
- Assignment produces a deep copy
What is managed
The compiler tracks ownership for:
stringvalues (24-byte descriptor: ptr + len + cap)- Structs that contain managed fields
- Tagged unions with a managed field in any variant
?TwhereTis managedT or Ekept whole in a variable (let r = parse(s);), whenTorEis managed- Fixed arrays of managed values (
[4]string,[N]Person) - Generated containers (
Vec_T,Map_K_Vfromcollections)
Raw pointers, integers, slices, and other types are unmanaged — no automatic cleanup.
Scope-based cleanup
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:
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 statementA 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:
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 itA 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:
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 varParameters are borrows
Function parameters are borrowed — the callee does not free them:
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 hereA 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:
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:
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):
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):
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:
let a: string = build_name();
let b: string = a; // b is an independent deep copyStatic 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:
var s: string = "first";
s = "second"; // "first" is freed, then "second" is assignedStruct fields
Structs with string fields are managed as a whole. When the struct goes out of scope, each string field is dropped:
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 freedStoring 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.
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:
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:
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 exitArrays 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:
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 exitA 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.
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):
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 exitset, 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:
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 freedmatch 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 = vstores a copy ofvand does not free what was there beforep.field = vfrees 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:
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 { 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 staticA container of owned values takes a copy instead: Vec_string.push(make())
is fine.
Raw pointers
Raw pointers opt out of automatic cleanup entirely:
let buf: *u8 = mem.alloc_or_trap(4096);
defer mem.free(buf, 4096);
// you are responsible for cleanupCan 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 aftermem.free, or keeping&localpast its function will crash.defer mem.free(...)keeps cleanup next to the allocation; it does not make misuse impossible. - Slices. A
[]Tview 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: