Skip to content

12. Memory Management

Most languages pick one of two strategies for memory. Python (and Java) hand the job to a garbage collector: you allocate freely and a runtime later sweeps up what you abandoned, at the cost of pauses and unpredictable timing. C hands the job to you: you malloc and you free, and if you get it wrong you get leaks, double-frees, and use-after-free bugs.

Sushi takes a third road, the one Rust and modern C++ travel: the compiler tracks ownership and inserts cleanup for you, at compile time, with no runtime collector. You get C's predictability and Python's "I didn't have to think about it" — and the compiler refuses to build programs that would corrupt memory. This chapter is how that works.

RAII: cleanup rides on scope

RAII stands for "Resource Acquisition Is Initialization", which is a mouthful for a simple idea: when a value goes out of scope, its resources are released automatically. A heap-allocated list, a string buffer, a file handle — when the variable holding it reaches the end of its block, the compiler has already arranged for the cleanup.

# RAII: cleanup happens automatically at the end of a scope.
# The List below allocates memory on the heap, but we never free it
# by hand. When `crew` goes out of scope at the end of main, the
# compiler has already inserted the cleanup for us.

fn main() i32:
    let List<string> crew = List.new()
    crew.push("Arthur Dent")
    crew.push("Ford Prefect")
    crew.push("Trillian")

    println("Crew size: {crew.len()}")

    foreach(member in crew.iter()):
        println("  - {member}")

    # No free(), no delete, no close(). The list's heap buffer is
    # released automatically when this function returns.
    return Result.Ok(0)

Output:

Crew size: 3
  - Arthur Dent
  - Ford Prefect
  - Trillian

Notice what's missing: there is no crew.free(), no delete, no defer. The List allocated a buffer on the heap, and that buffer is freed the instant main returns. You write the acquisition; the compiler writes the release.

Where the cleanup actually goes

The compiler emits the destructor call at every exit from the scope — the normal fall-through at the end, an early return, and the error path of a ??. That last one matters: even when an error propagates out mid-function, everything acquired so far is cleaned up first. RAII and error handling cooperate.

Move versus copy

When you assign one variable to another, what happens to the original? Sushi answers this differently depending on the type, and the rule is short:

  • Primitives and strings are copied. The original stays valid.
  • Dynamic arrays are moved. The original is consumed and may not be used again.
# Move semantics vs copy semantics.
#
# Primitives and strings are COPIED on assignment: the original
# stays valid. Dynamic arrays are MOVED: the source is consumed and
# can no longer be used (this is how Sushi avoids double-frees).

fn main() i32:
    # Primitives are copied
    let i32 x = 42
    let i32 y = 0
    y := x
    println("x is still usable: {x}")
    println("y is a copy: {y}")

    # Strings are copied too
    let string s1 = "Mostly Harmless"
    let string s2 = ""
    s2 := s1
    println("s1: {s1}")
    println("s2: {s2}")

    # Dynamic arrays are moved
    let i32[] source = from([1, 2, 3])
    let i32[] dest = new()
    dest := source
    # `source` is now moved-out and must not be touched again.
    println("dest length after move: {dest.len()}")

    return Result.Ok(0)

Output:

x is still usable: 42
y is a copy: 42
s1: Mostly Harmless
s2: Mostly Harmless
dest length after move: 3

Why the difference? A dynamic array owns a heap buffer. If assigning dest := source merely copied the pointer, you'd have two variables believing they own the same buffer — and when both went out of scope, RAII would free it twice. By moving instead (transferring ownership and marking the source as gone), Sushi guarantees exactly one owner, so exactly one free. Touch a moved-out variable and the compiler stops you cold with CE2405: cannot borrow moved variable — a use-after-free caught before the program ever runs. If you genuinely need two independent arrays, ask for one explicitly with .clone().

