12. Mutability and visibility
- Plain
= is immutable binding. To allow reassignment, use var.
x = 5 # immutable
var y = 0 # mutable
y = y + 1 # OK
- Structs are values, and a field write rebinds. A struct binding is a value and no reference:
q = p is an O(1) copy of the binding, and a later change to one never affects the other. There is no aliasing, unlike Python or JS. A value cannot mutate in place, and a field write p.x = v, or a nested p.a.b = v, rebinds p to an updated copy of the struct. It therefore requires p to be a var, and writing through a plain = binding or a parameter is rejected (H0207). No other binding that aliased the old value observes the change, and q below retains its old field:
struct Point
x: i32
y: i32
end
def main!() -> () [Crash]
var p = Point(x=1i32, y=2i32)
q = p # copy of the binding
p.x = 9i32 # rebinds p; q is untouched
assert!(p.x == 9i32) # p sees its own update
assert!(q.x == 1i32) # q keeps the old value - no aliasing
end
- Values are acyclic, and a knot cannot be tied. Recursive types are ordinary, a linked list or a tree, and no sequence of writes can make a value reference itself, even transitively. A field write's right-hand side is evaluated before the write and therefore sees at most a copy of the old value, and a constructor's arguments are evaluated before the aggregate they build exists. Every attempt to close a cycle nests one more finite level. To model a cyclic structure, a graph or a doubly-linked list, name nodes by key in a
Map<Id, Node>, or let actors reference each other: an ActorRef is an identifier and no traced pointer, and actor reference cycles are fine. Erlang gives its terms the same guarantee. Acyclicity is a semantic guarantee the runtime rests on. Every structural walk (==, cmp, hash, rendering, the mailbox deep copy) terminates on a finite value, and per-actor heap reclamation is plain reference counting, complete with no cycle detector, on both tiers. A value is freed at the instant its last handle disappears, which is also what makes resource close deterministic (§4), and there is no tracing collector and no collection pause.
open option
struct Node
id: i64
next: Option<Node>
end
# Total: values are acyclic, so the chain always ends.
def len(n: Node) -> i64
match n.next
Some(rest) -> 1i64 + len(rest)
None -> 0i64
end
end
def main!() -> () [Crash]
var x = Node(id=1i64, next=None)
y = Node(id=2i64, next=Some(x)) # y.next holds x's value
x.next = Some(y) # rebinds x - ties no knot
assert!(len(x) == 2i64) # x -> y -> old x; the chain ends
end
- A field write allocates a fresh aggregate, and a naive loop writing one field at a time would allocate each iteration. A compile-time uniqueness, or escape, analysis elides the copy to an in-place write where
p is provably unshared at the write. An accumulator loop that builds a struct it never aliases out, with no q = p, no passing it to a call, send or closure, and no return p, remains allocation-free, matching Rust's mutate-through-owner and Python's mutate-object. The analysis is flow-sensitive: a write whose value escapes only later, where the struct is built field by field and then returned, still mutates in place, while a write whose binding may be shared, through a loop back-edge included, retains the copy. This is a pure optimisation. The observable value semantics above are identical both ways, the two execution tiers stay in lockstep, and the cost of any un-elided copy is visible in the allocation profile and never a silent correctness gap. Where the static proof fails, on an aliased-then-dead binding or a form the analysis rejects, single-level p.x and two-level p.a.b writes on typed builds fall back to a runtime uniqueness check: a root whose handle count is 1 at the write mutates in place, and a shared one copies. Array.set obeys the same law: same-binding rebinding on a provably unique array is the allocation-free form, and a shared receiver copies its contiguous buffer. Deeper field chains and comptime field writes always copy. To see which writes the compiler could not prove, run hanki check --explain-copies. It reports each field write or Array.set lowered with the runtime check or the copy (H0564, non-blocking lint advice), the message says why, and it adds an O(n) note where the write is inside a loop. An accumulator that allocates because its root remains aliased is therefore easy to spot. What the lint cannot say is how much a flagged write costs: a write whose runtime check finds a unique root allocates nothing, and one inside a hot loop allocates once per iteration. hanki run --profile-allocs counts what a run paid and reports it per write (below). An actor's state fields are the one truly mutable place, and a field write on a struct-typed state field rebinds that field the same way.
- Shadowing is allowed only where the type does not change.
- Visibility: identifiers starting with
_ are private to the module. There is no separate export list, and a module's public surface is its names that do not start with _. The compiler enforces this (H0622), in every spelling a caller has for a name: qualified (m._helper()), bare through an open m, as an associated function on a type (u32._parse, m.T._make()), as a method or prop reached through a value receiver (writer._emit(s), where the receiver's type names the member's declaring module), as an actor handler in a send, and as a field. The declaring module reaches its own private names by every spelling.- A
_-prefixed field is private on a transparent struct as much as on an opaque type: the rule is about identifiers, and a field is one. Reads and writes of the field are rejected outside its module, and so are construction and destructure of the whole struct. v1 construction and patterns supply every field, the positional spellings without even naming it, and a struct with a private field is therefore constructible and matchable only by its own module, the opaque smart-constructor pattern reached by a second route. Public fields of such a struct remain readable everywhere. - A
_-prefixed trait member belongs to the trait's module. Impls conform to the trait's declared items, and the trait declares the identifier wherever the impl sits. Calls through any dispatch route, a value receiver, a bounded generic, a static T.fn, or a Module<T> handle, are rejected outside that module, which in practice makes such a trait usable only within it. - Dropping the underscore to publish a name also subjects it to the doc-coverage gate, the intended trade: a public name owes the reader a doc comment.
- For type-level opacity, the smart-constructor pattern where callers can hold values of a type but cannot construct, destructure or read its fields, use the
opaque modifier on the type declaration. See §9.