Memory and ownership
Beans manages memory with automatic reference counting (ARC) plus a cycle collector. This page explains how references and cycles are released, and the tools you use to control their lifetimes.
Reference counting
Section titled “Reference counting”Every heap value carries a small header (an atomic count plus shape
information). The compiler emits retains and releases at ownership boundaries,
and a generic destructor walks nested structures when a value’s count reaches
zero. Destruction is deterministic: deinit runs the moment
the last reference drops, on the thread that dropped it.
The design keeps reference counting off hot paths: function arguments, loop variables, and reads borrow instead of retaining. You pay for a retain only when you actually keep a value. Because a plain reference is borrowed while it is passed and read, most code never writes anything about ownership at all.
Cycles
Section titled “Cycles”Reference counting alone cannot free a cycle (a.next = some(b); b.next = some(a)). Beans catches cycles with a cycle collector (trial deletion, the
Bacon-Rajan / Nim ORC family): a decrement that does not hit zero parks the
object as a possible cycle root; when enough roots pile up, the collector
trial-deletes each root’s subgraph, restores anything still referenced from
outside, and frees the rest.
- Each worker collects its own unshared candidate graphs at allocation boundaries. It does not stop or poll another worker.
- Graphs published across threads or synchronization owners use the global fallback, which waits for the workers to drain. A write barrier preserves that shared mark for values published later.
- All walks are iterative, so even a very large dropped structure will not overflow the stack.
- Unreachable cycles are collected and their
deinitmethods run during collection. Collection timing is not scope-exit cleanup. Use aweakback edge when resource release must follow the last strong reference;Shared<T>cycles useWeak<T>instead.
weak fields
Section titled “weak fields”A class field declared weak is a zeroing reference: it holds no
ownership count on its referent, so it never forms a cycle edge. Its type must
be Option<C> for a non-unique class C, and its default must be none.
package main
import std.io
class Node { name: string = "" child: Option<Node> = none // owning: parent keeps child alive weak parent: Option<Node> = none // non-owning: zeroes when parent dies
fn deinit() { io.println("gone {self.name}") }}
fn main() { let parent: Node = new Node() parent.name = "parent" let child: Node = new Node() child.name = "child" child.parent = some(parent) parent.child = some(child) match child.parent { some(found) => { io.println("up: {found.name}") } none => { io.println("up: gone") } }}// up: parent, then both deinits run — the back edge is weak, so// there is no cycle to leakReads produce the declared Option<C>: some while the referent is alive —
the loaded value is retained for the read, so it cannot die mid-use — and
none from the first moment of the referent’s death, before its deinit
body runs, so a destructor can never resurrect itself through a weak slot.
When the collector kills a strong cycle, weak fields pointing into that cycle
read none from the kill onward.
Build the back edge of every parent/child graph and every stored callback
that points at its owner as weak, and both sides get their deinit. The
slot’s storage is a zeroing handle rather than the object, so weak fields are
invisible to reflection. weak is for instance fields of classes only — no
statics, no structs, no locals.
move transfers ownership of a value out of a binding instead of copying it.
return move local hands back the last reference rather than retaining. The
checker enforces use-after-move at compile time, so a moved binding cannot be
read again:
fn main() { var a: List<int> = [1, 2, 3] let b: List<int> = move a a.push(4) // error: use of moved value 'a'}A var can be moved out and then reassigned a fresh value before its next read.
Parameters, loop variables, and match bindings are borrowed, so they cannot be
moved. Closure captures borrow by default, but fn() move(a, b) { ... }
explicitly transfers named locals into the closure. See Variables and
constants for the full move, move-capture, move parameter,
and inout rules.
Move-only handles
Section titled “Move-only handles”List, Map, OrderedMap, Box<T>, and Arena<T> are move-only outer
handles, and so is any unique class. Binding, assigning, storing, or
returning them uses move; parameters and loop reads borrow. clone() makes an
independent copy (it needs Clone on the stored types).
var value: Box<int> = new Box(7)value.set(9)let owned: Box<int> = move value // move the handle
var arena: Arena<string> = new Arena(1024)let handle: int = arena.add("bean")let word: string = arena.at(handle) // checked; panics on a bad handlearena.clear() // drops all values in one passBox<T>owns one heap slot:get()returns the value,set(value)replaces it. Reach for it when a value has one clear owner and you just need it on the heap.Arena<T>is an append-only slab:add(value)returns a stable integer handle;at,get,len, andclearwork on the region.clearkeeps capacity but invalidates every old handle.
Passing a move-only handle to a function that only reads it needs no move,
because parameters borrow:
fn total(xs: List<int>) -> int { var sum: int = 0 for x: int in xs { sum += x } return sum}Shared ownership across threads
Section titled “Shared ownership across threads”Shared<T> is the explicit thread-safe shared-ownership handle. Weak<T>
observes the same value without keeping it alive:
let shared: Shared<string> = new Shared("beans")let weak: Weak<string> = shared.downgrade()let live: Option<Shared<string>> = weak.upgrade()let gone: bool = weak.is_expired()get() returns a copy of the value. Copying a Shared handle adds another
strong owner, and the value lives until the last strong handle dies. upgrade
uses an atomic compare/exchange, so it can never revive a dead value.
The cycle collector does not trace through Shared control blocks, so a cycle
built from Shared values never drops on its own. Break it by making one side a
Weak. Use Weak the same way for a back-pointer (child to parent, observer to
subject) that must not keep its target alive.
Shared<T> and Weak<T> are Send and Sync only when T is both. Reach for
Shared when a value has no single obvious owner or must be handed to another
thread; use Box<T> when one owner is obvious, since it needs no atomic count.
Send and Sync
Section titled “Send and Sync”Plain class references are not Send by default. Containers derive it from
what they own: List<T>, Box<T>, and Arena<T> are Send when T is;
Map<K, V> and OrderedMap<K, V> require both types. Bytes, File, and
MMap are move-only Send owners, but not Sync, so ownership can cross a
thread boundary without creating a mutable alias. Scalars, immutable strings,
Error, matching Option/Result values, network owners, AtomicInt, a
Mutex or Channel of Send values, and Shared/Weak of Send + Sync
types can also cross.
send fn(...) -> T is a move-only function value for a thread handoff. Every
capture must be Send; mutable, move-only, or non-Sync captures must be named
in move(...).
thread.spawn rejects a closure that captures or returns a non-Send value.
Use a Mutex to share mutable data between threads. See
Concurrency.
A complete program
Section titled “A complete program”Shared keeps a value alive while several places hold it; Weak watches it
without pinning it:
import std.io
class Cache { label: string fn init(label: string) { self.label = label } fn deinit() { io.println("drop {self.label}") }}
fn main() { let strong: Shared<Cache> = new Shared(new Cache("db")) let observer: Weak<Cache> = strong.downgrade()
match observer.upgrade() { some(live) => io.println("cache {live.get().label} still here"), none => io.println("gone"), }
io.println("expired {observer.is_expired()}")}The ownership handles reference lists every method on these types.