Mutability and argument passing
Principle: inside a process there is no concurrent aliasing
Section titled “Principle: inside a process there is no concurrent aliasing”A process has a single line of execution. The scheduler suspends it at the cooperative points (io.*, send/recv, <->) and can preempt it between instructions (section 3). In any case, the switch is always to another process, with its own isolated heap (section 5), and no other process touches its heap. Two pieces of code never mutate the same memory at the same time inside a process. It is the data-race under aliasing that forces Rust to have a borrow checker and lifetimes; intra-process, that reason does not exist.
What is left is smaller: use-after-free, confined to @mm(none) (where you asked for C-style responsibility); and an aliasing bug from mutating a structure while holding another reference to it. The model below eliminates the second by default and keeps the first as explicit pointer territory.
Immutable by default
Section titled “Immutable by default”The language is immutable by default. The safe case is what is written without ceremony; only what has write capability is marked. There are four declaration forms, and the shortcut is the common case:
| Declaration | Meaning |
|---|---|
x := … |
immutable, runtime (default); shortcut for let |
let x := … |
immutable, runtime (explicit form) |
var x := … |
mutable, runtime |
const x := … |
compile-time constant (see section 10) |
Explicit type Odin-style: x: int = 5. And := is literally : = with the type omitted: the same construct, type inferred. The rule: := (or : T =) declares, a bare = only assigns (to a var); the : is what marks a declaration.
x := 5 // declares, immutablex = 10 // ERROR: x is not var
var y := 5 // declares, mutabley = 10 // oklet and the bare := mean the same, immutable runtime; let is the explicit form, just as var is for mutable. const is the compile-time axis and lives in section 10. The complete grammar of declaration and the other keywords are in section 14 (Syntax).
A name is declared once per scope: re-declaring it in the same scope is a compile error. x := 1 then x := 2 (or a second let x) in the same scope is rejected — it is neither mutation (which is var plus =) nor shadowing (the language has no local shadowing, section 6/9), so it can only be an illegal redeclaration of an immutable name. To change a value in place, declare it var and assign with =; to reuse a name for a different value, open a new scope. (The check is uniform across backends: the native compiler must reject the re-:=, not silently rebind it.) Shadowing a parameter with a body let/:= is allowed (section 14): the parameter belongs to an enclosing scope, so the body binding shadows it rather than re-declaring it.
Write capability declared and never exercised is a compile error, in the spirit of Go’s “unused import/variable”. A var that is never mutated should be let/:=, and the compiler refuses:
var x := compute()return x * 2 // ERROR: 'x' is var but was never mutated. Use the immutable formThis is what gives teeth to immutable-by-default: every request for mutability has to be paid for with a use, otherwise it does not compile. It kills the reflex of “leave everything mutable just in case”.
Three concepts for value and mutation
Section titled “Three concepts for value and mutation”There are three mechanisms, each answering a different question; they are not interchangeable flavors:
| Concept | Question it answers | Is it a type? |
|---|---|---|
| value (default) | “I just need the data” | n/a |
mut (passing) |
“I want to mutate my caller’s state, transient and safe” | no |
*T (pointer) |
“I want a reference that persists and can be shared” | yes |
Under one lens, mut and *T are the same thing: write handles. Hence the single rule that governs both:
Writing to a place requires the place to be
var.mut(transient write) and*T(persistent write) are the two ways to obtain a write; both require avartarget. Immutable (let/:=/const) allows only reading, copying and sharing.
Keeping the three on distinct axes is what avoids Rust’s &T / &mut T / *const T / *mut T tangle. Here there is no “mutable pointer” as a separate type: mut is not a type, and there is a single pointer type.
Collections are values: copy on bind, share only via view or pointer
Section titled “Collections are values: copy on bind, share only via view or pointer”The value default is uniform: it covers collections too, not only scalars and structs. A slice, an array, and a List are value types. Binding one copies it, constructing a struct with one in a field copies it into the field, and returning one from a function copies it out. Two bindings never silently share a backing store:
xs := []int{1, 2, 3}var ys := xs // ys is an independent copyys[0] = 9 // xs stays [1, 2, 3]; only ys changedSharing is never implicit; you ask for it, and there are exactly two ways to ask:
- a view,
xs[a..b](and the whole-slice formxs[..]), which is a bounded window onto the same backing store (section 6,[]T=[*]T+ length). Writing through the view writes the original. - a pointer,
&x, which is a persistent alias with heap identity (below).
var xs := []int{1, 2, 3}var v := xs[..] // a view: shares xs's backingv[0] = 9 // xs is now [9, 2, 3]Writing through a view is per element (v[i] = x) or a whole region at once (xs[a..b] = ys, a bulk write that overwrites the window from ys, its width matching the range). Both reach the backing, so a region write on a [*]T fills the buffer it points at just the same.
This is why the two aliasing questions collapse into one answer. “Does a struct field alias the slice I built it from?” No, construction copies. “Does let ys := xs freeze a shared reference to a mutable xs?” No, it snapshots a copy, which is exactly what you wanted when you saved the state. Aliasing exists only where you spelled it (a view or a &), so there is no accidental coloring: an immutable binding of a mutable collection is always a clean copy, never a hidden read-only alias that has to be tracked.
The copy is a semantic guarantee, not a mandatory memcpy. For an immutable value nothing ever writes to the backing store, so the compiler is free to alias it internally without a copy (line above, on immutable sharing): the observable behavior is copy-on-bind, the implementation elides the copy whenever a write can never be observed. You reason about values; the compiler optimizes underneath.
Mutability is of the place, not the field
Section titled “Mutability is of the place, not the field”A struct is mutated as a whole, through the place it is reached by. A field carries a type and a visibility, never a var. p.x = v is legal exactly when the place rooted at p is var; the field inherits mutability from the binding, it does not declare its own. This is the same place rule as above, applied one level down: writing p.x is writing to a place, and the place is var or it is not.
Mutability and visibility look like they should both decorate a field, four boxes to tick (pub/priv × mut/immut). They do not, because they are different axes living at different levels (section 2). Visibility is of the field: it answers “who may reach this”, an access boundary that is inherently per-field and crosses modules. Mutability is of the place: it answers “may this be written”, a capability that flows from the binding down through the lvalue path. A var marker on a field would have nowhere coherent to stand: inside a let p it could not write (the place rule forbids it), and inside a var p it would be redundant. Either a contradiction or a no-op, so the field never carries it.
The case that tempts you toward a per-field var (an id fixed at construction while a name still changes) is encapsulation, and encapsulation is the visibility axis doing its job: make id private with no method that writes it. The whole struct can be var; the type’s own methods decide who mutates what. So the four boxes collapse to two for a field (pub/priv), and mutability contributes none, because it was never the field’s to carry.
mut: passing with write-back
Section titled “mut: passing with write-back”mut is the equivalent of Swift’s inout. By default, arguments go by value: the function receives a copy and does not alter the caller’s variable. For the function to mutate the caller’s variable, the parameter is marked with mut, and the mark is repeated at the call site. By the rule above, the target has to be var:
fn process(value: int) { ... } // by value; the caller does not changefn process(value: mut int) { ... } // write-back
var x := 5process(x) // copy: x stays 5process(mut x) // mutates x in-place
z := 5process(mut z) // ERROR: z is immutable; mut requires varInside the function, a mut parameter is mutable (that is its purpose); an ordinary parameter is an immutable binding:
fn a(value: int) { value = 9 } // ERROR: an ordinary parameter is immutablefn b(value: mut int) { value = 9 } // ok: mutates and returns to the caller
fn accumulate(total: mut int, values: []int) { loop v in values { total += v }}var sum := 0accumulate(mut sum, data)Want to work on a local copy without write-back? Rebind with var, no new syntax: it is the same mutable declaration:
fn process(value: int) { var acc := value // mutable local copy acc += 10 // touches mine; does not affect the caller}Properties of mut:
- It is not a type. You do not declare a
mutvariable, you do not store it in a struct or array, you do not pass it along. It exists only during the call. - Exclusive. You cannot pass the same place as
muttwice in the same call (f(mut x, mut x)is an error). The guarantee is local: the compiler only checks “has this place already been lentmutin this call?”, without whole-program lifetime analysis. - Does not escape. Since the access does not become a value and dies at the end of the call, exclusivity is checkable locally. Same spirit as
@transfer(“has this variable already been transferred?”):transferis the inter-process version (exclusive, source invalidated forever);mutis the intra-process and scoped version (exclusive for the duration of the call, the source comes back afterward). - Never exercised is an error. A
mutparameter that the function never writes to is a false contract: it promises write-back and does not deliver. It falls under the same rule as the never-mutatedvar.
mut applies to places (lvalues): mut x, mut p.field, mut arr[i], mut *p. This does not create a *mut: it is the same mut over a place that happens to have been reached via a pointer, and you pass the pointed-to thing in write-back. (In a simple variable, exclusivity is guaranteed statically; in mut *p it is not, because the pointer can alias. But there you are already in pointer territory, where responsibility is yours.)
Write-back is the semantics, by-ref is the implementation. “A copy goes in, a copy comes out” is how you reason about mut (without spurious aliasing getting in the way), but the compiler lowers mut to passing by reference when exclusivity holds, and for a direct place it always holds. So passing a large struct as mut, or filling a tree field by field via helpers (f(mut node.left)), is zero-copy: the C pattern of “build on the stack, pass to a function to fill” works without forcing the heap, and without an asterisk soup, since you write mut node.left, not &node.left here and *p there. The literal copy is left only for scalars, where it is cheaper than a pointer anyway.
Pointers: *T
Section titled “Pointers: *T”A pointer is for whoever needs to hold a reference: graphs, intrusive lists, a cache that several parts point to, FFI with C, manual MM. It is a first-class type, and the *T (the pointer-to-one) has a single form, no *const/*mut. (The pointer-to-many [*]T, the basis of collections, comes further on; it is another construct, not a mutability variant of *T.)
The reason for a single type (no *const/*mut) falls out of the write rule combined with immutable-by-default: a pointer is only formed from a var place with persistent identity, be it a heap allocation, an element of a structure or FFI. Not from a stack variable, not from an immutable place. So every *T points to something mutable writing through it is always valid there is no const/mut split.
You never need a read-only pointer, because immutable is shareable for free: since nothing ever writes to an immutable value, the compiler aliases the backing store freely, without a copy (it is not even copy-on-write, it is pure sharing). It passes by value; the compiler shares. A visible pointer exists only for persistent mutable aliasing, which is always read-write. *const/*mut reappears in a single place: the FFI-with-C boundary, in the unsafe escape hatch, because C requires it. Isolated, it does not pollute the core.
A pointer is formed with &place, from a var place with identity on the heap. Note that the pointer’s binding and the pointed-to thing’s mutability are independent:
p := &node.next // p is immutable (I won't re-point), but points to a var place*p = other // I write through it, the pointed-to thing is mutableThe same independence holds at any depth of a place that goes through a pointer. With let a: [*]Node, the elements live on the heap and are mutable, so a[0] = n, a[0].next = q, mut a[0].val and &a[0] are all valid: let freezes only the handle (a cannot be re-pointed). And since none of them changes the binding, a var a used only that way is a var that is never mutated, which is an error like any other. A fixed array [N]T is different: it is a value held by its binding, so writing into one of its elements needs var.
Deref is explicit on every access, with *p, both on read and write:
fn accumulate(total: *int, values: []int) { // total points to something on the heap, mutable loop v in values { *total = *total + v } // * on each read and write}Nullability via Optional[*T]. The pointer itself is non-nullable; absence lives in a separate Optional[*T], explicit in the type system. You deal with “may not exist” only when the type says it can.
Pointer-to-many: [*]T
Section titled “Pointer-to-many: [*]T”*T points to an object. A collection needs something else: N contiguous elements that it owns, the backing store of a List, of a Map. For that there is [*]T: a pointer to a contiguous sequence of T that does not carry a length. It is the “pointer-to-many” (Zig [*]T style), distinct from the pointer-to-one.
The relation with slice closes the model: []T is [*]T + length. A slice is a bounded view over a [*]T; the raw pointer says where it begins, the length says where it stops. Every safe slice of a raw buffer is a []T; the buffer itself is the [*]T.
*T // one object[]T // view: [*]T + length (borrowed, known bound)[N]T // array by value, N fixed at compile time[*]T // N contiguous that I OWN, indexable, WITHOUT lengthA slice or array value is written with the type as the head and the elements in { }, and since the elements are a homogeneous value group they separate with , (not the ; of struct fields, section 14): []int{1, 2, 3} is a slice, [3]int{1, 2, 3} a fixed array, and [_]int{1, 2, 3} an array whose size the compiler infers from the literal (here 3, the [...] of Go with _). A slice value also arises by slicing (buf[a..b], where the length is born). Indexing xs[i] reads an element, bounds-checked for []T/[N]T (unchecked only for the raw [*]T).
Indexing [*]T is unchecked, hence unsafe. It does not know its own size (it is the trade-off of “no length”), so the compiler has no bound to check, exactly like a raw T* in C. That is why the raw [*]T lives inside a collection, and the collection wraps it in safe doors: l.at(i) (checks i against the len and panics if it overflows) and l.get(i) (returns Optional[T]) validate against the len that the collection keeps. Safe on the outside, unsafe on the inside: the same containment model as unsafe (section 5).
No pointer arithmetic with +/-. ptr + i is C’s implicit footgun, and overloading + for a pointer violates “one symbol, one intent”. The three operations that implementing a collection requires are explicit and named:
items[i] // unchecked index → T (unsafe)items[a..b] // unchecked slice → []T (the [*]T → []T bridge; it is where the length enters)items.offset(k) // advances the base → [*]T (named method, for ring buffer / manual layout)Access is indexing, slicing produces []T (where the length is born), advancing the base is a method (offset), never an operator. You have the power of C’s raw pointer (index, slice, base advance) with the language’s explicitness: named and indexed, not +.
It is born from the MemoryManager (section 5), colorless intact. mm.alloc(n, align) returns a Ptr, reinterpreted as a typed [*]T. The [*]T carries the MemoryManager that allocated it; freeing the collection returns the buffer through its MM, without coloring, without manual tracking. Hence the typical backing store:
decl List[T] { items: [*]T // the raw buffer I own len: usize // how many are alive cap: usize // how many fit before reallocating}The List is writable in the language itself; it does not descend to an opaque RawPtr. The [*]T is the honest piece that was missing between “a pointer” (*T) and “a view” ([]T): the buffer you allocate, own and grow.
Why a pointer does not become a lazy shortcut for mut
Section titled “Why a pointer does not become a lazy shortcut for mut”The legitimate concern: if you can mutate via a pointer, why learn mut? A programmer follows the shortest path to type. It is what happened with the ? operator in Rust/Zig, used to escape handling an error just because it was one character. The answer is not to make mut more attractive; it is to make a pointer not be a substitute, just as in Go a value does not substitute for an error. The two have to stop solving the same problem. Three defenses, in order of strength:
- A pointer is born from identity on the heap (
var), not from any stack lvalue (the backbone). You do not draw a storable pointer from a local stack variable. To mutate a local scalar you simply have no way to take a pointer; you only havemut. This closes the shortcut at the source for most cases and solves the classic dangling without lifetimes:
- Under GC: a live
*Tis a reference thetracesees the GC keeps the target alive the pointer does not dangle, without needing a lifetime. - Under arena/none: life goes until the arena drop, or it is your responsibility; explicit escape-hatch.
Forbidding &local takes nothing from you, because the only decent reason to want the address of a local, “return a mutation to the variable”, became precisely mut.
The rule is about the stack lvalue, not about the name — so it does not forbid taking the address of a heap element reached through a local handle. &a[i] where a: [*]T (a many-pointer) is allowed: a is only a handle, and its pointee lives on the heap (born from mm.alloc), so &a[i] is a valid heap pointer, not a dangling stack address. What is forbidden is &x of a scalar stack local; a many-pointer’s element is elsewhere, and the no-&local rule stops at the stack.
-
Deref costs on every use (a targeted tax). For an object on the heap, where the pointer is legal, both
f(&obj)andf(mut obj)exist, and that is where the deref decides. The call site is almost a tie (one token), but the function body is where the typing lives, and there the pointer pays*on each access, whilemutuses the value directly. To mutate, the number of accesses is always > 1, so the pointer-path ends up more expensive overall. And the tax is targeted: passing a pointer along without dereferencing (plumbing, persistence, aliasing, the legitimate use) pays nothing; only whoever dereferences to mutate pays, which is exactly the case of someone who should have usedmut. It is not artificial punishment: deref is a real operation (an indirection), so making it visible is consistent with the language’s explicitness. -
Different intent in the signature (reinforcement).
f(x: mut int)promises “I mutate this variable and return it, exclusive, ends here”;f(p: *int)promises “I keep/share this address, it can alias, it may outlive the call”. The compiler/linter treats “I took a pointer to something just to write once and discarded it” as a code smell: using the persistence tool for a transient job.
Combined effect: the lazy shortcut either does not even compile (stack local, defense 1) or gets more verbose on every line (deref, defense 2), while the legitimate pointer pays no toll. mut does not compete; the pointer went to another domain of the language. There is no ?-effect, because the two stopped solving the same problem.
Interaction with @mm
Section titled “Interaction with @mm”For a mut parameter, any reallocation needed to mutate the object uses the object’s own MM (which travels with it, section 5), not the function’s @mm. The function’s @mm governs only the new allocations it originates from scratch:
@mm(gc)fn append_log(buf: mut Buffer, line: string) { buf.push(line) // if push reallocates, it uses buf.mm (= Arena), not the @mm(gc)}
var buf: Buffer @mm(arena) = Buffer.new()append_log(mut buf, "error X") // mutates in-place in the arena; the GC does not touch itThe function mutates buf without knowing or caring that it is arena-backed: colorless, like the rest of the design. Scalars are the trivial case: an int lives on the stack, does not allocate, and @mm over it is a no-op. Mutating is just a write on the stack, without a pointer and without an MM.
Reassigning the result to a var (x = process(x)) is not an expensive copy: since the old binding dies, the compiler does a move/in-place (RVO style).
Transient scope: expressions and boundary behaviors
Section titled “Transient scope: expressions and boundary behaviors”Some constructs of the language do not denote a value with a lifetime; they denote a transition. They exist only crossing a boundary (a call, a process’s limit) and vanish on the other side; they are never left loose, never bound to a name. We call this scope transient, and it has two inhabitants:
- Transient expressions:
mutand lambdas. They are the transition.mut xis not a value; it is “pass the write permission ofxfor this call” (above, in this section). A lambda-argumentfn(p) => …is not a value you hold; it is “this recipe, for the callee to run when it wants” (section 14). None lives anywhere. - Transient behaviors:
@transferand@promote. They annotate a value that crosses, governing how it goes across the barrier between processes:@transfermoves (invalidates the source, the object travels with itsmm),@promote(mm)copies to anotherMemoryManager(section 5). The value exists; the annotation governs the crossing. (The simple copy is the unmarked default, which is why there is no@copy.)
The difference between the two sides: the transient expression is the transition (with no value underneath); the transient behavior modifies a value during the transition.
Transient is an axis, orthogonal to comptime/runtime. Comptime vs runtime is the when axis (in which phase the expression is evaluated). Transient is the where axis (in which position it can appear, only at the boundary). They are independent, and reflection (section 10) proves it: the lambda of reflect(T).construct(fn(f) => …) is transient on the where axis (argument only) and comptime on the when axis (unrolled per field). If transient were a third value on the comptime/runtime axis, that combination would be contradictory; since it is another axis, it is free and correct.
The rule, said once: transient expressions exist only in argument/parameter position. The grammar already imposes it in two separate places: lambda “only as a direct argument” (section 14), mut only in a parameter/call-site (above). Naming the transient scope says the same rule once, and explains why it exists.
And the rule is the latch that kills two classic footguns, by construction:
- Rust’s mut tainting, where
mutis a property that propagates and infects signatures, does not happen, because a transientmutdoes not exist loose to propagate. - JS’s loose arrow functions,
const f = () => …floating around, capturing scope, creating identity confusion, do not happen, because a transient lambda cannot be bound to a name. Want a function-value to hold? Use a namedfn, or a binding of a function type. The category is the latch.
As the variant-set generalized the error-set (section 7), the transient scope generalizes: any future construct that is “a transition, not a value” inherits the rule for free.