A binding gives a value a local name. Atoll spells inference with := and an
explicit type with : plus =. Bindings are immutable unless they begin with
mut.
fn f(): void {
name := "Ada" // inferred, immutable
attempts: int = 3 // annotated, immutable
mut total := 0 // inferred, mutable
total += attempts
println("$name ${total}")
}The six forms
| Form | Type | Initial value | Reassignable |
|---|---|---|---|
name := value |
inferred | required | no |
mut name := value |
inferred | required | yes |
name: T = value |
T |
explicit | no |
mut name: T = value |
T |
explicit | yes |
name: T |
T |
type default | no |
mut name: T |
T |
type default | yes |
fn f(): int {
count := 0
label: string = "worker"
mut total: int = 0
mut seen: []int
total = total + count
seen.add(total)
println(label)
return seen.len()
}:= always introduces a new binding. = either initializes an annotated
binding or assigns to an existing mutable place. Keeping the two spellings
distinct is what lets a reader tell, at a glance, whether a line creates a name
or overwrites one.
Assigning to a name that was never declared is an unresolved-name error:
fn f(): void {
count = 0
println("${count}")
}And an annotated binding must use =, not :=:
fn f(): void {
count: int := 0
println("${count}")
}The diagnostic is ATOLL1032: Use = after a type annotation, not :=.
Immutable by default
An immutable binding cannot be reassigned.
fn f(): void {
name := "Ada"
name = "Grace"
println(name)
}Immutability is a property of the binding, and it also blocks mutation through the binding. A struct field cannot be written via an immutable local even though the field itself carries no modifier:
struct Counter { n: int }
fn f(): int {
c := Counter { n: 0 }
c.n = 1
return c.n
}Both diagnostics are ATOLL2001. Adding mut to the binding fixes both:
struct Counter { n: int }
fn f(): int {
mut c := Counter { n: 0 }
c.n = 1
c.n += 1
return c.n
}Mutable bindings
mut marks a local you intend to update — accumulators, cursors, and buffers.
fn sum(values: []int): int {
mut total := 0
for value in values {
total += value
}
return total
}
fn longest(words: []string): string {
mut best := ""
for word in words {
if word.len() > best.len() {
best = word
}
}
return best
}Parameters take the same modifier:
fn increment(mut value: int): int {
value += 1
return value
}A for binding can be mut too, and then writes go back into the source
collection:
fn doubled(): int {
mut xs := [1, 2, 3]
for mut v in xs {
v = v * 2
}
mut total := 0
for v in xs {
total += v
}
return total
}Inference and annotations
An initializer usually says everything the compiler needs:
fn f(): void {
enabled := true
port := 8080
message := "ready"
coordinates := (12, 34)
lookup := Map.new[string, int]()
println("${enabled} ${port} ${message} ${coordinates.0} ${lookup.len()}")
}Annotate when the value alone cannot pick the type, or when the type is part of the contract you want a reader to see:
fn f(): void {
port: u16 = 8080 // narrower than the default `int`
names: []string = [] // nothing to infer the element from
result: int? = None // nothing to infer the payload from
ratio: f32 = 0.5 // narrower than the default `float`
println("${port} ${names.len()} ${result ?? 0} ${ratio}")
}Scalar types are lowercase (int, float, bool, char, byte, string);
composite and user-defined types are PascalCase (List, Map, Option, and
your own structs and enums).
Declare now, assign later
An annotated binding may omit its initializer. The type supplies a default, and the binding is ready to be assigned in whichever branch computes it.
fn pick(ready: bool): int {
mut result: int
if ready {
result = 1
} else {
result = 2
}
return result
}| Type shape | Default |
|---|---|
| integer and floating-point types | numeric zero |
bool |
false |
char |
'\0' |
string |
empty string |
List, Map, Set |
empty collection |
fixed array [N]T |
N copies of T’s default |
| struct | field-by-field defaults |
The defaults are real, so a collection declared this way is usable immediately:
struct Point { x: int, y: int }
fn f(): void {
mut names: []string
mut counts: Map[string, int]
mut origin: Point
names.add("first")
counts.put("first", 1)
println("${names.len()} ${counts.len()} ${origin.x}")
}Not every type has an unambiguous default — an enum needs a chosen variant and a resource handle needs a real acquisition. Prefer an explicit initializer when the default would hide a decision that matters.
This form requires the annotation. An inferred binding has nothing to infer
from, so value := with no right-hand side is not a form.
Destructuring
A pattern on the left introduces several names at once.
fn divide(n: int, d: int): (int, int) {
return (n / d, n % d)
}
fn f(): void {
(quotient, remainder) := divide(17, 5)
println("${quotient} rem ${remainder}")
}The outer parentheses are optional for a tuple:
fn f(): void {
first, second := (1, 2)
println("${first} ${second}")
}mut applies to every name the pattern introduces:
fn f(): int {
mut (left, right) := (10, 20)
left += 1
right += 1
return left + right
}Struct patterns work the same way, and they nest:
struct Point { x: int, y: int }
struct Line { a: Point, b: Point }
fn f(): int {
Line { a: Point { x, y }, b } := Line {
a: Point { x: 1, y: 2 },
b: Point { x: 3, y: 4 },
}
return x + y + b.x
}Destructuring in a binding is only legal for an irrefutable pattern — one
that always matches. A list pattern, an enum variant, or an Option variant can
fail, so those go through match or the else form below. The complete pattern
language is in Patterns.
Refutable bindings with else
When a pattern might not match, attach an else block. The block must diverge —
return, error, break, or continue.
fn f(maybe: int?): int {
Some(value) := maybe else {
return 0
}
return value * 2
}The names the pattern introduces are in scope for the rest of the block, so this
reads as a guard clause rather than a nested if:
error AuthError { Denied }
fn authenticate(request: int): (int, string) ! AuthError {
if request < 0 { error Denied }
return (request, "token")
}
fn handle(request: int): string {
Ok((user, token)) := authenticate(request) else {
return "unauthorized"
}
return "user ${user} with ${token}"
}The initializer runs exactly once, before matching. On success every pattern
name is initialized together; on failure none of them enter scope and the else
branch must leave. Use match when both outcomes should continue with a value —
binding-else is for the case whose failure path cannot fall through.
Shadowing and scope
A later declaration may reuse an earlier name. Shadowing creates a new binding; the old one is untouched, and the type may change.
fn f(): int {
value := "42"
value := value.to_int() ?? 0
return value + 1
}The initializer of a shadowing declaration sees the previous binding — which is what makes the “clean up in stages” idiom safe:
fn normalize(raw: string): string {
input := raw.trim()
input := input.to_lower_case()
input := input.replace(" ", "_")
return input
}Each binding is visible from its declaration to the end of its enclosing block. Atoll does not hoist locals, so a name is unusable above its declaration, and an inner block’s shadow disappears at the closing brace:
fn f(): int {
x := 1
{
x := 2
println("inner ${x}")
}
return x // still 1
}Bindings versus places
Assignment needs two things: a mutable access path, and a type-compatible value.
A mut local is a place; so is a field or index reached through one.
struct Point { x: int, y: int }
fn f(): int {
mut point := Point { x: 1, y: 2 }
mut grid := [10, 20, 30]
point.x = 3
point.y += 4
grid[0] = 99
return point.x + point.y + (grid[0] ?? 0)
}Binding immutability is not a deep freeze. When a type has reference semantics,
an immutable binding still names shared storage — what mut controls is
whether this name can be repointed and whether writes may travel through it.
See Values for copying and aliasing.
Discarding a value
_ is a wildcard that accepts a value without naming it. Use it when a call is
made for its effect and the result is genuinely unwanted.
fn compute(): int { return 41 }
fn f(): void {
_ := compute()
println("done")
}Discarding suppresses nothing else: effects still happen, and a fallible call still has to be handled.
Naming
Locals and parameters use snake_case.
fn reserve(seat_count: int, hold_minutes: int): bool {
max_hold := 30
return seat_count > 0 && hold_minutes <= max_hold
}See the naming conventions for the complete table.
Composed example
struct Reading {
sensor: string
value: float
}
error FeedError { Empty }
fn parse_reading(line: string): Reading ! FeedError {
parts := line.split(":")
if parts.len() < 2 { error Empty }
sensor := parts[0] ?? ""
value := (parts[1] ?? "").to_float() ?? 0.0
return Reading { sensor, value }
}
fn hottest(lines: []string): string {
mut best: string
mut best_value := 0.0
mut parsed := 0
for line in lines {
Ok(reading) := parse_reading(line) else {
continue
}
parsed += 1
if reading.value > best_value {
best = reading.sensor
best_value = reading.value
}
}
if parsed == 0 {
return "no readings"
}
return "${best} at ${best_value} (${parsed} parsed)"
}
fn main(): void {
println(hottest(["cpu:71.5", "malformed", "gpu:83.25", "mem:44.0"]))
}