8. Control flow
Control flow is structured. Base has if, while, for, match, labeled break and continue, return, defer, errdefer, and inline assembly. It has no exceptions and, in this revision, no goto.
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.
8.2 while and while let#
while cursor.has_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 and ranges#
for item in items: render(item)
for index in 0..<image.width: draw_column(index)
for character in text: count += 1
start..<end is half-open, start..=end closed; both are integer ranges that ascend by one. Descending and stepped traversal are library iterators. The loop variable is a let scoped to the body.
for consumes the Iterable[T] protocol (§14.3): it calls source.iterator() once, stores the resulting value iterator in a hidden local of its concrete type, and calls its mutating next() -> T? until none. It never forms an interface view of the iterator, so nothing dangles and nothing allocates. The built-in iterables: T[] and const T[] (elements by value), T[N], ranges, and str (Unicode scalars). A user type implements Iterable[T] with a value iterator.
8.4 match statement#
match command:
.open(path): open_document(path)
.save(path):
validate(path)
save_document(path)
.quit: return
Patterns are closed: enum cases with payload bindings; .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 _. Every match is exhaustive, and the compiler names missing cases. Duplicate and unreachable patterns are errors. Alternatives that bind payloads must bind the same names with the same types. There is no fallthrough, no guard, and no nested destructuring; compute a predicate before the match or use an if inside an arm. 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 single largest correctness gain Base offers over C, and it costs the same jump table.
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 rows
break 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 is exactly what the compiler's structured intermediate representation can express with the branch it already has. Full Luce refuses labels and asks for a helper function; Base admits them because a C programmer will not accept the detour.
8.6 goto#
goto is reserved and not implemented. If a future revision admits it, 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 the WebAssembly backend 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, which while true: match state: expresses with one extra branch per transition and which is what Zig, Odin, C3, and Hare users write, since none of them has goto. The feature is reserved so that the door stays open if interpreter-shaped Base code proves the cost.
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.
8.8 defer and errdefer#
let file = try files.open(path)
defer file.close()
let buffer = try memory.array[u8](allocator, size)
errdefer memory.free_array(allocator, buffer)
try fill(buffer)
return buffer
defer call registers a unit call for the end of the current lexical scope, run last-in-first-out on normal exit, return, break, continue, and error propagation. The receiver and arguments are captured at registration. A deferred call may not return, recover, or replace an in-flight error.
errdefer call registers a call 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 defer calls 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 ladder of labels. Zig proved the pair.
8.9 Inline assembly#
func cycle_counter() -> u64:
var ticks: u64
asm x86_64 (out reg ticks, clobber rax, rdx):
rdtsc
shlq $32, %rdx
orq %rdx, %rax
movq %rax, {ticks}
asm arm64 (out reg ticks):
mrs {ticks}, cntvct_el0
return ticks
asm 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 compiler target name. 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.- Operands have a kind:
in reg,out reg,inout reg,in mem,out mem, followed by a name. A register operand substitutes{name}with a register holding the value; a memory operand substitutes an addressing form for the value's storage.clobberlists registers ormemorythe block destroys; every register written that is not an output must be listed. Operands are scalars and pointers. - Every block has side effects: it is never elided, duplicated, or reordered with another block or a
volatileaccess. - 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. syscallon x86-64 clobbersrcxandr11and must say so.- A module-level
asm ARCH:block with no operand list places raw assembly at file scope:_start, sections, data. - The WebAssembly target rejects
asm. The compiler's reference interpreter rejects programs containing it, so such programs are proven by the compiled backends only.
Why this shape. GCC's constraint strings are the least readable syntax in C, Zig inherited them and its users complain, and Rust's named operands with in/out kinds are what every newer design converged on. The per-architecture block replaces #ifdef __x86_64__ without a preprocessor.