Skip to content

Rationale · essay 02

Memory

rationale.md · 104 lines · 4 min read

The redefinition: memory is colorless, and the manager travels with the object, not with the context. And there is no borrow checker.

Every systems language has to answer two questions: where the bytes come from and who frees them. Rust answers with ownership + lifetimes + borrow checker (and &T/&mut T/*const T/*mut T as distinct types). Go answers with a GC and that is it. Zig answers with allocators passed as a parameter. Each answer colors the code in a way.

Every value allocated on the heap carries a pointer to its own MemoryManager (@mm). When the object grows or reallocates, it uses its @mm, not that of the function manipulating it. The function mutates a Buffer without knowing or caring whether it is arena-backed, GC or manual:

@mm(gc)
fn append_log(buf: mut Buffer, line: string) {
buf.push(line) // if push reallocates, it uses buf's @mm (the arena), NOT the function's @mm(gc)
}
var buf: Buffer @mm(arena) = Buffer.new()
append_log(mut buf, "error") // mutates in-place in the arena; the GC never touches it

This is “colorless” in action: a function’s @mm(gc) governs only the new allocations it originates, never the strategy of the objects that arrive at it. The strategy travels with the data.

Underneath, two orthogonal layers: MemoryManager (the strategy, gc/arena/none/c, “how I manage”) over MemorySource (where the raw pages come from, mmap/VirtualAlloc/ WASM’s memory.grow/bare-metal fixed buffer, “where the RAM comes from”). Any combination works: GC-over-WASM, arena-over-native. It is the same decoupling as colorless, descended one level.

Why can the borrow checker be abandoned? Because its reason disappears inside a process. The borrow checker exists to prevent concurrent mutable aliasing, two paths mutating the same data at the same time. But in Makoto the process is the unit of concurrency, with a single line of execution; the scheduler only suspends it at explicit points (IO, send/recv), and no other process touches its heap (shared-nothing, ch. 04). Without concurrent aliasing, the main justification for the borrow checker vanishes. Only two dangers remain, and each one has a local defense:

  • Aliasing bug (mutating a struct while holding another reference to it): solved by value semantics by default + mut for mutation. mut is a passing mode, not a type (like Swift’s inout): exclusive, write-back, does not escape (it dies at the end of the call). The check is purely local (“is this place already mut here?”), without lifetimes.
  • Use-after-free: it only exists under @mm(none)/arena, explicitly unsafe territory. Under the GC default, a live *T pointer is a reference the trace sees, so the target stays alive, safe by construction.

The key that closes the model: a pointer is born from persistent identity, whether a heap allocation (which already carries its @mm), a struct field or FFI. You cannot take *T from a local stack lvalue; to mutate an int on the stack, there is only mut. Hence stack dangling is impossible: the & of a local does not give you a storable pointer.

  • Lifetimes / borrow checker (Rust). &mut T packs three things at the same time (can alias, is exclusive, has a lifetime), and it is that which forces the borrow checker to exist. Makoto unpacks the bundle: mut is the “exclusive + write-back” without the “can alias”; *T is the “can alias” without the rest. Separated, neither of the two requires global tracking.
  • Nullable pointers by default (C/Go). They would make Optional[*T] redundant and reopen the null ambiguity. In Makoto the pointer is always valid; absence is Optional[*T], explicit.
  • Allocator as a parameter (Zig). It breaks colorless: the function would come to know which memory it uses, and passing an arena buffer to a GC function would become a mismatch. With @mm traveling in the object, this is safe and invisible.
  • Smart pointers / Cell / RefCell (Rust). They would embed in the core a machinery that contradicts “do not embed what is not used”. The @mm interfaces cover the same ground more cleanly.
  • An explicit @copy marker. Discarded: a plain copy is the unmarked default, and marking the common case would violate opt-in (you would pay syntax for what happens most). What gets a mark is the opposite: moving (@transfer, invalidates the source) and copying-to-another-MM (@promote(mm), cuts the cord with the source arena).

There was a real, named fear: that mut would die for the pointer shortcut (the way ? became a universal escape-hatch in Rust/Zig). The design answer was not to make mut more attractive. It was to make a pointer not a substitute: it solves a different problem (persistent reference), is born only from heap-identity (you cannot “prefer” it in the common case), and charges an explicit deref (*p) on each access, so whoever took a pointer “to avoid typing mut” types * all day long.

The Rust programmer who writes a simple mutation function and has to annotate lifetimes that have nothing to do with the problem. The Zig one who has to thread allocator: *Allocator through ten layers of calls. The Go one who has no choice of strategy when the GC does not serve. Each pain is a color of memory that leaked to where it should not have.

The object carries its memory strategy. You mutate the value; the runtime uses its @mm for any growth. No color, no lifetime.

Under @mm(none)/arena, use-after-free becomes your responsibility again, and the compiler does not cover it (it is the explicit price of descending to the metal, and it stays in an unsafe island). Non-nullable pointers force Optional[*T] where C would have a simple nullable. And the mandatory *p deref is noise in legitimate pointer code, accepted on purpose, because it is exactly what defeats laziness.

Next: 03 · Async and IO