Mako has no mandatory garbage collector. Memory safety is achieved through three complementary mechanisms that work at compile time:
hold bindings -- enforce move semantics with compile-time trackingshare bindings -- reference-counted shared readsThis chapter covers each in detail.
Most values in Mako live on the stack or are heap-allocated and freed when their enclosing scope exits. This is the default -- no annotation needed.
fn main() {
let x = 42 // lives for the duration of main
let s = "hello" // same
if true {
let inner = 99 // lives only within this block
print_int(inner)
}
// `inner` is gone here
print_int(x)
}
For explicit cleanup ordering, use defer:
fn process() {
defer print("cleanup done")
print("processing")
// "cleanup done" prints after "processing", before function returns
}
defer statements execute in LIFO order when the function exits:
fn main() {
defer print("third")
defer print("second")
defer print("first")
print("body")
}
// Output: body, first, second, third
Certain types are Copy -- they are duplicated rather than moved when assigned or passed. These types do not need ownership annotations:
int, int64, int32, int8, uint64bytefloat, float64boolfn main() {
let a = 42
let b = a // copies the value; both a and b are usable
print_int(a) // fine
print_int(b) // fine
let f = 3.14
let g = f // copies
print_int(int(f))
print_int(int(g))
}
Copy types work the same way even under hold -- you can read them multiple
times because the "move" is really a copy:
fn main() {
hold let x = 7
print_int(x) // first read
print_int(x) // second read -- OK because int is Copy
}
hold -- move semanticshold bindings enforce unique ownership. When a hold value is rebound, passed
to a function, or fully consumed, the original binding becomes dead. Using it
after that point is a compile error.
fn main() {
hold let x = "hello"
hold let y = x // x is moved into y
print(y) // OK
// print(x) // compile error: use of moved value `x`
}
Passing a hold value to a function is a consuming use:
fn consume(s: string) {
print(s)
}
fn main() {
hold let msg = "important"
consume(msg) // msg is moved into the function
// consume(msg) // compile error: use of moved value `msg`
}
If you only use a hold binding once, there is no issue:
fn id(n: int) -> int {
return n
}
fn main() {
hold let x = 42
print_int(id(x)) // single consuming use -- OK
}
You can reassign a hold let mut binding before it is moved:
fn main() {
hold let mut x = 7
x = 9 // reassignment before any move
print_int(x) // OK
}
When a struct is under hold, you can move individual fields independently.
Only the moved field becomes dead:
struct Point {
x: int,
y: int,
}
fn main() {
hold let p = Point { x: 1, y: 2 }
let px = p.x // moves only p.x
print_int(px) // OK
print_int(p.y) // OK -- p.y was never moved
// print_int(p.x) // compile error: p.x was moved
}
The compiler tracks moves through all branches of if/else and match. A
value is considered moved only if it is moved on all reachable paths:
fn main() {
hold let x = "hi"
if 0 == 1 {
let y = x // moves x in this branch
print(y)
} else {
print(x) // uses x in this branch
}
// After the if/else, x MAY be moved (branch-dependent)
// The compiler tracks this correctly
}
If one branch moves a value and another does not, the compiler understands that after the if/else the value's status depends on which branch executed. It will reject uses after the if/else because the move status is uncertain.
The compiler is aware of loop re-entry. If a value is moved inside a loop body, it cannot be used on subsequent iterations:
fn main() {
hold let x = "data"
let mut i = 0
while i < 1 {
print(x) // OK on first iteration
i = i + 1
}
// x may be moved depending on loop execution
}
return, break, and continue are recognized as diverging -- a branch that
returns does not affect the move status in the remaining code:
fn process() -> int {
hold let x = "value"
if true {
return 0 // diverges -- does not "use up" x for the else path
}
print(x) // OK -- the if-branch returned, so we know x is live
return 1
}
The checker prunes branches with constant-false conditions:
fn main() {
hold let x = "hi"
if false {
let y = x // dead code -- compiler ignores this move
}
print(x) // OK
}
Closures that do not capture a hold binding leave it usable:
fn main() {
hold let x = "hi"
let ys = fan([1, 2], |n| n + 1) // closure does not capture x
print(x) // OK
print_int(ys[0])
}
share -- reference-counted shared readsshare provides immutable shared access to a value through reference counting.
Multiple share bindings can read the same value simultaneously.
fn main() {
share let a = share_int(7) // wrap an int in a share
share let b = share_clone(a) // bump reference count
print_int(share_get(a)) // 7
print_int(share_get(b)) // 7
share_drop(a) // decrement refcount
print_int(share_get(b)) // still 7 -- b holds a reference
share_drop(b) // refcount hits zero, memory freed
}
share let bindings are always immutable -- there is no share let mut.share_drop.The compiler uses non-lexical lifetime analysis to end shares early. Once the last use of a share is past, the source value can be mutated again:
fn main() {
let mut x = 1
share let s = share_int(x)
print_int(share_get(s)) // last use of s
// share ends here (NLL) even though s is still in scope
x = 2 // OK -- share is no longer live
print_int(x)
}
fn main() {
share let a = share_int(42)
if true {
print_int(share_get(a))
}
// share lives through the if block
share_drop(a)
}
| Situation | Use |
|---|---|
| Unique ownership, value consumed once | hold |
| Pass data to exactly one consumer | hold (move into call) |
| Multiple readers need the same value | share |
| Temporary shared read of a scalar | share_int / share_clone |
| You just need a local variable | plain let (no annotation needed) |
General advice: Start without ownership annotations. Use plain let and
let mut for everything. Add hold when you want the compiler to enforce that
a value is used exactly once (or transferred to exactly one owner). Add share
only when you genuinely need multiple readers of the same heap-allocated value.
Arenas provide region-based memory management. You allocate many objects within an arena block, and they are all freed at once when the arena exits. This is ideal for request-scoped work where you do not need individual deallocation.
fn main() {
arena a {
let msg = arena_text(a, "hello arena")
print(msg)
let xs = arena_ints(a, 4) // allocate 4 ints from arena
print_int(len(xs))
print_int(xs[0]) // zero-initialized
print_int(arena_stamp(a, 99))
}
// everything allocated from `a` is freed here -- one deallocation
print("arena done")
}
Use make inside an arena block to allocate slices from the arena instead of
the heap:
fn main() {
arena a {
let mut s = make([]int, 3, 8) // len=3, cap=8, from arena
s[0] = 10
s[1] = 20
print_int(len(s))
print_int(s[0])
let mut b = make([]byte, 2)
b[0] = byte(65)
print_int(int(b[0]))
}
print("freed")
}
struct Point {
x: int,
y: int,
}
fn main() {
arena a {
let mut xs = make([]Point, 2, 4)
xs[0] = Point { x: 1, y: 2 }
xs[1] = Point { x: 3, y: 4 }
print_int(len(xs))
print_int(xs[0].x)
print_int(xs[1].y)
}
}
Arenas shine when:
free calls and reduce allocator overheadArenas do not work well when:
You can combine arenas with hold for precise tracking:
fn main() {
arena a {
hold let mut buf = make([]int, 0, 64)
buf = append(buf, 1)
buf = append(buf, 2)
process_buffer(buf)
// buf is moved -- cannot use it again
}
}
fn process_buffer(data: []int) {
for _, v in range data {
print_int(v)
}
}
Use hold to ensure a builder is consumed exactly once:
struct Config {
host: string,
port: int,
}
fn build_config(host: string, port: int) -> Config {
hold let cfg = Config { host: host, port: port }
return cfg // ownership transferred to caller
}
fn main() {
let cfg = build_config("localhost", 8080)
print(cfg.host)
print_int(cfg.port)
}
fn main() {
let ch = make(chan[string], 1)
hold let msg = "important data"
send(ch, msg)
// msg is moved into the channel -- cannot use it here
let received = recv(ch)
print(received)
}
fn handle_request(body: string) {
arena req {
let mut parts = make([]string, 0, 16)
// parse body into parts...
parts = append(parts, body)
print_int(len(parts))
}
// all request memory freed, regardless of how many allocations happened
}
fn main() {
handle_request("hello")
handle_request("world")
}
fn process_file(path: string) -> Result[int, string] {
let fd = open_file(path)?
defer close_file(fd)
// work with fd...
// close_file runs automatically when function exits (success or error)
Ok(1)
}
| Mechanism | When to use | Cost |
|---|---|---|
Plain let |
Default for all locals | Zero overhead |
defer |
Cleanup on scope exit | Minimal (LIFO call) |
hold |
Enforce single-owner / move | Zero runtime cost (compile-time only) |
share |
Multiple readers of same data | Reference count increment/decrement |
arena |
Bulk temporary allocations | Bump allocator + single free |
The ownership system is designed so that the simple case requires no annotation.
Add hold and share incrementally where the compiler's help prevents bugs.
Use arenas when you know a group of allocations share a lifetime.
Next: Errors & Result.