Docs
Memory model
Not garbage collected, not a borrow checker: how ADM manages memory for you, what is a value and what is a reference, when objects die, and the one case you design around.
Overview
Short version: ADM manages memory for you, automatically and deterministically. It is not garbage collected, it has no borrow checker, and you never call free. Every object is reference counted by the compiler: it lives exactly as long as something refers to it, and it is destroyed the moment the last reference goes away. If it declares dispose(), that runs right then.
If you know Swift or Objective-C with ARC, you already know the model. If you come from Go, Java or C#, the difference is that there is no collector thread and no pause: objects die at a predictable point. If you come from Rust, the difference is that the compiler does not ask you to prove lifetimes; references may alias freely, and the runtime keeps a count.
Values and references
ADM has two kinds of data: values and references.
Values are copied. Assigning, passing or returning one gives the other side its own copy. Nothing is shared and nothing has to be freed.
- numbers:
int,uint,float,byte, the sized variants,complex - SIMD vectors and masks:
vec4<T>throughvec128<T>,mask4<T>and up; a vector is one machine value, copied whole bool,char,duration- enum members
- tuples such as
(int, string) - optional
?Tand errorable!Twrappers, which are values around whatever they wrap - union values such as
int | string | Point: the union itself is a value; when the member it holds is a reference type, copying the union copies that reference
References are shared. Assigning, passing or returning one hands over another reference to the same object. Changing the object through any reference is visible through all of them.
- instances of
typeandstruct - arrays
T[]and mapsmap<K, V> - strings and
bigint(both immutable: every operation yields a new value, so sharing is never observable) regex(a compiled pattern; immutable)errorvalues- channels, futures, closures
- interface values and
any - services, components and views
The immutable ones on the reference list, string, bigint and regex, behave like values from the outside: you can pass them around freely and nothing you do through one name is visible through another. They are on this list only so you know they live on the heap and are counted, which is why building a big string or bigint in a loop does not copy it every time.
let a = new Point(1, 2)
let b = a // b refers to the same Point
b.x = 10
println(a.x) // 10
let xs = int[1, 2, 3]
let ys = xs // same array
ys.push(4)
println(xs.len()) // 4
There is no implicit copy of a reference type. When you want an independent copy, ask for one: xs.clone(), m.clone(), or implement Cloneable on your own type and call clone().
Lifetime
An object stays alive while at least one reference to it exists: a local variable, a field, an element of an array or map, a captured variable in a closure, a value inside an any, a pending message in a channel. When the last of those disappears, the object is destroyed immediately, on the thread that dropped the reference.
References disappear when:
- a local goes out of scope, or its function returns;
- a variable or field is reassigned, releasing what it held before;
- an element is removed from a container, or the container itself dies;
- you write
dispose x(see below).
Because destruction happens at a known point, ADM code can hold real resources in ordinary objects: files, sockets, GPU buffers, window handles. Freeing an object does not free what it wraps on its own; the operating system does not care that a number went out of scope. The type does it, in dispose(): std.os.File closes its descriptor there, so a file closes when its last reference is dropped, not "eventually".
def copyHeader(path string) !byte[] {
let f = try os.open(path) // f owns the descriptor
let buf byte[]
buf.len(64)
try f.read(buf)
return buf
} // f is released here; File.dispose() closes the descriptor
When the moment matters, close it yourself: try f.close() (safe to call more than once) or dispose f. A resource type you write gets the same guarantee by closing its handle in dispose().
Passing an object to a function does not transfer ownership; the callee borrows it for the duration of the call and may keep it alive longer by storing it somewhere. Returning an object hands the caller its own reference. You do not have to think about which side "owns" a value; the counts do that for you.
dispose()
A type may declare def dispose(). It runs exactly once, when the last reference to the instance is released, before the memory is reclaimed. Use it to release things the runtime does not know about: native handles, temporary files, GPU memory.
type Texture {
internal id uint32
def new(w int, h int) {
id = gl.createTexture(w, h)
}
def dispose() {
gl.deleteTexture(id)
}
}
The runtime calls dispose() when the object is really gone, so a shared texture is freed once, after its last user lets go.
You can also call it yourself, like any method: tex.dispose() runs the body right away and the object stays alive, references and all. The runtime still calls dispose() again when the object finally dies, so a body that must not run twice should note that it already ran:
def dispose() {
return when id == 0
gl.deleteTexture(id)
id = 0
}
When what you want is "I am done with this object", prefer the dispose statement below: it drops your reference, and the body runs exactly once, when nobody else holds one.
Services have their own lifecycle: the service manager starts them before your application runs and disposes them at shutdown.
The dispose statement
dispose x releases the reference held by x right now and leaves x empty. It is a way to end a lifetime early, for example to close a large buffer before a long computation, or to break a reference cycle. It does not force the object to die: if something else still refers to it, the object lives on until that reference is gone too.
Reference cycles
Reference counting has one blind spot: a cycle. If a refers to b and b refers back to a, neither count ever reaches zero, the objects are never freed and their dispose() never runs. This is the one memory situation in ADM that you have to design around.
Cycles arise from back pointers: a child that refers to its parent, a node that refers to its owner, an observer that refers to the object it observes, a closure stored in an object that captures that object.
How to handle them:
- Do not store the back reference. Pass the parent as a parameter when it is needed instead of keeping it in a field.
- Break the cycle explicitly. Clear the field that closes the loop when the structure is torn down, with
dispose node.parentor by assigningnone. - Keep one direction strong. A tree owns its children; children do not own the tree. Use an index, an id or a lookup through the owner instead of a pointer back.
Nothing in the language detects a cycle for you. When the back reference has to be stored, make it weak.
Weak references
A field marked weak refers to an object without owning it. The count is not touched when the field is set, so the field never keeps its target alive; and when the target dies the runtime empties the field, so it reads as none from then on. That is why a weak field must be optional (?T) and must point at a struct, type, service or component: those are the reference objects the runtime can watch.
type Node {
name string
children Node[]
weak parent ?Node
def add(child Node) {
child.parent = self
children.push(child)
}
}
The tree owns its children through children; each child points back through parent without owning it. Drop the root and the whole tree is freed, dispose() and all, because nothing in it holds a count on anything above it.
Reading a weak field gives you an ordinary optional. While you hold the value you read, the object stays alive: the read takes a reference of its own, released like any other local. Check for none before use, as with any optional; a child whose parent has already gone simply sees none.
let p = child.parent
if p is none {
return
}
println(p.name) // the parent lives at least as long as p
The standard library uses this where a structure needs a back pointer: DoublyLinkedList and LRUCache own their nodes through next and keep prev weak.
Assigning none or dispose child.parent clears the field without touching the target. weak cannot be combined with atomic, and it applies to fields only: not to datatype fields (values are copied, not shared), properties or locals.
A closure stored in an object that captures that object is still a cycle; capture a weak field's value instead, or clear the closure when the object is torn down.
Threads and tasks
Reference counts are atomic. An object can be handed from one task to another, stored in a shared map, sent over a channel or captured by a forall body, and its lifetime stays correct. What the counts do not do is synchronise the object's contents: two tasks mutating the same array still race. Use atomic fields, channels, or the service model for shared mutable state.
Every task (an async call, a forall iteration, a service handler) runs on its own stack. A task stack is a reservation of address space, 32 MB by default, of which only the pages actually touched cost memory; a guard page turns a stack overflow into a clean error instead of corruption. ADM_TASK_STACK changes the reservation for programs that need deeper recursion or many more tasks.
What it costs
Creating an object is an allocation with a small header; releasing it is a decrement, and freeing it when the count hits zero. The compiler removes counting where it can prove nothing observes it: a value that never leaves a function, a reference handed straight from a constructor into a field, a temporary consumed by the call that built it. Short-lived objects that never escape their scope may be placed on the stack or in a per-scope arena rather than the heap.
Arrays are contiguous. Appending grows the backing store geometrically, so pushing in a loop is amortised constant time. A slice xs[a:b] shares the backing store of xs rather than copying it; the two are detached on the first operation that would make them diverge in size.
Strings are immutable and reference counted; literals cost nothing to copy around. Concatenation in a loop reuses the left operand's buffer when nothing else refers to it, so building a string by repeated += is linear.
Numbers, vectors, booleans, enums, tuples and optionals of values never touch the heap. bigint, like string, does: each result is a new counted object, and the old one is freed when nothing refers to it.
Looking at memory
Runtime.sizeOf(value)reports the deep size of a value graph at runtime, counting shared objects once.- Run any compiled program with
ADM_MEM_STATS=1to print live bytes and allocation and free counts every 200k allocations. A leak shows as live bytes that grow while the program is steady. ADM_MEM_TRACE=<file>records every allocation with a backtrace and every free;tools/memtrace.py <file> <binary>lists what was never freed, by call site. It slows the program down, so use it on a reproduction rather than in production.
What ADM is not
- Not garbage collected. There is no collector, no heap scanning, no pauses, and no finaliser that "may run later". Memory is reclaimed as soon as it is unreachable, except for cycles.
- Not manually managed. There is no
malloc,free,deleteor ownership annotation. The compiler inserts every retain and release. - Not a borrow checker. References alias freely; the compiler never rejects a program for sharing an object or holding a reference too long.
- Not unsafe. Ordinary ADM code cannot form a dangling reference, read freed memory or overflow a buffer; indexing is bounds checked and optionals must be tested before use. The only way to touch raw memory is a foreign call declared with
@c, and there the foreign side's rules apply.
Summary
| Question | Answer |
|---|---|
| Who frees memory? | The compiler-inserted reference counting, automatically |
| When? | The instant the last reference is dropped; dispose() runs then |
| Copy or share? | Numbers, tuples, optionals copy; types, arrays, maps, strings, closures share |
| Cycles? | Not collected; avoid back pointers or break them with dispose |
| Threads? | Counts are atomic; contents are yours to synchronise |
| Pauses? | None |
| Manual control? | dispose x to release early, x.dispose() to run the cleanup now, clone() to copy explicitly |