The same move rule covers every owning type — dynamic arrays, List<T>, and Own<T>. Passing one by value to a function moves it: the callee takes ownership and frees it at scope exit, so the caller must not use it afterwards. Borrow with &peek / &poke (or pass a .clone()) when you want to keep it. (One special case: main's string[] args is a borrowed view of the process argument vector, not a heap-owned array — borrow it downstream, never move it by value.)

References: borrowing without owning

Moving is great for ownership, but you often want to lend a value to a function without giving it away. That's a borrow, and Sushi has two flavours:

  • &peek T — a read-only borrow. You may look, not touch. Many peeks can coexist.
  • &poke T — a read-write borrow. You may modify in place. Exclusive: only one at a time.
# Borrowing with references.
#
# &peek T  - read-only borrow; many allowed at once
# &poke T  - read-write borrow; exclusive, only one at a time
#
# Borrowing lets a function reach into a variable without taking
# ownership of it. The caller keeps its variable afterwards.

# Read-only: this function may look but not touch.
fn announce(&peek i32 value) ~:
    println("The answer is {value}")
    return Result.Ok(~)

# Read-write: this function modifies the caller's variable in place.
fn increment(&poke i32 counter) ~:
    counter := counter + 1
    return Result.Ok(~)

fn main() i32:
    let i32 answer = 41
    println("Starting value: {answer}")

    # A read-only peek - the variable is untouched.
    announce(&peek answer)

    # A read-write poke - the variable is updated in place.
    increment(&poke answer)
    announce(&peek answer)

    # &poke safely coerces down to &peek where a read-only
    # borrow is expected (the reverse is not allowed).
    announce(&poke answer)

    return Result.Ok(0)

Output:

Starting value: 41
The answer is 41
The answer is 42
The answer is 42

The caller never loses answer; it lends it out and keeps using it afterwards. A &peek borrow is the zero-cost way to pass something read-only (no copy of the underlying data is made), and a &poke borrow lets a function mutate the caller's variable directly, which is how the unit-returning increment bumped answer from 41 to 42.

Note the last line: announce wants a &peek, and we handed it a &poke. That's allowed — a read-write borrow can safely coerce down to a read-only one. The reverse never happens: you cannot smuggle a read-only borrow into a slot that wants to write.

The borrow-checking rules

The exclusivity is not a guideline; it's enforced at compile time. The full ruleset:

  • Any number of &peek borrows of the same value may be active at once.
  • Only one &poke borrow may be active at a time.
  • You may not mix &peek and &poke borrows of the same value simultaneously.
  • A &poke coerces to &peek (safe downgrade); the reverse is forbidden.

These are exactly the rules that make data races and aliasing bugs impossible: shared read-only access is fine, but anyone who can write must have exclusive access.

Here's a program that breaks the second rule. It does not compile — it asks for two exclusive &poke borrows of num at the same call site:

fn swap(&poke i32 a, &poke i32 b) ~:
    let i32 t = a
    a := b
    b := t
    return Result.Ok(~)

fn main() i32:
    let i32 num = 42
    swap(&poke num, &poke num)   # two &poke borrows of num at once
    return Result.Ok(0)

The compiler rejects it with CE2403: 'num' already has an active &poke borrow (only one exclusive borrow allowed), and helpfully points at where the first borrow started. (Mixing a &peek and a &poke of the same value instead trips the closely related CE2407.) The fix is to give each exclusive borrow its own variable — the borrow checker is telling you, correctly, that two mutable aliases to the same memory is a bug.

Own<T>: explicit heap allocation

Sometimes you need a value on the heap by name — most commonly for recursive types, where a struct must contain itself (a linked-list node pointing at the next node). A struct can't physically embed an infinitely-nested copy of itself, so the recursive field has to be a pointer. Own<T> is that owned heap pointer.

# Own<T>: an owned heap allocation.
#
# Own<T> is Sushi's tool for putting a value on the heap explicitly.
# You need it for recursive types (a struct that contains itself),
# and it cleans up after itself via RAII - just like everything else.

struct Vogon:
    i32 id
    string name

fn main() i32:
    # Allocate a value on the heap.
    let Own<i32> answer = Own.alloc(42)

    # .get() reads the value back out.
    println("Heap-allocated answer: {answer.get()}")

    # Own<T> works with any type, including structs.
    let Vogon v = Vogon(7, "Prostetnic Vogon Jeltz")
    let Own<Vogon> boxed = Own.alloc(v)
    let Vogon loaded = boxed.get()
    println("Vogon #{loaded.id}: {loaded.name}")

    # `answer` is freed automatically by RAII at scope exit.
    # `boxed` we destroy by hand, just to show it is possible.
    boxed.destroy()

    return Result.Ok(0)

Output:

Heap-allocated answer: 42
Vogon #7: Prostetnic Vogon Jeltz

The three methods you'll reach for:

  • Own.alloc(value) — allocate value on the heap and hand back an Own<T>.
  • .get() — read the value back out.
  • .destroy() — free it by hand, right now.

You rarely need .destroy(): like everything else in this chapter, an Own<T> is freed automatically by RAII when it goes out of scope (answer in the example never gets a manual .destroy() and leaks nothing). It's there for the cases where you want to release a large allocation early. For an actual recursive structure, the pattern is a struct field typed Maybe<Own<Node>>Maybe.None() marks the end of the chain, and Sushi stays entirely null-free.

No nulls, ever

You may have noticed Sushi has no null literal. An absent pointer is Maybe.None(), a present one is Maybe.Some(...), and the compiler forces you to handle both. The entire category of null-pointer dereferences simply doesn't exist here.

What you learned

  • RAII frees resources automatically at scope exit — no collector, no manual free.
  • Primitives and strings are copied on assignment; dynamic arrays are moved, leaving the source invalid (use-after-move is caught as CE2405), so each heap buffer has exactly one owner and one free. Use .clone() for an independent copy.
  • References lend without owning: &peek is read-only and shareable, &poke is read-write and exclusive, and &poke coerces down to &peek.
  • The borrow checker enforces those rules at compile time (e.g. two &poke borrows of one value is CE2403; mixing &peek with &poke is CE2407).
  • Own<T> is explicit heap allocation for recursive types: .alloc(), .get(), .destroy() — though RAII usually frees it for you.

Next up: the standard collections that put all of this to work. On to Collections.