Makori has no garbage collector. Active memory/resource safety mechanisms include
ownership rules (hold/share) and region-based allocation (arena). This
guide explains when to use each strategy; generated C and FFI remain outside
the Mako type system.
Deeper contracts: ownership categories and SAFE/RT drop/escape rules are
in SOUNDNESS.md and MEMORY_MODEL.md.
As of 0.3.0, owning slices, maps, strings, and struct Own fields free at
scope exit, reassign, break/continue, return transfer, ? early-return, and
match Own payloads. Free is once per allocation: live owns move into
a new freer; aliases and field/index borrows clone. Alias muts that start as
a view of another owner (let mut out = path) only free after they take Own
(conditional freer flag — no double-free with the caller).
Regular let bindings are the simplest form. They work like stack values:
let x = 42 // immutable
let mut y = 10 // mutable
y = y + 1
let mut s = make([]int, 0, 8)
s = append(s, 1) // s freed at end of scope
let v: string_view = "route" // zero-copy view — never free
let owned = f"id={1}" // owning string — free at scope exit
let w = str_as_view(owned)
For most local computation, this is all you need. Prefer string_view for
read-only hot paths; use make([]T, 0, n) when you will grow.
Matching on Result / Option Own payloads takes ownership of the payload for
the arm. The arm frees it unless you move it into a larger expression result:
fn load() -> Result[string, string] {
return Ok("payload")
}
fn use_match() {
match load() {
Ok(s) => {
// s freed at end of arm
assert(str_len(s) > 0)
},
Err(e) => {
// e freed at end of arm
assert(str_len(e) > 0)
},
}
// Move into a let — only the let frees
let s = match load() {
Ok(x) => x,
Err(e) => e,
}
}
let _ = value and _ = value destroy the resolved payload of a fresh
Option or Result. Common string, slice, map, struct, and nested-bag shapes
are supported. Bags borrowed from fields or indexes are left untouched so the
containing value remains valid. A debug compiler warns when it cannot resolve
the payload type and therefore cannot complete cleanup.
let _ = load()
fn branch(a: string, path: string) -> string {
let mut out = path // alias of path until reassigned
if str_eq(a, "f") {
out = "custom" // out becomes freer of its Own buffer
}
return out // free only if out took Own (not the raw param)
}
hold marks a binding as move-semantics. Once moved, the original is gone:
hold let x = 7
hold let y = x // x is moved into y
print_int(y) // 7
// print_int(x) // COMPILE ERROR: use of moved value `x`
Moving into a function call also consumes the binding:
fn consume(n: int) -> int {
return n * 2
}
hold let val = 42
print_int(consume(val))
// print_int(val) // COMPILE ERROR: moved into consume
hold let mut x = 7
x = 9 // allowed -- still owned
print_int(x) // 9
Move individual fields while keeping the rest usable:
struct Point { x: int y: int }
hold let p = Point { x: 1, y: 2 }
let px = p.x // moves only x
print_int(p.y) // y still usable
// print_int(p.x) // COMPILE ERROR: x already moved
Integer and bool types are Copy -- they can be re-read after a hold binding
without consuming:
hold let n = 42
let a = n
let b = n // fine -- int is Copy
print_int(a + b) // 84
share creates a read-only shared reference. While a share exists, the
original cannot be mutated:
hold let a = 1
share let s = share_int(a)
print_int(share_get(s)) // 1
// a = 5 // COMPILE ERROR: cannot mutate while shared
share_drop(s)
// Now a is free again (if mut)
Rules enforced at compile time:
share let is always immutable (no share let mut)share_drop, block exit, or last use (NLL)| Situation | Use |
|---|---|
| Value has one owner, passed linearly | hold |
| Multiple readers, no mutation needed | share |
| Short-lived local computation | plain let |
| Large allocation, bounded lifetime | arena |
Prefer hold (unique ownership) whenever possible. It adds no reference-counting
traffic and gives the compiler maximum freedom to optimize.
An arena allocates many objects cheaply and frees them all at once when the scope exits:
fn main() {
arena a {
let msg = arena_text(a, "hello arena")
print(msg)
let xs = arena_ints(a, 4) // 4 ints, zeroed
print_int(len(xs)) // 4
let stamp = arena_stamp(a, 99)
print_int(stamp) // 99
}
// Everything in `a` freed here -- one deallocation for the whole region
print("arena done")
}
Inside an arena block, make allocates from the arena automatically:
arena a {
let mut s = make([]int, 3, 8) // backed by arena memory
s[0] = 10
s[1] = 20
s = append(s, 30) // grows within the arena
print_int(len(s)) // 4
}
struct Point { x: int y: int }
arena a {
let mut pts = make([]Point, 0, 4)
pts = append(pts, Point { x: 1, y: 2 })
pts = append(pts, Point { x: 3, y: 4 })
print_int(pts[0].x) // 1
}
The compiler tracks ownership through branches and loops:
hold let x = 7
if some_condition() {
let y = x // moves x on this branch
} else {
print_int(x) // x still alive on this branch
}
// x may or may not be moved here -- compiler tracks both paths
Moves in a loop iteration are checked per-iteration:
hold let x = 42
while condition() {
// Cannot move x here -- would be used-after-move on next iteration
print_int(x) // read of Copy type is fine
}
Use defer to ensure cleanup runs when a scope exits:
fn process() {
let fd = open_resource()
defer close_resource(fd)
// ... work with fd ...
// close_resource runs automatically on return
}
Defers execute in LIFO (last-in, first-out) order.
fn handle_request(fd: int) {
arena req {
let mut buf = make([]byte, 0, 4096)
let c = http_accept(fd)
if c < 0 { return }
let path = http_path(c)
let body = http_body(c)
if str_eq(path, "/data") {
let _ = http_respond_json(c, 200, body)
} else {
let _ = http_respond(c, 404, "not found\n")
}
let _ = http_close(c)
}
// All request memory freed in one shot
}
| Mechanism | Overhead | Lifetime | Use case |
|---|---|---|---|
let |
None (stack) | Lexical scope | Local computation |
hold |
None (move) | Until moved | Linear resource passing |
share |
RC seed | Until drop/end | Multiple readers |
arena |
Bump alloc | Block scope | Batch/request work |