12. Mutability and visibility
- Plain
=is immutable binding. To allow reassignment, usevar.
x = 5 # immutable
var y = 0 # mutable
y = y + 1 # OK
- Structs are values, and a field write rebinds. A struct binding holds a
value, not a 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). Because a value cannot mutate in place, 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; writing through a plain = binding or a parameter is rejected (H0207). No other binding that aliased the old value observes the change - q below keeps its old field:
struct Point
x: i32
y: i32
end
def main!() -> ()
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 - a knot cannot be tied. Recursive types are ordinary
(a linked list, a tree), but no sequence of writes can make a value reference itself, even transitively: a field write's right-hand side is evaluated before the write, so it holds 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 just nests one more finite level. To model a genuinely cyclic structure (a graph, a doubly-linked list), name nodes by key in a Map<Id, Node>, or let actors reference each other - an ActorRef is an identifier, not a traced pointer, so actor reference cycles are fine. (Erlang gives its terms the same guarantee.) Acyclicity is a semantic guarantee the runtime leans 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!() -> ()
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, so a naive loop writing one
field at a time would allocate each iteration. A compile-time uniqueness (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 (no q = p, no passing it to a call/send/closure, no return p) stays 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 (e.g. the struct is built field-by-field and then returned) still mutates in place, while a write whose binding may be shared - including via a loop back-edge - keeps the copy. This is a pure optimisation: the observable value semantics above are identical either way, so the two execution tiers stay in lockstep and the cost of any un-elided copy is visible in the allocation profile, never a silent correctness gap. Where the static proof fails (an aliased-then-dead binding, a shape 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, anything genuinely shared copies. Deeper chains and comptime evaluation always copy. To see which writes the compiler could not prove, run hanki check --explain-copies: it reports each field write lowered with the runtime check or the copy (H0564, non-blocking lint advice) - the message says why (the root may be shared, or the target is nested) and adds an O(n) note when the write is inside a loop, so an accumulator that allocates because its root stays aliased is easy to spot. An actor's state fields are the one genuinely mutable place - a field write on a struct-typed state field rebinds that field the same way.
- Shadowing is allowed only when the type does not change.
- Visibility: identifiers starting with
_are private to the module. The compiler enforces this. - For type-level opacity (smart-constructor patterns where callers can hold values of a type but cannot construct, destructure, or read its fields), use the
opaquemodifier on the type declaration - see §9.