std/arena
An arena stores values and gives back handles instead of pointers. You use a handle to access or update a value later. The arena owns everything; your code holds only handles. If a handle's slot was reused since you got the handle, lookup returns None rather than someone else's value.
This is useful when values need to reference each other, such as nodes in a graph, entries in a cache, or entities in a game. Normal ownership can't model cycles (A owns B owns A?), but handles can.
from "std/arena" import { Arena, Handle }Quick start
Store some values, get handles, look them up:
from "std/arena" import { Arena, Handle }
fn main(): i32 {
var names: Arena<string> = Arena<string>.new()
let alice = names.alloc("Alice")
let bob = names.alloc("Bob")
print(names.get(alice)!) // "Alice"
print(names.get(bob)!) // "Bob"
names.free(alice)
match names.get(alice) {
Some(n) => { print(n) }
None => { print("gone") } // prints "gone" — handle is stale
}
return 0
}How it works
One arena per data type. An Arena<string> holds strings. An Arena<Node> holds nodes. You don't mix types in a single arena.
The lifecycle is simple:
- Create:
var a = Arena<string>.new() - Store —
a.alloc(value)puts a value in and returns aHandle<T> - Access —
a.get(handle)returnsOption<T>—Someif alive,Noneif stale - Update —
.set()replaces a value;.modify()transforms it with a function - Remove —
a.free(handle)removes the value and recycles the slot
Handles are cheap. They contain an arena identity, slot index, and generation. Copy them freely, store them in Vecs, pass them around. They don't own anything — the arena does.
Stale handles are safe. Every slot has a generation counter that bumps on free. If you hold a handle from generation 2 but the slot is now on generation 3, .get() returns None instead of the wrong value. A generation that reaches its maximum retires the slot instead of wrapping onto an old handle.
Live handles can be enumerated. a.handles() returns a snapshot. Freeing or allocating after the call does not change that Vec; each returned handle is still generation-checked when used.
Example: a graph with cycles
Nodes that reference each other. Plain ownership can't express a cycle (who owns whom?); an arena can.
from "std/arena" import { Arena, Handle }
struct Node {
name: string,
neighbors: Vec<Handle<Node>>,
}
fn main(): i32 {
var graph: Arena<Node> = Arena<Node>.new()
let a = graph.alloc(Node { name: "A", neighbors: Vec.new() })
let b = graph.alloc(Node { name: "B", neighbors: Vec.new() })
let c = graph.alloc(Node { name: "C", neighbors: Vec.new() })
// wire up a cycle: A -> B -> C -> A
// modifyMut hands the closure the stored value to change in place
let _ = graph.modifyMut(a, (node: &mut Node): void => { node.neighbors.push(b) })
let _ = graph.modifyMut(b, (node: &mut Node): void => { node.neighbors.push(c) })
let _ = graph.modifyMut(c, (node: &mut Node): void => { node.neighbors.push(a) })
// traverse: start at A, follow first neighbor twice
let nodeA = graph.get(a)!
let nodeB = graph.get(nodeA.neighbors[0])!
let nodeC = graph.get(nodeB.neighbors[0])!
print($"{nodeA.name} -> {nodeB.name} -> {nodeC.name}") // A -> B -> C
return 0
}Gotchas
.get() returns a copy. Changing the returned value doesn't update the arena. Use .modifyMut() or get/set:
var node = graph.get(handle)!
node.name = "changed" // changes your local copy, not the arena
// option 1: modifyMut (changes the stored value in place)
graph.modifyMut(handle, (n: &mut Node): void => { n.name = "changed" })
// option 2: get, change, set (explicit)
var node2 = graph.get(handle)!
node2.name = "changed"
graph.set(handle, node2)Handles are checked against their arena. A Handle<string> from arena A still type-checks against arena B because both have the same element type, but every handle carries an arena identity. Arena B returns None/false instead of reading a coincidentally matching slot.
Enumeration is a snapshot. a.handles() allocates a Vec of the handles that are live at that moment. Later frees make entries stale, and later allocations do not appear in the existing snapshot. Keep your own handle Vec when you need a continuously maintained index.
Memory grows, doesn't shrink. Freed slots get recycled by the next .alloc(), but the backing storage never shrinks. Fine for most use cases — be aware if you're allocating millions and freeing most of them.
API reference
Arena
pub struct Arena<T>A pool of T values addressed by generational Handle<T> rather than by reference. Allocation appends to _data or reuses a slot from _freeList; every operation is O(1) and none of them can dangle.
The safety of a handle rests on two checks, one per field pair. Every field is private, so only this file can break them:
_id — a per-process identity stamped into every handle this arena hands out, so a handle from a DIFFERENT arena (or from this one before a clear) is rejected outright instead of indexing into unrelated memory. _gens — one counter per slot, parallel to _data, bumped on every free. A handle carries the generation current when it was made, so a read through a freed-and-reused slot mismatches and yields None. A slot that reaches i32 max is retired, never wrapped, so the counter cannot alias an old handle by rolling over.
Memory model worth knowing before you build on it: freeing recycles a slot but never shrinks _data, so a long-lived arena holds its PEAK footprint for as long as it lives. clear is the only thing that gives storage back.
Nothing here is a garbage collector. free is logical deletion — it marks a slot dead so the generation check can catch stale handles. The arena's actual memory is owned by _data and released when the arena drops, like any other Milo value. If your object graph has cycles, reclaiming them is a sweep you write yourself over handles().
Arena.alloc
fn Arena.alloc(self: &mut Arena, val: T): Handle<T>Move val into a free slot and return a Handle<T> naming it. Reuses a slot from the free list when one exists, otherwise grows the arena.
Arena.clear
fn Arena.clear(self: &mut Arena)Drop every value and release the backing storage. The arena takes a fresh identity, so every handle ever issued before this point is now stale. The only operation that returns memory to the allocator.
Arena.free
fn Arena.free(self: &mut Arena, h: Handle<T>): boolLogically delete the slot: drop the value, bump the slot's generation so every outstanding handle to it goes stale, and offer the index for reuse. False if h was already stale — so a double free is a no-op, not a fault. Does NOT shrink the arena, and does not chase handles held INSIDE the freed value; a cyclic graph still needs a sweep you write.
Arena.freeze
fn Arena.freeze(self: Arena): Result<FrozenArena<T>, FreezeRejected<T>>End the build phase: consume this arena and hand back a FrozenArena<T> whose get returns T rather than Option<T>. self is moved, so any later use of this binding is a compile error.
Errs if this arena ever freed a slot; see arenaFreeze for why that is refused rather than papered over.
Arena.get
fn Arena.get(self: &Arena, h: Handle<T>): Option<T>A COPY of the value at h, or None if h is stale.
This copies the whole T out of the slot every call, and for any T that owns heap that copy is an ALLOCATION, not a few words — 20ms per 200k reads of a node with one Vec field, against ~0 for the borrowing paths. Reach for it only when T is plain scalars or you genuinely want an owned copy.
To read without copying, use with, which lends &T to a closure and returns what the closure returns, or read when there is nothing to return:
let edges = a.with(h, (v: &Node): i64 => v.edges.len) ?? 0
a.read(h, (v: &Node) => { total = total + v.edges.len })
Absent on an arena whose T carries Drop or @noCopy: the copy would be a second owner of the resource. read and modifyMut remain.
Arena.handles
fn Arena.handles(self: &Arena): Vec<Handle<T>>A snapshot Vec of every currently live handle — the enumeration shape a collector sweeps. Frees after this call invalidate entries in the Vec; allocs after it do not appear in it.
Arena.id
fn Arena.id(self: &Arena): i64The identity stamped into every handle this arena mints (h.arenaId). For a caller that packs a handle into fewer bits and rebuilds it against this arena; reading it grants nothing, since every accessor still checks the handle.
Arena.len
fn Arena.len(self: &Arena): i64Live entries, not slots: freed slots awaiting reuse are excluded, so this can be far below the arena's actual footprint.
Arena.modify
fn Arena.modify(self: &mut Arena, h: Handle<T>, f: (T) => T): boolUpdate the slot by mapping its value through f. False (and f not called) if h is stale. Moves T out and back, so prefer modifyMut when T is large or you touch only a field or two. Absent when T carries Drop or @noCopy, for the same reason as get. The closure's parameter is immutable: rebind it as a var, change that, and return it.
Arena.modifyMut
fn Arena.modifyMut(self: &mut Arena, h: Handle<T>, f: (&mut T) => void): boolMutate the live value in place through &mut T. No copy in, no copy out. False (and f not called) if h is stale. This is the write counterpart to arenaWith and the right default for large T.
Arena.new
fn Arena.new(): Arena<T>A new empty arena with a fresh identity. Spell the type argument (Arena<Node>.new()); a bare Arena.new() cannot infer T.
Arena.read
fn Arena.read(self: &Arena, h: Handle<T>, f: (&T) => void): boolRead the value at h by BORROW — no copy. Prefer this to get for any T that owns heap (a string, a Vec, a nested struct): get clones the whole value out of the slot on every call. False (and f not called) if h is stale. Take the result out through a captured var.
Arena.sealGrowth
fn Arena.sealGrowth(self: Arena): Result<GrowOnlyArena<T>, FreezeRejected<T>>End freeing, but keep allocating. The middle tier: get becomes infallible while alloc still works. See GrowOnlyArena.
Arena.set
fn Arena.set(self: &mut Arena, h: Handle<T>, val: T): boolReplace the value at h. False (and no write) if h is stale.
Arena.valid
fn Arena.valid(self: &Arena, h: Handle<T>): boolWhether h still names a live value in THIS arena — identity and generation both checked. A handle from another arena reads as invalid.
Arena.with
fn Arena.with<R>(self: &Arena, h: Handle<T>, f: (&T) => R): Option<R>Read the value at h by BORROW and return what f computes from it. None (and f not called) if h is stale. The same zero-copy read as read, for the common case where the read produces a value.
let edges = a.with(h, (v: &Node): i64 => v.edges.len) ?? 0
FreezeRejected
pub struct FreezeRejected<T>Why a refused freeze does not eat the arena: freeze consumes its input, so a plain Result<FrozenArena<T>, string> would drop the caller's whole arena on the failure path with no way to get it back. The rejection carries the arena home instead.
Fields:
arena: Arena<T>: The arena, untouched. Take it back and keep using it.slot: i64: The first slot found to have been freed.
FrozenArena
pub struct FrozenArena<T>A read-only Arena<T>: no alloc, no free, no clear, ever. You get one by consuming an arena with freeze():
var a = Arena<Node>.new()
let root = a.alloc(Node { ... }) // build phase: alloc freely
let tree = a.freeze()! // `a` is moved; build phase over
let n = tree.get(root) // Node, not Option<Node>
Staleness is not representable here, so get returns T directly instead of Option<T> and there is no failure path to plumb through every call site. That is not a promise the type makes on its honour: the conversion CONSUMES the arena, so the move checker rejects any later use of the source binding, and no path survives through which a slot could be freed after the freeze.
The generation counters are not carried over because nothing can bump them.
FrozenArena.get
fn FrozenArena.get(self: &FrozenArena, h: Handle<T>): TThe value at h, with no Option to unwrap. Aborts only on a handle from another arena or an out-of-range index, neither of which is staleness. Absent when T carries Drop or @noCopy; read is the borrowing form.
FrozenArena.holds
fn FrozenArena.holds(self: &FrozenArena, h: Handle<T>): boolWhether h names a slot here. Branch on this instead of get when a foreign handle is a case you expect rather than a bug.
FrozenArena.len
fn FrozenArena.len(self: &FrozenArena): i64Slots held; every one is live.
FrozenArena.read
fn FrozenArena.read(self: &FrozenArena, h: Handle<T>, f: (&T) => void): voidBorrow the value at h rather than copying it. Prefer this when T owns heap storage.
GrowOnlyArena
pub struct GrowOnlyArena<T>An arena that can still ALLOCATE but can no longer free, clear or reuse a slot.
The middle tier, and the one most builders actually want. freeze gives an infallible get by ending the build phase; that is no use to a symbol table or an AST that is still growing. Removing only free is enough: a slot is never recycled, so no handle this arena ever minted can come to name a different value, and get returns T with no Option to unwrap — while alloc keeps working.
Growth does not disturb it. Appending moves the backing storage but not the slot INDICES, and a handle names an index, so every handle minted before a growth still resolves to the same value after it.
freeze() is the terminal state when the building really is done.
GrowOnlyArena.alloc
fn GrowOnlyArena.alloc(self: &mut GrowOnlyArena, val: T): Handle<T>Append a value and return a handle to it. The only mutating operation this tier has: nothing here frees, clears or recycles.
GrowOnlyArena.freeze
fn GrowOnlyArena.freeze(self: GrowOnlyArena): FrozenArena<T>Building is over: give up alloc too. Cannot fail, because this tier never freed anything.
GrowOnlyArena.get
fn GrowOnlyArena.get(self: &GrowOnlyArena, h: Handle<T>): TThe value at h, with no Option to unwrap. Infallible for any handle this arena or the one it was sealed from minted: no slot is ever recycled, so a handle cannot come to name something else. A copy, so absent for a T that carries Drop or @noCopy (see the module comment); read is the borrowing form.
GrowOnlyArena.holds
fn GrowOnlyArena.holds(self: &GrowOnlyArena, h: Handle<T>): boolWhether h names a slot here. Branch on this for a handle that might be foreign.
GrowOnlyArena.len
fn GrowOnlyArena.len(self: &GrowOnlyArena): i64Slots held; every one is live.
GrowOnlyArena.modifyMut
fn GrowOnlyArena.modifyMut(self: &mut GrowOnlyArena, h: Handle<T>, f: (&mut T) => void): voidMutate the value at h in place, without copying it out and back.
GrowOnlyArena.read
fn GrowOnlyArena.read(self: &GrowOnlyArena, h: Handle<T>, f: (&T) => void): voidBorrow rather than copy. Prefer this when T owns heap storage.
GrowOnlyArena.set
fn GrowOnlyArena.set(self: &mut GrowOnlyArena, h: Handle<T>, val: T): voidOverwrite the value at h.
Mutating a slot's CONTENTS is not what makes a handle stale — only recycling a slot is, and this tier cannot recycle. So a graph whose nodes keep changing is exactly the shape that belongs here, and locking it out would have left the tier usable only by data that never moves.
Handle
pub struct Handle<T>A copyable token naming one slot in an Arena<T> — the arena's stand-in for a pointer. You do not use it directly: get one from arenaAlloc (a.alloc), then pass it to a.get / a.set / a.modify / a.free to read, overwrite, mutate, or release that slot. Safe to copy, store in fields, and return (unlike &T). T is phantom (not stored) so a handle can't be used with an arena of another type.
Fields: arenaId: i64, index: i32, generation: i32.
Functions
arenaAlloc
pub fn arenaAlloc<T>(a: &mut Arena<T>, val: T): Handle<T>Insert a value and return a handle to it.
arenaClear
pub fn arenaClear<T>(a: &mut Arena<T>)Drop every value and release the backing storage in one go. Per-slot free() recycles a slot but never shrinks the arena, so a long-lived arena sits at its peak footprint forever; clear is the bulk-reset escape from that.
Every outstanding handle goes stale because the arena takes a fresh identity — a pre-clear handle fails the arenaId check outright. Restarting generations at 1 on a recycled Vec would otherwise let an old handle alias a new value.
arenaFree
pub fn arenaFree<T>(a: &mut Arena<T>, h: Handle<T>): boolFree a slot, bumping its generation so stale handles are detected. A slot at maximum generation is retired rather than wrapped back onto an old handle.
arenaFreeze
pub fn arenaFreeze<T>(a: Arena<T>): Result<FrozenArena<T>, FreezeRejected<T>>Consume an arena and hand back a read-only view of the same storage. O(n) in slots to check the precondition; no element is copied.
Refused when the source arena ever freed a slot, and the refusal returns the arena. That refusal is the whole reason get can skip the generation check: a slot that was freed and then reallocated leaves stale handles naming a LIVE slot that now holds a DIFFERENT value, and an unchecked get would hand that value back as though it were the right one. Silently returning the wrong value is worse than any rejection, so the freeze is refused instead and the caller keeps the generation-checked arena.
A slot's generation is 1 from birth and only free ever moves it (to a negative value, or to 0 when the slot retires), so gens[i] != 1 is exactly the test for "this slot was freed at some point".
arenaGet
pub fn arenaGet<T>(a: &Arena<T>, h: Handle<T>): Option<T>Get a copy of the value at a handle. Returns None if the handle is stale. Returns by value, not &T, because second-class refs cannot be stored in Option<_>. For large T, prefer arenaModify to avoid the copy churn. A T that carries Drop or @noCopy has no sound copy: this is rejected for it, use arenaRead.
arenaHandles
pub fn arenaHandles<T>(arena: &Arena<T>): Vec<Handle<T>>Snapshot every currently live handle. The returned handles remain ordinary generational capabilities: later frees invalidate them, while later allocs do not appear in this already-produced Vec. This is the safe enumeration shape for collectors because no element reference survives into caller code.
arenaLen
pub fn arenaLen<T>(a: &Arena<T>): i64Number of live entries.
arenaModify
pub fn arenaModify<T>(a: &mut Arena<T>, h: Handle<T>, f: (T) => T): boolIn-place update via closure. Avoids the manual get/modify/set dance and is the recommended way to mutate a single field of an arena value. Returns false if the handle is stale (closure not invoked). The closure takes the value by copy, so a T carrying Drop or @noCopy is rejected: use arenaModifyMut.
arenaModifyMut
pub fn arenaModifyMut<T>(a: &mut Arena<T>, h: Handle<T>, f: (&mut T) => void): boolIn-place mutate via &mut borrow — no copy in, no copy out, no full-struct rewrite. Mutate fields of the live value directly inside f. Returns false (f not called) if the handle is stale. Preferred over arenaModify when T is large or you only touch a field or two.
arenaNew
pub fn arenaNew<T>(): Arena<T>Create a new empty arena.
arenaRead
pub fn arenaRead<T>(a: &Arena<T>, h: Handle<T>, f: (&T) => void): boolRead via borrow with a VOID callback — the same zero-copy read as arenaWith (a.with(h, f)), for a read that has nothing to return, or one whose result is easier to take out through a captured var:
var name = ""
a.read(h, (v: &Node) => { name = v.name.clone() })
Why this matters more than it looks: a.get(h) COPIES the whole T out of the slot, which for any T with an owning field is a heap allocation per read — measured at 20ms per 200k reads of a node with one Vec field, against ~0 for the borrowing paths. That gap is why real programs reach for a bare index into a Vec instead of a Handle, so the ergonomic read path is the one that decides whether generational safety gets used at all.
arenaSealGrowth
pub fn arenaSealGrowth<T>(a: Arena<T>): Result<GrowOnlyArena<T>, FreezeRejected<T>>Consume an arena into the grow-only tier. Refused, like freeze, for an arena that ever freed a slot: a freed-and-reused slot leaves stale handles naming a live slot holding a different value, and this tier removes the generation check that would catch them. The refusal hands the arena back.
arenaSet
pub fn arenaSet<T>(a: &mut Arena<T>, h: Handle<T>, val: T): boolOverwrite the value at a handle. Returns false if the handle is stale.
arenaValid
pub fn arenaValid<T>(a: &Arena<T>, h: Handle<T>): boolCheck whether a handle is still valid.
arenaWith
pub fn arenaWith<T, R>(a: &Arena<T>, h: Handle<T>, f: (&T) => R): Option<R>Read via borrow — no copy. The &T flows into f as a second-class ref: valid only inside the closure, never stored or returned. Returns None (and does not call f) if the handle is stale. This is the zero-copy alternative to arenaGet for large T — read just the field(s) you need inside f.
frozenGet
pub fn frozenGet<T>(a: &FrozenArena<T>, h: Handle<T>): TThe value at h. Infallible for any handle this arena's source minted.
The two checks that remain are not liveness checks, they are confusion checks: a handle from ANOTHER arena, or an index outside this arena's storage, has nothing to do with staleness and must not be allowed to read unrelated memory. Both abort with a named message rather than return a plausible-looking value.
A copy, so rejected for a T that carries Drop or @noCopy; frozenRead borrows.
frozenHolds
pub fn frozenHolds<T>(a: &FrozenArena<T>, h: Handle<T>): boolWhether h names a slot in THIS frozen arena. False for a handle minted by a different arena. Use it when a foreign handle is a possibility you want to branch on rather than abort over.
frozenLen
pub fn frozenLen<T>(a: &FrozenArena<T>): i64Slots held. Every one is live, so unlike Arena.len this is also the storage count.
frozenRead
pub fn frozenRead<T>(a: &FrozenArena<T>, h: Handle<T>, f: (&T) => void): voidBorrow the value at h instead of copying it. Prefer this to get when T owns heap storage, since get hands back a copy.