Memory Model
Zynx enforces a compiler-driven ownership model with deterministic cleanup. The runtime is minimal and avoids pointer tagging or background garbage collection.
This document explains:
- How scope-based management works in practice.
- How moves and borrows interact with the type system.
- How struct returns and explicit heap allocation with
Unique<T>work.
For an overview of the type system and references, see:
Automated Scope-Based Management (ASBM)
Values are cleaned up immediately when their owning scope ends. In most cases, you do not need manual memory management; the compiler inserts appropriate drop calls at precise points.
struct Logger {
id: i32,
drop(self) {
os.write("Drop {self.id}\n");
}
}
fn main() {
let a = Logger { id: 1 };
let b = Logger { id: 2 };
// a and b are dropped here, in reverse declaration order
}
Key properties:
- Destructors (
dropmethods) run deterministically when a value's scope ends. - Destruction order within a scope is well-defined (typically reverse declaration order).
- There is no tracing garbage collector; all lifetimes are determined statically.
ASBM is closely tied to the borrow checker and move semantics, described next.
Move and Borrow Rules
Ownership and borrowing are enforced at compile time. The rules align with the rest of the language reference, but are summarized here from the memory model perspective.
Move Semantics
- Moving a value transfers ownership to the destination.
- After a move, the original binding can no longer be used, unless it is reinitialized.
struct Packet {
id: i32,
}
fn consume(p: Packet) {
// use p, then it is dropped at the end of this function
}
fn demo() {
let p = Packet { id: 1 };
consume(p); // ownership moves into consume
// p is now considered moved and cannot be used here
}
Move semantics eliminate double-free and use-after-free bugs in safe Zynx code.
Borrowing
Zynx distinguishes between immutable and mutable borrows:
- Immutable borrows (
const &T):- Many may exist at the same time.
- They guarantee read-only access.
- Mutable borrows (
&T):- At most one mutable borrow is allowed at a time.
- It cannot coexist with any immutable borrows of the same value.
fn inspect(x: const &i32) {
_ = x;
}
fn increment_mut(x: &i32) {
*x += 1;
}
fn demo() {
let v = 0;
const r1: &i32 = &v;
// const r2: &i32 = &v; // another immutable borrow is fine
// inspect(r1); // ok
// let m: &i32 = &v; // error: cannot take a mutable borrow while immutable borrows are live
}
Borrowing rules are enforced statically and integrated with lifetime analysis:
- References must not outlive the values they point to.
- The compiler diagnoses references that could escape beyond their owner’s scope.
See Scoping and Shadowing for additional examples of how scopes and lifetimes interact.
Struct Return and Heap Allocation
Returning structs efficiently is central to Zynx’s memory model. By default, Zynx prefers structure return (sret), where the caller provides storage for the result.
Structure Return (sret)
- For large or aggregate types, the caller allocates space and passes a hidden pointer to the callee.
- The callee initializes the value in-place and returns normally.
- This design avoids unnecessary heap allocations and copies.
From a Zynx programmer’s point of view, this is transparent:
struct Buffer {
data: u8[256],
}
fn make_buffer() -> Buffer {
return Buffer { data: [0; 256] };
}
fn main() {
let buf = make_buffer(); // typically lowered via sret
}
Explicit Heap Allocation with Unique<T>
Heap allocation in Zynx is always explicit. When a value needs to outlive its defining scope via a pointer, use Unique<T> to express this intent in the type signature.
Unique<T> is a single-owner heap-allocated value, similar to Box<T> in Rust or std::unique_ptr<T> in C++. It is created with the Unique(value) constructor.
struct Buffer {
data: u8[256],
}
fn make_buffer() -> Unique<Buffer> {
return Unique(Buffer { data: [0; 256] });
}
fn main() {
let buf = make_buffer();
// buf.data is accessible via auto-deref
// buf is automatically freed when main returns
}
Key properties:
- Explicit cost: The type signature
Unique<T>tells the caller that a heap allocation occurs. A function returning&Tis always a zero-cost borrow; a function returningUnique<T>always allocates. - Auto-deref: Fields and methods of
Tcan be accessed directly throughUnique<T>without manual dereferencing. - Coercion to
&T: AUnique<T>implicitly coerces to&Twhen passed to a function expecting a reference. - Move semantics:
Unique<T>is a move type — assigning or returning it transfers ownership without copying. - Automatic cleanup: The heap allocation is freed when the
Unique<T>goes out of scope, following the same ASBM rules as other owned values.
Returning a locally-created value as &T is a compile error:
fn bad() -> &Buffer {
return Buffer { data: [0; 256] };
// Error: returning locally-created value as `&` requires heap allocation;
// use `Unique<Buffer>` return type instead
}
This ensures that every heap allocation is visible in the type system.
Restrictions
Unique<Unique<T>>is not allowed (no nested unique pointers).Unique<&T>is not allowed (use&Tdirectly).
Interaction with Other Features
- Async / Await: borrows may not cross
awaitpoints; see Async / Await for current suspend-safety rules. - Error Handling: error unions (
T!) do not by themselves force heap allocation.
Related Topics
For deeper background and related behavior, see:
- Type System - value, reference, and pointer types.
- Control Flow - how scopes are formed by blocks.
- Error Handling - error unions and propagation.
- Async / Await - suspend-safety and lifetimes across
await. - Compiler Internals - lowering, analysis, and code generation pipeline.