Memory
Manual memory management is the default in dusk. There is no garbage collector built into the language; the collector<T> wrapper syntax is reserved for a later standard library allocator strategy. Instead, the language gives you explicit allocation, deterministic cleanup with defer, swappable allocators, and a generational heap that turns a use after free or a double free into a deterministic fault instead of memory corruption.
This guide covers the day-to-day workflow. The memory reference states the rules precisely, and the std.memory pages document the shipped allocators.
Stack by default
Section titled “Stack by default”A normal variable declaration is stack allocated. No annotation is needed, and nothing has to be freed.
x: int32 = 5 // stack allocatedHeap memory enters the picture only through alloc. There is no address-of operator for stack variables; pointers exist only as the result of an explicit heap allocation.
alloc, free, and defer
Section titled “alloc, free, and defer”alloc heap-allocates a value and returns a managed pointer to it. The allocation size is inferred from the declared type on the left hand side, so you never pass a byte count, which prevents size and type mismatch bugs.
x: *int64 = alloc(100) // 8 bytes, initialized to 100y: *char = alloc('c') // 1 byte, initialized to 'c'z: *int64 = alloc() // 8 bytes, uninitializedThe uninitialized form requires the pointer annotation, since the annotation is what sizes the block. A bare x := alloc() is a compile error.
Heap values are dereferenced explicitly with the * prefix operator; there is no implicit dereference. free releases the allocation, and defer schedules cleanup to run when the enclosing function scope exits, in reverse order of registration, including on an early return.
func main() -> int32 { p: *int64 = alloc(100) defer free(p) println(*p + 1) return 0}defer makes deallocation deterministic and visible without any ownership tracking. One restriction: a defer sits at the top level of its function. Registration is lexical and every return replays the list, so a defer inside a conditional or a loop is a compile error. Dynamic registration is planned.
Pointers themselves are immutable. Once a pointer binding is assigned it cannot be reassigned to a different address; reassigning an owning pointer is rejected as a leak.
The allocator interface
Section titled “The allocator interface”An allocator is any type that implements the built-in Allocator interface:
interface Allocator { alloc(size: int64, align: int64) -> *void free(p: *void) -> void}The standard library ships four allocators that implement it:
Heap: backed by libc, the default when no allocator is in scope.Arena: frees everything at once; per-objectfreeis a no-op.FixedBuffer: a bump allocator over a buffer you provide, touching no heap. Good for embedded or scratch use.Debug: tracks allocations to report leaks and catch a double free, and poisons freed memory.
You can write your own allocator by implementing the interface. You never redefine alloc itself. No builtin is overridable, and there is no function overloading.
using parameters
Section titled “using parameters”alloc and free are builtins, but they are not a fixed implementation: they lower to a call on the allocator that is in scope. A function that allocates marks a parameter with using to designate it as the ambient allocator for that function body. The signature shows the function needs an allocator, while the body stays readable: you write alloc(...), not allocator.alloc(...).
The spec’s general shape takes the interface type:
func work(using allocator: Allocator) -> void { p: *Point = alloc(Point { x: 1.0, y: 2.0 }) // uses the passed allocator defer free(p)}In the current compiler, the repository’s examples pass a concrete allocator type through using, which also makes the dispatch static and zero cost. Dispatch falls back to a vtable call only when the allocator type is erased behind the interface. Here is a complete program routing allocations through an explicit Heap:
@import std.memory.allocator
func work(using a: Heap) -> void { p: *int64 = alloc(8) *p = 41 println(*p + 1) free(p)}
func main() -> int32 { h := heap() work(h) return 0}Two rules to keep in mind:
freemust run under the allocator that produced the pointer, the same caller-matches rule C allocators follow. Inside ausingscope,freegoes to the scope’s allocator, so do not free a default heap block there.maincan declareusing allocator: Allocatoras its last parameter to set the program-wide ambient allocator, but that form is planned and the compiler rejects it today. With no allocator in scope,allocandfreeuse the default heap.
Arenas
Section titled “Arenas”An arena is a bump allocator over one backing buffer. Allocations carve forward from the buffer, individual frees are a no-op, and the whole arena is reset or destroyed at once. Arenas are the ergonomic answer to threading an allocator through code: nothing inside needs an individual free.
std.memory.arena provides arena_new(cap), arena_alloc(a, size), arena_reset(a), and arena_destroy(a). Arena also implements Allocator, so it can be passed with using and the alloc builtin dispatches to it. Pass the arena by pointer so the bump offset persists across calls.
@paradigm procedural
@import std.memory.arena
func fill(using a: Arena) -> int64 { p: *int64 = alloc(8) *p = 1 q: *int64 = alloc(8) *q = 2 return a.used}
func main() -> int32 { a: *Arena = alloc(arena_new(64)) println(fill(*a)) arena_destroy(a) free(a) return 0}The two eight-byte allocations leave used at 16, which the program prints. arena_reset rolls the offset back to zero and keeps the buffer; arena_destroy frees the backing buffer. Exhausting an arena aborts rather than handing out memory past its end.
The debug allocator
Section titled “The debug allocator”The Debug allocator from std.memory.allocator tracks each allocation. It reports leaks and double frees through two counters, debug_leaks() and debug_double_frees(), and poisons freed memory with 0xDD. Route a section of code through it with using, then read the counters:
@paradigm procedural
@import std.memory.allocator
func work(using a: Debug) -> void { p: *int64 = alloc(8) *p = 1 q: *int64 = alloc(8) *q = 2 free(q) free(q)}
func main() -> int32 { mut d := debug() work(d) println(debug_leaks()) println(debug_double_frees()) return 0}p is never freed, so it leaks; q is freed twice. The program prints 1 and 1. This is a diagnostic tool you opt into per scope. The always-on safety story is the generational heap below.
The generational heap
Section titled “The generational heap”Since 0.2.1, the default heap is generational. A managed pointer *T is a fat pointer: the data pointer paired with a remembered generation. The heap writes a live generation in a header before each block, and free bumps it and parks the block on a size-matched free list. Every managed dereference compares the remembered generation against the header (in every build, not just debug) and faults on a use after free, a double free, or a stale pointer to a reused block, instead of corrupting memory. The generation rides inside the pointer value, so the check survives copies.
func main() -> int32 { p: *int64 = alloc(7) free(p) println(*p) return 0}This program compiles, and at run time it dies with a named fault rather than reading freed memory:
fatal: use of a freed or stale pointerA generation of zero is the untracked sentinel: a using allocator hands back unchecked memory, which is how custom allocators keep working without a generation header.
Managed *T versus raw *raw T
Section titled “Managed *T versus raw *raw T”The pointer layer splits in two:
- Managed
*T: a fat pointer carrying a generation, checked at every dereference. This is whatallocreturns and what your own data structures should use. - Raw
*raw Tand*void: one-word pointers with no generation, used for strings, slice data, receivers, and collection buffers, withptr_addfor byte arithmetic. The raw layer is the honor system: no check runs.
The raw layer is also the foreign function boundary. A foreign "C" parameter or return type is a scalar, a *raw T, or a *void; a managed *T is rejected, since it is a fat value carrying a generation that C cannot read. A *raw T passes anywhere *void is expected, but not the reverse, and a managed pointer that round-trips through *void comes back untracked, so keep managed pointers on the managed layer. See the memory reference for the full rules.
Ownership: ref and move
Section titled “Ownership: ref and move”Since 0.2.2, a managed pointer is single owner, tracked statically by the checker:
- The binding created from
allocis the owner. Only the owner frees or moves. - A plain copy of an owner is a compile error:
cannot copy an owning pointer; bind a ref alias or move it. refbinds a non-owning alias.move(x)transfers ownership and invalidates the source; a later use is rejected withuse of a moved pointer.- Passing a pointer to a function borrows it. Freeing or moving a borrow is a compile error.
@paradigm procedural
struct Box { n: int64,}
func bump(b: *Box) -> void { (*b).n = (*b).n + 1}
func main() -> int32 { owner: *Box = alloc(Box { n: 1 }) bump(owner) // borrows; main still owns ref alias: *Box = owner // non-owning alias println((*alias).n) moved: *Box = move(owner) // owner is now dead println((*moved).n) free(moved) // the new owner frees return 0}The static pass covers the clear cases, and since 0.2.3 a return that lets a frame local escape (a slice viewing a frame-local array, a closure capturing a frame local) is a compile error too. The raw layer is exempt from ownership tracking, and the runtime generation check backstops whatever the static pass cannot see, such as a pointer laundered through an aggregate.
Ownership also crosses threads: chan_send(c, move(p)) hands a heap record to a receiving thread with the sender’s name dead at compile time. See Concurrency.
Where to go next
Section titled “Where to go next”- Memory reference: the precise rules for pointers, allocators, and ownership.
- std.memory: the shipped allocators and their APIs.
- Errors: the other half of writing robust dusk code.