Memory management
You manage memory yourself by default. There is no garbage collector built into the language, though one arrives later through the standard library as an allocator strategy. What you get instead is a small, explicit toolkit: stack allocation for ordinary variables, alloc and free builtins that route through an in-scope allocator, defer for deterministic cleanup, arenas for bulk lifetimes, and a generational heap that checks every managed dereference at runtime.
This page is the normative reference for that toolkit. For a walkthrough, see the memory guide. For the standard library allocators themselves, see std.memory.
Stack allocation
Section titled “Stack allocation”Normal variable declaration results in stack allocation. No explicit action is needed.
x: int32 = 5 // stack allocatedThe 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 this interface:
Heap: the default, backed by libc.Arena: frees everything at once.FixedBuffer: a bump allocator over a caller-provided buffer, no heap, for embedded or scratch use.Debug: reports leaks and catches a double free.
Users can write their own allocator by implementing the interface. See interfaces for impl ... for syntax and std.memory for the shipped allocators.
alloc and free are sugar over the in-scope allocator
Section titled “alloc and free are sugar over the in-scope allocator”alloc and free are builtins, but they are not a fixed implementation. They lower to a call on the allocator that is in scope. Choosing the allocator type chooses the implementation that alloc resolves to. The default is the heap allocator.
The allocation size is inferred from the declared type on the left-hand side. The programmer does not pass a byte size, 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.
free must run under the allocator that produced the pointer. A using scope routes free to the scope’s allocator, so freeing a default heap block inside one hands it to the wrong allocator, the same caller-matches rule C allocators follow. Freeing a managed pointer also runs the generation check, so freeing a stale pointer to a reused block faults at the free instead of corrupting the live owner.
Users never redefine alloc. They implement the Allocator interface and pass a value in. No other builtin or function is overridable, and there is no function overloading.
Dispatch is static when the allocator’s concrete type is known at that point, which is the common case and is zero cost. It falls back to a vtable call only when the allocator type is erased behind the interface.
using-allocator parameters
Section titled “using-allocator parameters”A function that allocates must have an allocator in scope. You mark a parameter with using to designate it as the ambient allocator for that function body. Call sites stay clean: you write alloc(...), not allocator.alloc(...).
func work(using allocator: Allocator) -> void { p: *Point = alloc(Point { x: 1.0, y: 2.0 }) // uses the passed allocator defer free(p)}This keeps allocation explicit at the boundary (the signature shows the function needs an allocator) while keeping the body readable.
A stateful allocator’s state persists across calls because every method takes its receiver by pointer. This complete program defines a minimal bump allocator and passes it with using; two 8-byte allocations leave used at 16:
@paradigm procedural@paradigm oop
interface Allocator { alloc(size: int64, align: int64) -> *void free(p: *void) -> void}
struct Bump { base: *raw int8, used: int64,}
impl Allocator for Bump { func alloc(size: int64, align: int64) -> *void { off := self.used p := ptr_add(self.base, off) self.used = off + size return p } func free(p: *void) -> void { }}
func fill(using a: Bump) -> int64 { p: *int64 = alloc(8) *p = 1 q: *int64 = alloc(8) *q = 2 return a.used}
func main() -> int32 { buf: *raw int8 = alloc_bytes(64) mut b := Bump { base: buf, used: 0 } println(fill(b)) return 0}Dereferencing
Section titled “Dereferencing”Heap-allocated values are dereferenced explicitly with the * prefix operator. Implicit dereferencing is not allowed.
x: *int64 = alloc(100)y: int64 = 10 + *x // dereference x to get 100Struct fields behind a pointer are reached the same way: (*p).x. Managed pointers *T and the raw layer, *raw T and *void, are distinct kinds; see types for the split.
Scope cleanup with defer
Section titled “Scope cleanup with defer”Use defer to run cleanup when the enclosing function scope exits, in reverse order of registration, including on an early return.
p: *int64 = alloc(100)defer free(p) // runs at scope exit, even on early returny: int64 = *p + 1defer makes deallocation deterministic and visible without any ownership tracking.
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 cannot be honored and is a compile error. Dynamic registration is planned.
@paradigm procedural
struct Point { x: float64, y: float64,}
func main() -> int32 { n: *int64 = alloc(100) defer free(n)
p: *Point = alloc(Point { x: 1.0, y: 2.0 }) defer free(p)
println(*n + 1) println((*p).x) return 0}Arena allocation
Section titled “Arena allocation”An arena frees all of its allocations at once. Per-object free is a no-op. Arenas are the ergonomic answer to threading an allocator through code, and they fit a compiler’s allocation pattern well.
Arena lives in std.memory.arena and implements Allocator, so it can be passed with using and the builtins dispatch to it. Allocations carve forward from one backing buffer; the whole arena is reset with arena_reset or destroyed with arena_destroy. Exhausting the buffer aborts rather than handing out memory past its end.
@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}Debug allocator
Section titled “Debug allocator”The standard library’s Debug allocator tracks live allocations and detects three faults:
- Leaks: heap not freed by program or scope end.
- Double free: freeing an already-freed pointer.
- Use after free: freed memory is poisoned with
0xDD.
These are diagnostics from an opt-in allocator, not language guarantees. Route a section of code through it with using, then read debug_leaks() and debug_double_frees().
@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}This program prints 1 and 1: p is never freed, so it leaks, and the second free(q) is a double free.
Generational safety
Section titled “Generational safety”dusk does no ownership-based freeing: deallocation is manual, and defer and arenas keep it deterministic. Soundness comes from generational references, which landed in the 0.2.x line.
A managed *T is a fat pointer: the data pointer paired with a remembered generation. The default 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 and faults on a use after free, a double free, or a stale pointer to a reused block. This happens in every build, not only debug. The generation token rides inside each reference, so the check survives copies.
Two boundaries limit the check:
- A generation of zero is the untracked sentinel. A
usingallocator hands back unchecked memory, so custom allocators keep working; their pointers are on the honor system. - The raw layer,
*raw Tand*void, is one-word pointers with no generation. A managed pointer that round-trips through*voidcomes back untracked.
The generational heap is thread safe, and the dereference check stays armed on every thread. In a program that races, the check degrades to a best-effort backstop; see concurrency for the memory model.
n: *int64 = alloc(10)free(n)println(*n) // faults at runtime: use after free caught by the generation checkThis fragment compiles. The fault is a runtime check, not a compile error.
Garbage collector
Section titled “Garbage collector”Garbage collection is deferred. The collector<T> wrapper syntax is reserved. It ships first as a conservative collector, with a precise collector much later.
The main function
Section titled “The main function”main is a special function with a flexible signature. All parameters are optional.
func main() -> int32 { ... }func main(argc: int32, argv: string[]) -> int32 { ... }func main(argc: int32, argv: string[], using allocator: Allocator) -> int32 { ... }main returns an int32 exit code. 0 means success. If main declares a using allocator parameter, the program runs with that allocator as the ambient allocator. With no allocator parameter the default heap allocator is used.
The allocator form is planned. The compiler rejects it until the entry wrapper that constructs the ambient allocator lands, so a program never reads a garbage register where the allocator should be. Any other unsupported shape is also named and rejected.
The argc/argv form works today. The compiler emits a C ABI entry wrapper that receives the real (int, char**) and builds the dusk slice, so argv.len matches argc.
@paradigm procedural
func main(argc: int32, argv: string[]) -> int32 { println(argv.len) return 0}