Memory Model — Node Resource Analysis (NRA)

Zith uses Node Resource Analysis (NRA) to guarantee memory safety without a borrow checker or garbage collector. NRA is the final compiler pass — after SEMA and SOLVE have fully resolved all types — and is responsible for tracking ownership, aliasing, and lifetime validity through the entire program.

ℹ What NRA does: tracks whether a node is alive, dead, or lent; ensures unique nodes have exactly one owner; validates that belong nodes do not outlive their parent; and enforces argument exclusivity in every call expression.

Memory Modifiers (The Five Ownership Modes)

Modifier Owns? Exclusive? Mutable? Common Use
default Yes Yes Follows binding Variables, struct fields — no explicit keyword needed
lend No Yes Yes Exclusive mutable temporary borrow for function arguments
view No No No Read-only non-owning reference; many can coexist
unique Yes Yes Yes Single-owner guarantee; ownership transfer patterns
share Yes No Yes Compile-time-proven shared mutable state; no ref-count
belong No Follows parent Part-of relationship; back-pointers, hierarchies

💡 In practice, most code only needs lend and view. The rest are for specific ownership patterns.

Default Ownership

When no keyword is specified, the type uses default ownership: the binding owns its value exclusively, and the value follows the binding's lifetime.

let pt = Point { x: 1.0, y: 2.0 };  // default ownership
var data = Buffer.new(1024);        // default ownership, mutable binding

Move Semantics

Moving a value from a to b redirects the name b to a's node. The name a becomes dead and cannot be read — only reassigned:

var a = Point { x: 1.0, y: 2.0 };
let b = a;                          // b -> a's node; a becomes dead
// @println(a.x);                   -- COMPILE ERROR: a is dead
@println(b.x);                       // OK

a = Point { x: 3.0, y: 4.0 };      // OK: reassignment creates a new node for a

Effectively, if a is never reassigned, it is as if a never existed and b has always held the value.

The Four NRA Rules

Rule 1 — Argument Exclusivity

In any call expression, each argument must refer to a distinct node. No exceptions.

fn update(a: lend Point, b: lend Point) { }
let pt = Point { x: 1.0, y: 2.0 };
// update(pt, pt);                  -- COMPILE ERROR: same node in two arguments

Rule 2 — No Dead Node Access

A symbol may not be read if its node is in state dead.

let a = Point { x: 1.0, y: 2.0 };
let b = a;
// @println(a.x);                   -- COMPILE ERROR: a is dead after move

Rule 3 — No Escaping belong

A belong node may not be stored in any location whose lifetime exceeds any node in its dependency nodes. At every use, all parents must be alive.

struct Node<T> {
    data: T,
    parent: ?belong Self,           // back-pointer; lifetime tied to parent
}

Rule 4 — lend Behavioural Promise

A lend value may not be stored, moved, or captured. It may be passed as a call argument or returned (passing the promise to the caller).

fn scale(p: lend Point, factor: f32) { p.x *= factor; p.y *= factor; }
var pt = Point { x: 3.0, y: 4.0 };
scale(pt, 2.0);                      // pt is lent to scale
@println(pt.x);                      // OK: lend has ended

Ownership in Practice

lend — Exclusive Temporary Borrow

A lend parameter gets exclusive mutable access. The caller retains ownership and regains access after the function returns. belong fields can be passed as lend to functions.

fn move_by(p: lend Point, dx: f32, dy: f32) {
    p.x += dx;
    p.y += dy;
}

var pt = Point { x: 0.0, y: 0.0 };
move_by(pt, 5.0, 3.0);
// pt is accessible again here — lend has ended

view — Read-Only Non-Owning Reference

Many view references may coexist. The underlying value cannot be mutated through a view.

fn length(p: view Point): f32 {
    sqrt(p.x * p.x + p.y * p.y)     // OK: read-only
}

let pt = Point { x: 3.0, y: 4.0 };
let v1: view Point = pt;
let v2: view Point = pt;            // fine: multiple views

unique — Single-Owner Guarantee

Only one name in the graph may point to a unique node. This is ideal for ownership-transfer patterns.

let resource = unique Buffer.new(1024);
let other = resource;               // transfer: other now owns
// resource.use();                  -- COMPILE ERROR: resource is dead

share — Compile-Time-Proven Sharing

share allows multiple names to point to the same node. All can mutate. No reference-counting — the compiler statically proves correctness.

let a: share Config = loadConfig();
let b: share Config = a;            // both point to the same node
b.port = 9090;                      // mutation visible through a

belong — Part-Of Relationship

A belong node is structurally part of its parent and cannot outlive it. It cannot be stored independently and can be passed as lend to functions. Used for back-pointers and hierarchies.

struct Node<T> {
    data:     T,
    children: []unique Self,
    parent:   ?belong Self,         // back-pointer: lifetime tied to parent
}

fn getParent(self: view Node): lend Node { self.parent }

implement Node<T> {
    fn append(self: lend Self, data: T) {
        self.children ~= unique Node {
            data,
            children: [],
            parent: belong self,     // belong ties lifetime to self
        };
    }
}

Self-Referential Types

Zith supports self-referential types through unique (owning forward pointers) and belong (non-owning back-pointers). NRA guarantees belong never outlives its owner, eliminating the need for weak pointers or unsafe.

struct Node<T> {
    data: T,
    next: ?unique Self,              // owns next; null at tail
    prev: ?belong Self,              // back-ref; null at head
}

implement Node<T> {
    fn isHead(self): bool { self.prev is null }
    fn isTail(self): bool { self.next is null }

    fn append(self: lend Self, data: T) {
        let new = unique Node { data, next: null, prev: belong self };
        self.next = new;
    }
}

// Freeing the head frees the entire chain (unique ownership chain)

Branch Isolation

Inside conditional branches (if/else/when), each branch operates in isolation: a move, mutation, or invalidation inside one branch does not affect other branches. After all branches complete, NRA collects all side-effects and applies them to the enclosing scope.

The Return Trick

If a conditional expression is used to recover a value but not all paths return, the compiler implicitly deduces null for the missing paths. The result type becomes ?T:

let result = if (v is i32) { v } else { };     // ?i32

NRA Limitations & Escape Hatches

NRA cannot statically validate all shared/view cycles across threads. Three escape hatches are available:

await — Structured Concurrency

let handle = spawn worker(shared_data);
await handle;                                  // compiler knows thread is done before scope ends

#wontRemain — Promise

#wontRemain let _ = spawn quick_task(shared_data);  // promise: thread dies before scope ends

Rc — Runtime Ref-Count Fallback

let shared = Rc.new(HeavyResource.init());
let _ = spawn worker(Rc.clone(shared));             // runtime reference counting

Summary Table

Modifier Owns Exclusive Mutable Can Be Stored Can Be Returned
default Yes Yes Follows binding Yes Yes
lend No Yes Yes No Yes (passes promise)
view No No No Yes Yes
unique Yes Yes Yes Yes Yes
share Yes No Yes Yes Yes
belong No Follows parent Only inside parent As lend

Best Practices

See also: Spec: Memory Model (NRA)


Next: Memory Management — allocation, scenes, and runtime memory strategies.