Memory Overview

Current implementation

The compiler implements scope-based automatic cleanup for every managed value: strings, structs with managed fields, tagged unions with managed payloads, optionals and T or E results of those, fixed arrays of them, and the containers collections generates. All other heap memory is manual (mem.alloc / mem.free + defer).

Managed types

A value is managed when it owns heap memory:

  • string (a computed string owns its buffer; a literal owns nothing)
  • Structs with a managed field
  • Tagged unions with a managed field in any variant
  • ?T of a managed T, and T or E kept whole in a variable
  • Fixed arrays of managed values ([4]string)
  • Generated containers (Vec_string, Map_i64_Value, …)

Managed locals get automatic cleanup when their block ends.

Scope-based cleanup

tape
fn process() {
    let name = build_name();    // managed string
    let data = read_input();    // managed string
    // both are freed automatically at scope exit
}

A scope is a block: a function body, an if branch, a loop body (every iteration), a match arm. The compiler drops at every exit point (end of block, return, break, continue, early return), in reverse declaration order. A value nobody keeps (a string built just for a call, an ignored result) is dropped by the compiler too.

Copy semantics

Assigning a managed value to a new variable produces a deep copy:

tape
let a = "hello";
let b = a;  // b is an independent copy (deep copy if dynamic)

Static strings (string literals, cap == 0) are shallow-copied (just the pointer). Dynamic strings (built at runtime, cap > 0) are heap-duplicated. Structs, tagged unions and containers copy their managed parts the same way.

Move on return

When a function returns a managed local, the compiler skips the drop for that local — the value is “moved” to the caller:

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

Returning anything else that is managed (a parameter, a field) returns a copy.

Manual allocation

For raw memory, use the mem module:

tape
import mem from "mem";

let ptr: *u8 = mem.alloc_or_trap(1024);   // or mem.alloc(1024) or return;
defer mem.free(ptr, 1024);

Available functions:

FunctionDescription
mem.alloc(size: i64) -> *u8 or mem.MemErrorAllocate bytes (OutOfMemory as an error)
mem.alloc_or_trap(size: i64) -> *u8Allocate bytes (trap: out of memory when there are none)
mem.realloc(p, old, new) -> *u8 or mem.MemErrorResize a block
mem.free(ptr: *u8, size: i64)Free allocation
mem.set(dst, val, count)Fill memory
mem.copy(dst, src, count)Copy bytes (non-overlapping)
mem.move(dst, src, count)Copy bytes (overlapping safe)
mem.zero(dst, count)Zero-fill
mem.compare(a, b, count) -> i32Compare bytes
mem.equal(a, b, count) -> boolEquality check
mem.live_allocs() -> i64Heap blocks currently allocated (strings included)

Checking for leaks

mem.live_allocs() counts the heap blocks currently allocated, string buffers included, in the VM and natively alike. Compare it before and after a piece of code: if it grew, something leaked; if it shrank, something was freed twice.

tape
let before: i64 = mem.live_allocs();
work();
if (mem.live_allocs() != before) { @trap("leak"); }

Defer for cleanup

Use defer to schedule cleanup for manual resources:

tape
var file: fs.File = fs.open("data.bin", fs.Access.Read, fs.Creation.Existing) or return;
defer file.close();

let buf: *u8 = mem.alloc_or_trap(4096);
defer mem.free(buf, 4096);

See Defer & Errdefer for details.

PlannedThe design specifies profile-based memory strategies: t0 fully manual (no implicit heap), t1 scope-based ownership, t2 arena allocation with a simple garbage collector for long-running scripts. t0 already works that way: code that would allocate implicitly (string concatenation, interpolation) is a compile error. t2 still uses t1’s scope-based cleanup; its arena and GC are not implemented yet.

Last modified: