Evaluation order matters as soon as an expression can mutate state, suspend, perform I/O, or fail. Atoll fixes the order at those observable boundaries; an optimizer may rearrange only work whose reordering cannot be noticed.
Every example on this page records what ran by appending to a string, so the order is printed rather than asserted.
Operands and arguments run left to right
A binary operator evaluates its left operand first. A call evaluates its arguments left to right, then calls.
struct Trace { steps: string }
impl Trace {
fn note(mut self, label: string, value: int): int {
self.steps = self.steps + label
return value
}
}
fn combine(a: int, b: int, c: int): int {
return a * 100 + b * 10 + c
}
fn main(): void {
mut binary := Trace { steps: "" }
sum := binary.note("L", 10) + binary.note("R", 20)
println("binary ${binary.steps} = ${sum}")
mut args := Trace { steps: "" }
packed := combine(args.note("a", 1), args.note("b", 2), args.note("c", 3))
println("args ${args.steps} = ${packed}")
mut items := Trace { steps: "" }
list := [items.note("x", 1), items.note("y", 2)]
println("list ${items.steps} = ${list.len()}")
}That prints LR, abc, and xy. The same source order applies to tuple,
array, struct-literal, and interpolation subexpressions, and the receiver of a
field, index, or method operation is evaluated once for that operation.
Order is a contract, not a style. Still, when two operands mutate the same object, binding their results first usually reads better than relying on the rule.
and and or short-circuit
The right operand of and runs only when the left is true; the right operand
of or runs only when the left is false.
struct Trace { steps: string }
impl Trace {
fn flag(mut self, label: string, value: bool): bool {
self.steps = self.steps + label
return value
}
}
fn main(): void {
mut conjunction := Trace { steps: "" }
both := conjunction.flag("L", false) and conjunction.flag("R", true)
println("and [${conjunction.steps}] = ${both}")
mut disjunction := Trace { steps: "" }
either := disjunction.flag("L", true) or disjunction.flag("R", true)
println("or [${disjunction.steps}] = ${either}")
mut guarded := Trace { steps: "" }
ready := guarded.flag("L", true) and guarded.flag("R", false)
println("both sides [${guarded.steps}] = ${ready}")
}Only the third line evaluates both operands, so the traces are L, L, and
LR.
Skipped is not unchecked
“Not evaluated” means absent from one runtime path. It never means exempt from name resolution, typing, or effect checking. A short-circuited operand is compiled like any other:
fn main(): void {
ready := false
// `ready` is false, so the right operand never runs — it is still resolved.
usable := ready and check_license()
println("${usable}")
}That is rejected with ATOLL1009: unresolved name check_license, even though
the call is unreachable at runtime.
Option fallbacks are lazy
?? evaluates its right side only when the left has no payload, and ??=
stores only when the target is empty.
struct Trace { steps: string }
impl Trace {
fn note(mut self, label: string, value: int): int {
self.steps = self.steps + label
return value
}
}
fn main(): void {
mut present := Trace { steps: "" }
cached: int? = Some(5)
value := cached ?? present.note("fallback", 9)
println("?? present [${present.steps}] = ${value}")
mut absent := Trace { steps: "" }
missing: int? = None
other := missing ?? absent.note("fallback", 9)
println("?? absent [${absent.steps}] = ${other}")
mut assign := Trace { steps: "" }
mut slot: int? = Some(3)
slot ??= Some(assign.note("compute", 9))
println("??= [${assign.steps}] = ${slot ?? -1}")
}The traces are empty, fallback, and empty. Note that ??= takes an Option
on the right, because it replaces the whole optional rather than its payload.
A method argument is not lazy
Laziness comes from the operator, not from the intent. unwrap_or takes a
value, so its argument is evaluated before the call — even when the option is
Some. unwrap_or_else takes a closure and calls it only on the empty path.
struct Trace { steps: string }
impl Trace {
fn note(mut self, label: string, value: int): int {
self.steps = self.steps + label
return value
}
}
fn main(): void {
present: int? = Some(1)
mut eager := Trace { steps: "" }
a := present.unwrap_or(eager.note("computed", 0))
println("unwrap_or [${eager.steps}] = ${a}")
mut lazy := Trace { steps: "" }
b := present.unwrap_or_else(() => lazy.note("computed", 0))
println("unwrap_or_else [${lazy.steps}] = ${b}")
}The first trace is computed; the second is empty. Reach for unwrap_or with
a constant and unwrap_or_else (or ??, which is lazy) when producing the
fallback costs something.
There is no unwrap on Option at all, so there is no unchecked shortcut to
misuse:
fn main(): void {
values := [1, 2, 3]
first := values[0].unwrap()
println("${first}")
}ATOLL2003: no method unwrap on type int?. Use ?? 0, .unwrap_or(0),
.unwrap_or_else(...), or a pattern.
Branches evaluate one path
if evaluates the selected branch. match evaluates its subject once, tries
patterns top to bottom, and runs the guard of a pattern only after that pattern
matches.
enum Event {
Data { value: int }
Closed
}
struct Trace { steps: string }
impl Trace {
fn next(mut self): Event {
self.steps = self.steps + "subject"
return Event.Data(7)
}
fn large(mut self, value: int): bool {
self.steps = self.steps + "|guard"
return value > 5
}
}
fn main(): void {
mut trace := Trace { steps: "" }
label := match trace.next() {
Data(value) if trace.large(value) => "large"
Data(_) => "small"
Closed => "closed"
}
println("[${trace.steps}] = ${label}")
}The trace is subject|guard: the subject ran once, the guard ran once, and no
other arm body ran. A failing guard resumes with the next arm. Bindings made by
a pattern are visible to its guard and body and do not escape the arm.
Statement position discards the value
A block statement still runs; only its value is dropped. This is why a call
whose result you ignore is legal, and why an if used as a statement is fine
even though if is an expression.
struct Trace { steps: string }
impl Trace {
fn note(mut self, label: string, value: int): int {
self.steps = self.steps + label
return value
}
}
fn main(): void {
mut trace := Trace { steps: "" }
trace.note("call", 1)
if trace.note("cond", 1) > 0 { trace.note("then", 2) } else { trace.note("else", 3) }
for i in 0..2 {
trace.note("|loop", i)
}
total := {
parts := trace.note("block", 4)
parts * 10
}
println("[${trace.steps}] = ${total}")
}The trace is callcondthen|loop|loopblock, and total is 40 — the block’s
tail expression is its value when the block is used as one.
Assignment happens once
An assignment evaluates its source once and writes the destination once. Compound assignment on a local or a field is a single read-modify-write of that place.
struct Counter { hits: int }
fn main(): void {
mut n := 1
n += 4
println("local ${n}")
mut counter := Counter { hits: 0 }
counter.hits += 3
println("field ${counter.hits}")
// Write a list element through explicit bindings when the index or the
// new value does real work: each step is then obviously evaluated once.
mut totals := [10, 20, 30]
index := 1
updated := (totals[index] ?? 0) + 5
totals[index] = updated
println("element ${totals[index] ?? -1}")
}Naming the index and the new value is also what you want if either side can suspend or fail, because the order between them becomes explicit.
An early exit stops the rest
?, return, break, and continue abandon the remaining expression, then
run the cleanup registered for the scopes being left. Deferred actions run
last-in, first-out at the scope boundary.
error LoadError { Missing }
struct Trace { steps: string }
impl Trace {
fn note(mut self, label: string, value: int): int {
self.steps = self.steps + label
return value
}
fn missing(mut self, label: string): int ! LoadError {
self.steps = self.steps + label
error Missing
}
}
fn load(mut trace: Trace): int ! LoadError {
defer trace.steps = trace.steps + "|cleanup"
first := trace.note("a", 1)
second := trace.missing("|b")?
return first + trace.note("|c", second)
}
fn main(): void {
mut trace := Trace { steps: "" }
outcome := load(trace)
println("[${trace.steps}] ok=${outcome.is_ok()}")
}The trace is a|b|cleanup: ? propagated before c ran, and the defer still
executed on the way out. See Defer for the full
ordering rules.
Suspension resumes, it does not restart
An argument that suspends does not rewind the arguments already evaluated. Values computed before the suspension point become part of the suspended frame and are not recomputed after resume.
fn combine(cached: int, fetched: int): int {
return cached + fetched
}
fn read_cached(): int {
return 10
}
fn fetch_remote(): int {
handle := spawn { 32 }
return handle.await()
}
fn main(): void {
total := combine(read_cached(), fetch_remote())
println("total ${total}")
}read_cached() runs first. When fetch_remote() suspends, the cached 10 must
survive in the continuation, which is why the result is 42 rather than a
recomputed or lost value.
What is not ordered
Atoll promises nothing where the source does not describe a sequence:
- declaration discovery across files is compile-time work, not runtime order;
MapandSettraversal order is not source order;- task completion order is independent of the order handles were created in;
- the order between evaluating a dynamically computed callee and its arguments is not yet a stable guarantee.
If a callee expression itself does work, bind it before the call:
fn add_tax(amount: int): int {
return amount + amount / 10
}
fn add_fee(amount: int): int {
return amount + 3
}
fn choose(premium: bool): fn(int) -> int {
if premium {
return add_fee
}
return add_tax
}
fn main(): void {
handler := choose(true)
println("${handler(100)}")
}Ordinary calls to a named function or method are unaffected: resolving the callable does no user-visible work.
Optimization boundary
The compiler may fold, inline, eliminate, or reorder work only when observable behavior is unchanged. Observable includes mutations later code can see, calls that may fail or suspend, host I/O and queries, task creation and cancellation, user-defined operators and trait methods, which branch or arm runs, and every order guaranteed above.
Arithmetic on local scalars is free game. Purity is never inferred from a name:
a function called length is optimizable only because its checked declaration
and effect row say so.
See Operators for precedence, Blocks for scope exit, and Match for pattern selection.