8. Control flow
Control flow is structured. Base has if, while, for, match, labeled break and continue, return, defer, errdefer, with (§12.3), and inline assembly. It has no exceptions and no goto (§8.6).
8.1 if and if let#
if temperature > 30.0:
fan.start()
elif temperature < 10.0:
heater.start()
else:
climate.hold()
if let user = cache.find(name):
greet(user)
else:
log("not found")Conditions are bool. if let name = optional: binds the present value inside the branch only; it may be followed by elif and else.
8.2 while and while let#
while cursor.more():
parse_one(cursor)
while let token = lexer.next():
consume(token)while let binds on each iteration while the expression is present. It is the replacement for C's assignment-in-condition idiom. There is no do while; write the first step before the loop.
8.3 for, ranges, and iteration#
for item in items: render(item)
for index in 0..<image.width: draw_column(index)
for value: u32 in 1..=4: try ring.push(value)
for entity in &entities: entity.position += entity.velocity
for (index, byte) in data.indexed(): table[byte] = index
for character in text: count += 1start..<end is half-open, start..=end closed; both are integer ranges that ascend by one. When the bounds are untyped literals or one bound is untyped, the range's type is the typed bound's, and a range of two untyped literals is i64, as an untyped literal alone is (§4.2). The loop variable may carry a type, for value: u32 in 1..=4, which becomes the range's type. Descending and stepped traversal are library iterators. The loop variable is a let scoped to the body.
for x in items over a span or array yields each element by value. for x in &items yields a pointer to each element, T* for a mutable span or array and const T* otherwise, so the body may modify elements in place. items.indexed() yields (usize, T) pairs. for character in text yields Unicode scalars.
for consumes the Iterable protocol (§14.4): it calls source.iterator() once, stores the resulting value iterator in a hidden local of its concrete type, and calls its next() -> T? until none. It never forms an interface view of the iterator, so nothing dangles and nothing allocates. Spans, arrays, ranges, and str are iterable; a user type implements Iterable with a value iterator.
8.4 match#
match command:
.open(path): open_document(path)
.save(path) if path.length == 0: error(empty_path, "no path")
.save(path):
validate(path)
save_document(path)
.quit: returnPatterns are closed: enum cases with payload bindings, where _ stands for a payload that is not used; .some(value) and .none; boolean, integer, character, and string literals; lower..<upper and lower..=upper literal ranges; comma-separated alternatives that share one body; and _. A pattern may carry a guard, pattern if condition:, evaluated after the pattern binds; a guarded arm does not count toward exhaustiveness.
Every match is exhaustive, and the compiler names missing cases. A match on an integer, char, str, or integer-backed enum requires a _ arm unless every value is covered. Duplicate and unreachable patterns are errors. Alternatives that bind payloads must bind the same names with the same types. There is no fallthrough and no nested destructuring. The statement form uses : per arm; the expression form (§7.8) uses =>; they do not mix.
Why. C's switch has fallthrough, no exhaustiveness, and no payloads. Exhaustive match over a payload enum is the largest correctness gain in this document over C, and it costs the same jump table. Guards are admitted because state machines and tokenisers need them and the alternative is one nesting level per condition; full Luce refused them, and the difference is recorded in §23.
8.5 break, continue, and labels#
rows: for y in 0..<height:
for x in 0..<width:
if pixel(x, y) == target:
found = (x, y)
break rowsbreak and continue act on the innermost loop, or on the named loop when a label is given. A label is name: immediately before while or for; it lives in its own namespace and scopes over the loop body. defer runs for every scope left, innermost first.
Why labels and not more. Leaving a nested loop is the most common use of goto in C that defer does not already cover, and a labeled break is a structured jump: it leaves scopes and never enters one, which the compiler's structured intermediate representation expresses with the branch it already has. Full Luce refuses labels and asks for a helper function; Base admits them because extracting a function in order to leave a loop is a cost C code never pays.
8.6 No goto#
Base has no goto, and the word is an ordinary name. Should a later revision admit a jump, it follows Go's rules: targets within the same function; a jump may leave scopes, running their deferred calls, but may not enter a scope it is not already inside; it may not skip a binding's declaration; no computed targets; no jumps into or out of match arms.
Why. The compiler's intermediate representation is structured, with blocks, loops, and branches to an enclosing region, and it was built that way so that a backend for a structured target never has to reconstruct structure from a jump graph. An unrestricted goto would force that reconstruction into the compiler. Every use of goto in C is one of: retry (a while), error exit (defer and errdefer), leaving nested loops (labels), or a hand-written state machine. A state machine is written while true: match state: at the cost of one branch per transition; Zig, Odin, C3, and Hare have no goto and their users write it this way. A jump can be admitted later, under a word of its own, if interpreter-style Base code shows the cost is justified; reserving the word in the meantime would only keep it from programs.
8.7 return#
return value exits the function. A unit function uses bare return or reaches its end. Every path of a non-unit function returns or terminates with error, trap, or a never call. There is no implicit return of a final expression.
Statements after one that always leaves (a return, break, continue, a never call, or an if or match whose every branch leaves) never run. They are still checked, names and types alike, and then dropped; -W warns that they are unreachable. Only the code after a test that folds on the target (if platform.windows:) is left unchecked, since it is another target's.
8.8 defer and errdefer#
import luce_std.files
let file = try files.open(path)
defer file.close() catch failure:
log(f"close failed: {failure.message}")
let buffer = try new u8[size]
errdefer free(buffer)
try fill(buffer)
return bufferdefer statement or defer: with a block registers that code for the end of the current lexical scope, run last-in-first-out on normal exit, return, break, continue, and error propagation. The statement is a simple one: a call, an assignment, or free; anything compound (if, a loop, match) is written in a block:
defer:
for i in 0..<count:
free(items[i])
free(items)Nothing is captured at registration: the deferred code is compiled where it is written and runs as the scope is left, reading its names as they are then, as Zig's does. A return evaluates its value before the deferred code runs, so a deferred assignment to a local does not change what is returned. A deferred block is a scope of its own, with bindings, loops, handlers, and defers of its own; those run when the deferred block ends.
Nothing leaves deferred code: it may not return, let a failure escape (try, error(...); a fallible call takes a catch handler that recovers), or break or continue to a loop outside it, so it can never replace an in-flight error.
errdefer statement and errdefer: register code in the current scope that runs only when control leaves that scope because an error is propagating out of it, from try or from error(...), in order with the ordinary deferred code of the same scope. It is discarded when the scope exits normally. A catch that recovers inside the scope never triggers it, because no error leaves. It is a compile error in a non-fallible function.
Neither runs on a trap, process abort, or power loss.
Why. defer is C's goto cleanup without the label. errdefer is the half of it that runs only on the failure path, which is what the partial-acquisition pattern (allocate A, allocate B, fail, free A) needs and what C spells with a sequence of labels. Zig has both, and its users rely on them. Reading names at exit, not at registration, is Zig's rule too: cleanup sees the state it cleans up, and a block needs no helper function.
8.9 Inline assembly#
func write(fd: i32, data: const u8[]) -> isize:
var result: isize
asm x86_64 (in("rax") 1, in("rdi") fd, in("rsi") data.data, in("rdx") data.length,
out("rax") result, out("rcx") _, out("r11") _, options(nostack)):
syscall
asm arm64 (in("x8") 64, in("x0") fd, in("x1") data.data, in("x2") data.length,
out("x0") result, options(nostack)):
svc #0
return result
func ticks() -> u64:
var low: u32
var high: u32
asm x86_64 (out("rax") low, out("rdx") high):
rdtsc
asm arm64 (out("x0") low, out("x1") high):
mrs x0, cntvct_el0
lsr x1, x0, #32
return (u64(high) << 32) | lowasm ARCH (operands):opens a suite whose lines the lexer captures raw: no tokens, no comment stripping, because#is an immediate prefix in ARM64 assembly. The suite's indentation baseline is removed; the suite ends at the dedent.ARCHis a target architecture name (§19.5). A function may carry one block per architecture in sequence; the compiler emits the one matching the build target and rejects a build for a target with no block. There is no fallback. The intermediate representation carries every variant and the backend selects, so no target name enters the shared representation.- An operand is
in(place) expression,out(place) lvalue, orinout(place) lvalue, whereplaceis a register name in quotes orregfor any register the compiler chooses.out(place) _declares a register the block destroys.{name}in the text stands for the register the compiler chose forregoperands namedname.options(...)may statenostack(the block does not use the stack),nomem(the block reads and writes no memory other than its operands),pure(the block may be removed if its outputs are unused), andflags(the block preserves the condition flags). Operands are scalars and pointers. - Every block without
purehas side effects: it is never elided, duplicated, or reordered with another block or avolatileaccess. A block that declaresnomemmay be moved across ordinary memory operations. - The text is the target assembler's, passed as written: GNU AT&T syntax on x86-64, standard syntax on ARM64. A block may begin with
.intel_syntax noprefixand end with.att_syntax prefix. naked func(§9.8) declares a function whose body is exactly oneasmblock per architecture, with no prologue or epilogue generated: the block owns the stack and registers, which is how an interrupt entry, a context switch, and a_startare written.- A module-level
asm ARCH:block with no operand list places raw assembly at file scope: symbols, sections, data. - The compiler's reference interpreter rejects programs containing
asm, so such programs are proven by the compiled backends only.
Why this syntax. GCC's constraint strings encode operand kinds and registers in single characters; Zig adopted them. Named operands with the register in quotes, as in Rust, are easier to read and let the block name the registers a syscall or a calling convention requires. The per-architecture block replaces #ifdef __x86_64__ without a preprocessor.