Memory Model

Ownership, borrowing, returns, drops, and manual memory in current Zynx.

Zynx uses deterministic ownership with explicit borrowing. There are no lifetime annotations today; instead, the analyzer follows values through blocks, branches, loops, calls, match, await, and returns, then rejects code that would use a moved value or keep an invalid borrow alive.

This page is the deeper reference. For the everyday introduction, start with Move, Copy, and Clone. For the cleanup-focused guide, see RAII and Drop.

Copy And Move

The compiler classifies each type as copyable or move-checked.

CopyMove-checked
numeric scalarsplain user structs
bool, str, nullarrays and slices
enumsreferences
raw pointerscallable values
SIMD vectorserror unions
nullable wrappers of Copy valuesUnique<T>
structs marked Copyfuture, channel, socket, string, and resource handles

Moving transfers ownership. The old binding cannot be used unless the type is Copy.

struct Resource {
    id: i32,
}

struct Point: Copy {
    x: i32,
    y: i32,
}

fn consume(resource: Resource) -> i32 {
    return resource.id;
}

fn main() {
    let resource = Resource { id: 7 };
    let id = consume(resource);

    let point = Point { x: 1, y: 2 };
    let alias = point;

    assert id == 7;
    assert point.x == 1;
    assert alias.y == 2;
}

At control-flow joins the checker is conservative. If a branch or loop can move a value, later code must not assume the old binding is still available.

Borrowing

&T is a mutable reference. const &T is a read-only reference. A mutable reference is an exclusive borrow; a read-only reference is a shared borrow.

fn read(value: const &i32) -> i32 {
    return *value;
}

fn write(value: &i32, next: i32) {
    value = next;
}

fn main() {
    let count = 1;

    assert read(&count) == 1;
    write(&count, 2);
    assert count == 2;
}

Function and callable arguments require explicit borrows for reference parameters:

fn read(value: const &i32) -> i32 {
    return *value;
}

fn main() {
    let count = 42;

    assert read(&count) == 42;
    assert read(&count) == 42;
}

Method receivers keep receiver auto-borrowing for self: &T ergonomics. Operator overload operands keep the same receiver-style borrowing because operator syntax has no explicit argument list. Calls can also implicitly dereference a reference for a value parameter when the referenced type is Copy.

fn take(value: i32) -> i32 {
    return value;
}

fn main() {
    let count = 42;
    let borrowed: const &i32 = &count;

    assert take(borrowed) == 42;
}

Use const &T for read-only parameters. A non-const reference parameter is expected to mutate the referenced value; the compiler reports one that is never modified.

Borrow Scope

Mutable borrows are exclusive. While a mutable borrow is live, the owner cannot be borrowed again, moved, or reassigned.

fn main() {
    let value = 1;
    let borrowed: &i32 = &value;

    borrowed = 2;
    assert value == 2;
}

Borrowing a field or array element also protects the owning aggregate from whole-value reassignment while that borrow is live.

struct Box {
    value: i32,
}

fn main() {
    let box = Box { value: 1 };

    if true {
        let value_ref: &i32 = &box.value;
        value_ref = 7;
    }

    box = Box { value: 2 };
    assert box.value == 2;
}

Put borrows in smaller blocks when a later mutation or reassignment should be allowed.

Async Suspension

Mutable borrows may not live across await or select in an async fn. Suspension can let other work run while the borrow is still live, so the checker requires the borrow to end first.

async fn one() -> i32 {
    return 1;
}

async fn main() {
    let value = 10;

    if true {
        let borrowed: &i32 = &value;
        borrowed = 11;
    }

    let out = await one();
    assert out == 1;
    assert value == 11;
}

Read-only borrow calls that finish before the suspension point are fine. A defer may not live across await or select, and await is rejected inside defer.

Returning References

Returning a borrowed input as a reference is allowed because the returned reference points at caller-owned storage.

struct Box {
    value: i32,
}

fn id(box: &Box) -> &Box {
    return box;
}

fn main() {
    let box = Box { value: 7 };
    let borrowed = id(&box);

    assert borrowed.value == 7;
}

Returning a reference to a locally-created value is rejected. Use Unique<T> when a newly-created value must escape the function as owned heap storage.

struct Point {
    x: i32,
    y: i32,
}

fn make_point() -> Unique<Point> {
    let point = Point { x: 10, y: 20 };
    return Unique(point);
}

fn main() {
    let point = make_point();

    assert point.x == 10;
    assert point.y == 20;
}

Unique(value) takes one non-reference value. Passing a move-only value to it consumes the source binding.

Drops And Reassignment

Types can define drop(self) to release owned resources. Drops run on scope exit, reassignment of an owning binding, and explicit std.drop(value).

import std.os;

struct Inner {
    id: i32,

    drop(self) {
        _ = os.write("drop inner {self.id}\n");
    }
}

struct Outer {
    inner: Inner,

    drop(self) {
        _ = os.write("drop outer {self.inner.id}\n");
    }
}

fn main() {
    let value = Outer { inner: Inner { id: 1 } };
    value = Outer { inner: Inner { id: 2 } };

    std.drop(value);
}

On reassignment, the previous owner is dropped before the new value is stored. For nested owners, the outer drop(self) body runs first, then owned fields are dropped. Moving a value transfers its eventual drop to the new owner, so the moved-from binding is not dropped.

A Copy struct cannot declare drop, and all fields in a Copy struct must also be Copy.

The current implementation also tracks liveness and can place cleanup when an owner is no longer live. defer bodies registered in a scope run before automatic owner cleanup for that scope, and std.drop(value) consumes the local binding immediately.

Manual Memory

Raw pointers use *T and const *T. They are copyable low-level values for interop and manual memory. Raw pointers are already nullable, so use null directly with *T.

fn main() {
    let value: u32 = 10;
    let pointer: *u32 = &value as *u32;

    unsafe {
        *pointer = 42;
    }

    let none: *u32 = null;
    unsafe {
        assert *pointer == 42;
    }
    assert pointer != none;
}

Raw pointers support dereference, indexing, pointer arithmetic, comparison, *T <-> *U casts, and *T <-> usize/isize casts. *void cannot be dereferenced, and raw pointer-to-reference casts are rejected. Prefer references for checked borrows; use raw pointers where native boundaries or manual memory require them.

std.mem.slice_of<T>(ptr, len) creates a non-owned [T] view over existing storage. The slice does not extend the lifetime of that storage.

import std.mem;

fn main() {
    let values: [i32, 3] = [4, 5, 6];
    let view = mem.slice_of<i32>(values.ptr, values.length);

    assert view.length == 3;
    assert view[2] == 6;
    assert !mem.is_owned<i32>(view);
}

Use std.mem.owned_slice<T>(ptr, len) only for allocation-backed storage that the slice should own and release. Do not mark stack arrays or borrowed storage as owned.