Skip to content

Control Flow

Blocks, conditionals, the four for-loop forms, patterns, match, transfers, and deferred cleanup.

Updated View as Markdown

Atoll’s control-flow forms are expressions. A block, an if, a match, and a value-bearing loop can each be assigned, returned, or nested wherever their result type is accepted.

Chapter Main question
Blocks How do statement sequences create scopes and values?
Conditionals How do if, else if, and pattern conditions branch?
Loops What do the four for forms do?
Patterns Which shapes can bindings and arms destructure?
Match How are arms selected, guarded, and checked for coverage?
Transfers Where do return, error, ?, break, and continue land?
Defer How is deterministic scope cleanup registered and ordered?

Everything is an expression

enum Job { Queued, Running(int), Done { code: int } }

fn describe(job: Job, count: int): string {
    state := match job {
        Queued => "queued"
        Running(percent) if percent > 90 => "almost done"
        Running(percent) => "running at ${percent}%"
        Done { code: 0 } => "succeeded"
        Done { code } => "failed with ${code}"
    }

    label := if count == 0 {
        "empty"
    } else if count == 1 {
        "single"
    } else {
        "batch of ${count}"
    }

    return "${label}: ${state}"
}

Each branch contributes to the expression’s type. A branch that transfers control with return, error, break, or continue has no normal value, so it never forces the other branches to invent one:

error ConfigError { Missing }

fn read(key: string): string ! ConfigError {
    if key.is_empty() { error Missing }
    return key
}

fn width(key: string): int ! ConfigError {
    value := if key.is_empty() {
        return 0
    } else {
        read(key)?
    }
    return value.len()
}

Loops

for is the only loop keyword — there is no while and no loop. Four forms cover every shape:

fn shapes(values: []int, scores: Map[string, int]): int {
    mut total := 0

    // iterate a collection, a range, or a map's entries
    for value in values { total += value }
    for index in 0..10 { total += index }
    for name, score in scores { total += score + name.len() }

    // loop while a condition holds
    mut remaining := 3
    for remaining > 0 { remaining -= 1 }

    // loop until the head stops matching
    mut cursor := values.get(0)
    for Some(value) := cursor {
        total += value
        cursor = None
    }

    // loop until the body transfers out
    for { break }

    return total
}

A bare for { } can produce a value through break value; the other forms can also finish through their head, so they type as void.

Transfer map

Form Target Carries
return value enclosing function or closure success value
error Variant enclosing fallible function or closure error value
postfix ? enclosing optional or fallible function None or a compatible error
break value nearest or labeled loop optional loop result
continue nearest or labeled loop nothing
pattern or guard failure next arm, or the loop-head decision nothing user-visible

Before reaching its target, a transfer leaves the intervening lexical scopes and runs their defers and managed destruction. A transfer is never a jump that bypasses ownership or cleanup.

Composition

A realistic path combines several of these at once.

error LoadError { NotFound { id: int }, Corrupt }

struct User { id: int, name: string, active: bool }

fn load_user(id: int): User ! LoadError {
    if id < 0 { error NotFound { id: id } }
    if id == 13 { error Corrupt }
    return User { id: id, name: "user-${id}", active: id % 2 == 0 }
}

fn note(message: string): void { println(message) }

fn first_active(ids: []int): User? ! LoadError {
    defer note("lookup finished")

    for id in ids {
        user := load_user(id)?
        if !user.active {
            continue
        }
        return Some(user)
    }

    return None
}

Read each transfer by its target: the iterator controls repetition, ? can leave the whole function with a LoadError, continue advances only the loop, return produces the optional success, and the defer runs on all three exits.

Reading an unfamiliar block

Trace four things separately, in this order:

  1. scope — which names begin and end at each brace;
  2. value — which tail expression or break value reaches the enclosing expression;
  3. transfer — which function, loop, or arm receives each early exit;
  4. cleanup — which defers and managed values leave scope on that path.

Then check that every value-producing path joins to one accepted type. Applied to the example above: user is scoped to one iteration; the function’s value comes from return Some(user) or the final return None; ? targets the function boundary while continue targets the loop; and the defer belongs to the function scope, so it runs once on every one of those exits.

This is also what catches the three common mistakes — assuming continue returns from the function, forgetting that ? exits through the error boundary, and registering cleanup after the operation that can fail.

For how branch types are joined, continue to Inference. For cleanup under cancellation, continue to Cancellation.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close