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.
The compiler classifies each type as copyable or move-checked.
| Copy | Move-checked |
|---|---|
| numeric scalars | plain user structs |
bool, str, null | arrays and slices |
| enums | references |
| raw pointers | callable values |
| SIMD vectors | error unions |
| nullable wrappers of Copy values | Unique<T> |
structs marked Copy | future, 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.
&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.
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.
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 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.
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.
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.