Guides · 05
Control flow
Vyi's control flow is expression-oriented: if, switch, and even bare { … }
blocks can produce values, so most "compute one of several things" code needs no
temporary mut. There is a single loop keyword, for, that covers while-loops,
for-each, and infinite loops. Statements are separated by newlines; a semicolon
works as an explicit separator when you want two statements on one line.
if as a statement and an expression
Statement-form if looks like it does everywhere, minus the parentheses. Braces are
always required:
fn classify(n: i32) i32 {
if n < 0 {
return -1
} else if n == 0 {
return 0
} else {
return 1
}
}In value position, if is an expression — each branch's block yields its final
expression, and the whole if is the value of the branch taken:
import { stdout } from "@io"
fn main() !u8 {
con n = 2
con a = if n == 1 { 100 } else if n == 2 { 200 } else { 30 }
con sign = if n >= 0 { "+" } else { "-" }
stdout.writeInt(i64(a)) // prints 200
stdout.write(" ")
stdout.write(sign) // prints +
stdout.write("\n")
return Ok(0)
}An if expression must cover both outcomes — a value has to exist either way — so in
value position else is required; con v = if n > 0 { 1 } is rejected with
"if-expression requires an else branch". The exception is a known condition:
con v = if true { 1 } folds to its live branch at compile time, so no else is
needed (a known-false condition without an else still has no value and is
rejected). Known-condition folding is covered in
generics and known values.
switch
switch matches one subject against a series of arms. Each arm is
Pattern: { body }, and arms over an enum bind payloads directly:
enum Shape {
Dot,
Circle(i32),
Rect(i32, i32),
}
fn (s: Shape) area() i32 {
switch s {
Dot: { return 0 },
Circle(r): { return 3 * r * r },
Rect(w, h): { return w * h },
}
}A switch over an enum is exhaustive — miss a variant and the program does not
compile:
enum Color { Red, Green, Blue }
fn hue(c: Color) i32 {
switch c {
Red: { return 1 },
Green: { return 2 },
} // error: non-exhaustive match; missing Blue
}
fn main() !u8 { return Ok(0) }The catch-all arm is spelled any (not _ — _ is the inference/discard marker, and
not else). With any, a switch can match on plain values — integers, strings —
by equality:
fn code(word: [*]u8) i32 {
switch word {
"stop": { return 0 },
"go": { return 1 },
any: { return 99 },
}
}The comma rule
Arms are comma-delimited like struct fields. A multi-line switch requires a comma after every arm — including the last:
fn label(n: i32) i32 {
switch n {
1: { return 10 }
2: { return 20 },
any: { return 99 },
} // error: missing "," after the first arm
}
fn main() !u8 { return Ok(0) }A single-line switch separates arms with commas but must not have a trailing one:
switch n { 1: 10, 2: 20, any: 99 }.
switch as an expression
In value position each arm yields a value, and single-expression arms don't need braces:
import { stdout } from "@io"
fn main() !u8 {
con n = 2
con label = switch n { 1: 10, 2: 20, any: 99 }
stdout.writeInt(i64(label)) // prints 20
stdout.write("\n")
return Ok(0)
}An arm that diverges (return, break, panic) simply doesn't produce a value, and
that's fine — the switch takes its value from whichever arm actually yields one.
Multi-pattern arms
Comma-separated patterns before the : are alternatives sharing one body:
import { stdout } from "@io"
enum Color { Red, Green, Blue }
fn warm(c: Color) i32 {
switch c {
Red, Green: { return 10 },
Blue: { return 1 },
}
}
fn main() !u8 {
con a = switch 2 { 1, 2: 11, 3: 3, any: 0 } // value alternatives work too
stdout.writeInt(i64(warm(Red))) // prints 10
stdout.write(" ")
stdout.writeInt(i64(a)) // prints 11
stdout.write("\n")
return Ok(0)
}Alternatives cannot bind payloads — a binder would be undefined whenever one of the
other alternatives matched, so Ok(n), Err(_): is rejected.
Guard chains
There is no per-arm guard clause; instead, switch over true and let each arm be a
condition, matched in order:
fn classify(n: i32) i32 {
switch true {
n > 10: { return 100 },
n > 5: { return 50 },
any: { return 0 },
}
}Switching over types
A union value carries which member type it holds; switch typeof u dispatches on it:
fn width(u: i32 | bool) i32 {
switch typeof u {
i32: { return 4 },
bool: { return 1 },
}
}switch patterns go deeper than shown here — nested destructuring, if-let binding
forms, narrowing — see guide 06 — Pattern matching.
Loops
for is the only loop keyword, in three forms.
Bare condition — a while-loop:
import { stdout } from "@io"
fn main() !u8 {
mut n = 5
mut sum = 0
for n > 0 {
sum = sum + n
n = n - 1
}
stdout.writeInt(i64(sum)) // prints 15
stdout.write("\n")
return Ok(0)
}Omit the condition entirely for an infinite loop (exit with break or return):
import { stdout } from "@io"
fn main() !u8 {
mut i = 0
for {
i = i + 1
if i == 5 { break }
}
stdout.writeInt(i64(i)) // prints 5
stdout.write("\n")
return Ok(0)
}for x in iterable — iterates ranges, fixed arrays [N]T, and array pointers
[*]T (a string literal is a [*]u8, so its bytes iterate directly). The loop
variable is a fresh immutable binding each iteration — no con keyword needed:
import { stdout } from "@io"
fn main() !u8 {
mut s = 0
for x in 1..5 { s = s + x } // 1+2+3+4 = 10
con xs = [3]i32[10, 20, 12]
for x in xs { s = s + x } // + 42
for b in "ab" { s = s + i32(b) } // + 97 + 98 (bytes)
stdout.writeInt(i64(s)) // prints 247
stdout.write("\n")
return Ok(0)
}Your own types are iterable too, by implementing the iteration protocol — an
@index(i32) method plus a len() method, which is exactly what the built-in
collections provide (guide 10 — Collections and strings).
break and continue work as you'd expect: break exits the nearest enclosing
loop, continue skips to its next iteration:
import { stdout } from "@io"
fn main() !u8 {
mut s = 0
for x in 0..100 {
if x >= 9 { break }
if x % 2 == 0 { continue }
s = s + x // odd values below 9: 1+3+5+7
}
stdout.writeInt(i64(s)) // prints 16
stdout.write("\n")
return Ok(0)
}Ranges and slicing
a..b is a half-open range (includes a, excludes b); a...b is inclusive:
import { stdout } from "@io"
fn main() !u8 {
mut s = 0
for x in 1..5 { s = s + x } // 1+2+3+4 = 10
for y in 1...5 { s = s + y } // 1+…+5 = 15
stdout.writeInt(i64(s)) // prints 25
stdout.write("\n")
return Ok(0)
}A range is an ordinary value with start and end fields, and indexing an array or
[*]T with one slices it — you get a sub-view over the same storage, no copy.
Either end may be omitted to mean "from the start" / "to the end":
import { stdout } from "@io"
fn main() !u8 {
con s = "hello"
con mid = s[1..3] // "el"
con head = s[..2] // "he"
con tail = s[3..] // "lo"
stdout.write(mid)
stdout.write(" ")
stdout.write(head)
stdout.write(" ")
stdout.write(tail)
stdout.write("\n") // prints el he lo
return Ok(0)
}One asymmetry: for x in 0..10 iterates the range syntactically — a range
stored in a binding is a plain two-field value and is not itself iterable.
Labels and jumps
Prefix a loop with #name to label it; break #name and continue #name then
target that loop from anywhere inside it — the way out of nested loops:
import { stdout } from "@io"
fn main() !u8 {
mut cells = 0
#rows for row in 0..5 {
for col in 0..5 {
if row == 3 { break #rows } // stop everything
if col == row { continue #rows } // next row, skip rest of this one
cells = cells + 1
}
}
stdout.writeInt(i64(cells)) // rows 0,1,2 count col < row → prints 3
stdout.write("\n")
return Ok(0)
}A label may only name an ancestor — you can't jump sideways, and there is no
goto. Standalone blocks can be labeled too, and break #name exits them; but
continue is loop-only — a block is never a continue target.
Block expressions and break with a value
A bare { … } block in value position evaluates to its final expression:
import { stdout } from "@io"
fn main() !u8 {
con x = {
con a = 2
a + 3
}
stdout.writeInt(i64(x)) // prints 5
stdout.write("\n")
return Ok(0)
}Inside a value-position block, break value yields early, and break #name value
yields from a named ancestor block — even from inside a loop nested within it. This is
how you write "search and stop at the first hit" as a single expression:
import { stdout } from "@io"
fn firstSquareAtLeast(n: i32) i32 {
con found = #search {
mut i = 0
for i * i < 1000 {
if i * i >= n { break #search i * i }
i = i + 1
}
-1
}
return found
}
fn main() !u8 {
stdout.writeInt(i64(firstSquareAtLeast(8))) // prints 9
stdout.write("\n")
return Ok(0)
}Only a value-position block can yield a value — a loop never produces one, so break
with a value inside a plain loop is rejected:
fn f() i32 {
for {
break 5 // error: a loop is not a value-position block
}
return 0
}
fn main() !u8 { return Ok(0) }defer, briefly
defer { … } schedules a block to run when the enclosing function exits — on
every exit path, in last-in-first-out order. It keeps cleanup next to the thing
it cleans up:
import { stdout } from "@io"
fn main() !u8 {
defer { stdout.write("world\n") }
stdout.write("hello ")
return Ok(0) // prints "hello world"
}That one idea — registered cleanup that survives early returns, errors, and panics — gets its own guide: 08 — defer, recover, and panic.
See also: reference/expressions, reference/statements.