sysl

Statements and control flow

Blocks, bindings, every loop and branch form, and what each one yields — because in sysl most of them yield something.

if, match, while, loop and for are expressions. Each yields a value, so each can initialize a binding, be a function’s body, or feed a branch of another. In statement position the value is simply unused and the same forms read as ordinary control flow.

That removes the statement/expression split that forces C’s temporary-and-reassign dance (int label; if (…) label = …; else label = …;), and it makes one rule — the last expression is the value — uniform across functions, branches, arms and loops.

Blocks

A block is an indented run of statements, and its value is its trailing expression. There are no braces and no statement terminator: a newline ends a statement, and indentation opens and closes a block. See lexical structure for the layout rules and how a long expression is continued across lines.

Where a block’s own value is unused, the block has none — its type is unit whatever the last line yields — and that propagates inward, into the branches of an if, the arms of a match, and the else of a loop. This is what lets branches merely do things without being made to agree on a value nobody asked for:

var full = false
var len = 0
var wrapped = true

if wrapped
    full = true
else
    len += 1

print(full, len)
true 0

Without that rule, full = true (a bool) and len += 1 (an int) would be two branches with nothing to meet at. Statement position starts at a statement, at a loop body, and at the body of a function that returns nothing.

A block stands wherever a construct puts one, and a binding’s = is one of those places — see the value may be an indented block. The rules there are the ones above, unchanged: the trailing expression is the value, and what the block binds goes out of scope with it.

The statement forms

Everything that can stand as a statement:

form
an expressionincluding every control-flow form, since they are expressions
var / val / consta binding — see declarations
ref name = placea second name for one place — see memory
a, b = x, ya multiple assignment, which is not an expression
return, break, continuetransfers of control
become f(…)return with the frame replaced rather than added to — see declarations
defer stmtwhat to run on the way out of this block
require / ensurea contract clause — see errors and contracts
import, impl, and the declarationsa declaration is a statement, so a function may be nested in one

if

if cond with an indented block, elif for each further test, and an optional else. The inline form puts then between the condition and a one-line body.

label(n: int) -> string = if n % 2 == 0 then "even" else "odd"

grade(n: int) -> string
    if n >= 90
        "A"
    elif n >= 80
        "B"
    else
        "C"

print(label(4), label(7), grade(95), grade(85), grade(70))
even odd A B C

elif is sugar: each one nests into the else branch of the one before, so no separate node exists and no rule about it has to be learned.

An if used for a value needs an else. A missing one leaves the open branch at unit, which is a diagnostic wherever a value was wanted. In statement position a missing else is ordinary.

An if may be closed with end if, which is optional everywhere and reads well when the block is long enough that its extent has stopped being obvious:

var n = 3

if n > 0
    print("positive")
end if
positive

end is a soft word — an ordinary identifier everywhere except immediately before a construct keyword — so end remains usable as a name. The same marker closes while, for, loop and match here, and a struct, an enum and a block-bodied val or var on declarations.

A marker is checked where it is written, so one naming another construct is refused, naming both:

var n = 3

while n > 0
    n -= 1
end for
'end for' cannot close the 'while' above it — its marker is 'end while'

A marker closes an indented block, so one after a one-line form is refused — a one-liner has no block for it to close, and the marker would only be clutter. A construct whose last part is written on one line ends on a one-line form too, whatever came before it:

var n = 3

if n > 0 then print("positive")
end if
'end if' has no block to close — the 'if' above it ends on a one-line form, which takes no marker

Where two constructs open on one line, their blocks close together, and the marker is the outer one’s to write — the match below ends the if‘s body as well as its own arms, so end if closes both:

report(wide: bool, n: int)
    if wide then n match
        0 -> print("none")
        _ -> print("some")
    end if

report(true, 0)
report(true, 5)
none
some

A match takes end match under its arms in the same way:

tier(n: int) -> string
    val name = n match
        0 -> "bronze"
        1 -> "silver"
        _ -> "gold"
    end match

    name

print(tier(1))
silver

match

value match, then an indented list of pattern -> body arms. The keyword goes after the value, as in Scala and for Scala’s reason: a match is a transformation of the thing to its left, so writing it there is what lets one feed another.

enum Tier
    Bronze
    Silver
    Gold

fee(t: Tier) -> int
    t match
        Bronze -> 0
        Silver -> 10
        Gold   -> 25

print(fee(Bronze), fee(Silver), fee(Gold))
0 10 25

A match used for a value must be exhaustive; one written for effect need not be, which follows the same rule the block-value discard does. Everything about the left side of an arm — the pattern grammar, guards, alternatives, bindings — is on patterns and matching.

A branch with no type of its own takes its sibling’s

The branches of an if and the arms of a match must agree on one type; there is no subtyping among concrete types for them to widen towards. A bare literal is the exception, because it has no type of its own to be overridden. It takes the type of the branch beside it, exactly as it takes the type of the operand beside it in n + 1 and of the other end in 0..<xs.len.

width(n: usize) -> usize = n

at_least_one(n: usize) -> usize = width(if n == 0 then 1 else n)
gap(a: usize, b: usize) -> usize = width(if a > b then a - b else 0)

print(at_least_one(0), gap(9, 2), gap(2, 9))
1 7 0

width is what makes those three real: it takes a usize, so an if that had come out as int would not compile at all. Without the rule the 1 and the 0 fall to int, each pair is refused for a difference nobody wrote, and the fix is an annotation on the declaration restating a width the other branch already gives.

A match is the same rule over as many alternatives as the form has. The arms that know what they are settle the type and the bare literals take it, wherever they are written — the arm that knows may be the last one.

width(n: usize) -> usize = n

step(n: usize) -> usize
    val w = n match
        0 -> 1
        1 -> 2
        else n

    width(w)

print(step(0), step(1), step(8))
1 2 8

An arm or a branch that does not finish settles nothing, which is the same standing-aside never already does when the type of the whole form is worked out. It does not stop its sibling taking a type either: if c then 0 else continue is a 0 of whatever the position wants, exactly as it would be with a 1 in place of the continue. A branch ending in return, break, continue or a call of a function declared -> never (panic, or one of the program’s own) stands aside like this.

first_even(xs: []usize) -> usize
    for x in xs
        val w = (x % 2) match
            0 -> x
            _ -> if x > 100 then 0 else continue

        return w

    1

print(first_even([3, 5, 8]), first_even([7, 101]), first_even([1]))
8 0 1

Two branches that each know what they are still have to agree. That is the half the rule protects: neither is silently decided by the other, because both arrived with an opinion.

f(a: int, b: usize)
    val w = if a == 0 then a else b

    print(w)

f(0, 1)
if branches have different types: int and usize

The rule reaches inside a construction, part by part. A tuple or an array literal written out of literals has no type of its own either, so (0, 0) beside a (u64, u64) is a pair of u64s, however deeply the tuples nest. A construction that knows only some of its parts — (n, 0), Some(0) — is offered the type its sibling settled, and two such branches that each know a different part meet at the type that has both:

pair(ok: bool) -> Result[(u64, u64), int] = if ok then Ok((3, 4)) else Err(1)

ends(ok: bool) -> u64
    val (r, w) = pair(ok) match
        Ok(both) -> both
        Err(_) -> (0, 0)

    r + w

joined(n: u8, m: u16) -> u16
    val t = if n < 3 then (n, 1) else (0, m)

    u16(t.0) + t.1

print(ends(true), ends(false), joined(1, 5), joined(7, 5))
7 0 2 5

The branches of an elif chain are siblings, so they meet at one type all together rather than two at a time — the four branches below are four pairs of usize:

split(n: usize) -> usize
    val (a, b) = if n % 3 == 0 then (n, 0) elif n % 2 == 0 then (0, n) elif n % 3 == 2 then (n, 1) else (n, 2)

    a * 10 + b

print(split(3), split(4), split(5), split(7))
30 4 51 72

A part that cannot be what the sibling says keeps its own type, and the branches are refused as two that disagree:

f(n: u64)
    val t = n match
        1 -> (n, n)
        _ -> (1, "a")

    print(t.0)

f(1)
match arms have different types: (ulong, ulong) and (ulong, string)

A branch with nothing to go on is tried, not refused. A nullary generic call has no argument to fix its type parameter from, so a branch like empty() cannot be read on its own — it is held over until its sibling has settled something, in either order and through an elif chain or a match‘s arms:

empty[T]() -> Option[T] = None

pick(c: bool) -> Option[real]
    val v = if c then Some(1.0) else empty()
    v

print(pick(true))
print(pick(false))
Some(1)
None

Where neither branch can be read alone, the refusal above is unchanged.

Branches that each settle a different part of one generic enum’s type meet at the whole of it. Err(E.Bad) says what a Result fails with and nothing about what it succeeds with; Ok(n) says the opposite. Neither stands alone, and together they are one Result[int, E], in either order, across a match‘s arms or an elif chain:

enum E
    Bad

pick(n: int) -> Result[int, E]
    val r = n match
        0 -> Err(E.Bad)
        _ -> Ok(n)

    r

check(n: int) -> Result[int, E]
    val r = if n < 0 then Ok(-n) elif n == 0 then Err(E.Bad) else Ok(n)
    r

print(pick(0).is_err(), pick(4).unwrap(), check(-3).unwrap(), check(0).is_err())
true 4 3 true

Each parameter takes the first branch that settles it, and a later branch settling it differently is refused at its own value. A parameter no branch settles is still a question nobody answered:

enum E
    Bad
    Worse

f(c: bool)
    val r = if c then Err(E.Bad) else Err(E.Worse)
    print(r.is_err())

f(true)
cannot infer the type argument 'T' of 'Err' here — annotate the expected type

Loops

Five forms, and each is an expression.

formwhen
while condtest before each iteration
do … while condtest after each iteration, so the body runs at least once
loopno test at all; something inside leaves it
for x in seqwalk a range, an array, a slice, an Iterate, or a container through its Walk
for init; cond; stepa stride, a descent, a compound test, several variables

A fourth thing may follow for, and it is not a loop. for const i in 0..<A.len is unrolled while the program is compiled — the compiler emits one copy of the body per value and there is no loop left at run time, which is why it takes no label, no break and no else. It exists to walk a type pack, where each iteration has a different type and so genuinely has to be a different copy. Everything on this page is about the five forms above; for const is documented with generics, where its range rule and its restrictions are.

for x in

The ordinary walk, and preferred wherever it fits: it names one thing instead of three and cannot get any of them wrong.

var xs = [4, 5, 6]

for x in xs
    print(x)

for i in 0..<2
    print(i)
4
5
6
0
1

A container is walked through Walk. Where seq‘s type implements the library’s Walk and is not itself a cursor, the loop is for x in seq.walk(): walk is called once, before the first turn, and what it answers is walked by the rules above — so a Buf, whose walk answers its view, is walked by index as a slice is, and a Map through its cursor, a (key, value) pair apiece. A type implementing Iterate is walked by next even if it implements Walk too.

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(4)
b.push(5)

for x in b
    print(x)
4
5

The loop variable may be a pattern

A for binds what a binding binds, so an element that is a tuple or a struct may be taken apart in the header instead of inside the body. The pattern grammar is the same one, whole: nested as deep as the value goes, a part skipped with _, a struct named by its fields, and the element kept beside its parts with n @.

struct Point
    x: int
    y: int

for (name, n) in [("a", 1), ("b", 2)]
    print(name, n)

for ((a, b), c) in [((1, 2), 3)]
    print(a, b, c)

for Point{x, y} in [Point(3, 4)]
    print(x * y)

for whole @ (first, _) in [(5, 6)]
    print(first, whole.1)
a 1
b 2
1 2 3
12
5 6

The parts are bound the way the loop variable itself is, so a part may be assigned to. What the pattern may not be is a choice among an enum’s variants: a loop has no other arm to take when the element turns out to have a different shape, which is the same rule a binding follows.

The parts go in parentheses, and the comma spelling is not a second way to write it — for a, b is already the beginning of a three-clause header whose first clause assigns to two names, and the two would not tell themselves apart until the in:

for k, v in [(1, 2)]
    print(k, v)
a 'for' names one thing, and a pattern is what takes it apart — write 'for (k, v) in …'

break carries a value, and else supplies the other one

break expr leaves the nearest loop and makes expr the loop’s value. An optional else block, written after the body as in Python, runs when the loop finishes normally — the condition turned false or the sequence ran out, with no break — and its trailing expression is the loop’s value on that path.

var mixed = [1, 3, 4, 5]
var odds = [1, 3, 5]

var found = for x in mixed
    if x % 2 == 0 then break x
else -1

var none = for x in odds
    if x % 2 == 0 then break x
else -1

print(found, none)
4 -1

That turns the most common reason to leave a loop early — find the first element satisfying a predicate — into one expression whose type states, through the mandatory else, what happens when nothing is found. It is the same discipline that gives references no null and errors a Result: the loop cannot fall through to an undefined value.

With no else, normal completion yields unit — so a value-carrying break without one is refused:

var xs = [1, 2, 3]

var found = for x in xs
    if x == 2 then break x

print(found)
this loop breaks with a int but has no 'else' to give a value when it finishes normally

Every break value and the else value share one type, which becomes the loop’s. A bare break with no else is the ordinary statement loop, of type unit.

do … while

The test goes at the foot, so the body runs before anything is asked. The body is an indented block with the while on the line that closes it, or a single statement on one line.

digits(n: int) -> string
    var rest = n
    var s = ""

    do
        s = str(rest % 10) + s
        rest /= 10
    while rest > 0

    s

var i = 0

do i += 1 while i < 4

print(digits(0), digits(4071), i)
0 4071 4

Written as a while, digits prints nothing for 0. The value rules are while‘s: a break carries the loop’s value, and an else runs when the test at the foot finally fails.

continue runs the test, and that is what the form is for. The shape written instead is loop with the test inverted at the bottom:

loop
    body
    if !cond then break        // NOT the same loop

That has no test for a continue to reach, so the first continue added to it jumps over the exit and the loop never leaves — the same trap the three-clause for exists to avoid, where a continue skips the step.

The test takes no is binding, and cannot read what the body declared: it guards nothing, because the body it belongs to has already run.

do
    var k = 1
while k < 2
undefined name 'k'

do is never ambiguous with the do that introduces a one-line body. That one only ever follows a loop header on the same line, so it is never a statement’s first token; a do that starts a line opens this form and nothing else.

loop

A loop with nothing to test. It runs until something leaves it, which is what while true was always being used to say.

var n = 0

var stop = loop
    n += 1

    if n == 4 then break n * 10

print(stop, n)
40 4

Two things follow from the condition being gone rather than merely constant. It takes no else — an else runs on normal completion, and this loop has none — so a value-carrying break needs nothing beside it, which is why the program above compiles where the for above it did not.

And a loop nothing breaks out of has type never, so it may stand as the last thing a function owing a value does, with nothing after it to supply one:

serve(handle: () -> unit) -> int
    loop
        handle()

That is the fact while true cannot state. A condition is an expression the analyzer does not evaluate, so a loop written that way looks like one that might finish, and the code after it looks reachable.

The three-clause for

C’s counted loop without the parentheses, as Go writes it — every other header in the language is parenthesis-free, and parentheses here would be structure rather than grouping. Each of the three clauses may be left out.

var total = 0

for var i = 0; i < 10; i += 1
    if i % 2 == 0 then continue

    total += i

var down = ""

for var i = 3; i > 0; i -= 1
    down += str(i)

print(total, down)
25 321

The step is why the form exists, and it is not sugar for a while. continue runs the step before testing again, so the skipped iterations above still advance — which is the whole reason that loop terminates. Written as a while the increment sits at the foot of the body, where the first continue anybody adds later walks straight past it: a bug the shape of the code invites and nothing about it warns of.

The second loop is the other reason: a descent is not something a range says.

An absent condition never turns false, so such a loop ends only through a break and takes no else — exactly as loop does, and loop says it better.

Labels

A 'label written before the loop keyword names that loop, and break 'label / continue 'label then act on it rather than on the nearest enclosing one. This is the only way to leave or restart an outer loop from inside a nested one.

var grid = [[1, 2], [3, 4]]

var hit = 'outer for row in grid
    for x in row
        if x == 3 then break 'outer x
else -1

print(hit)
3

A labeled break carries a value exactly as a bare one does, meeting that loop’s other breaks and its else at one type. continue takes a label but never a value.

The sigil is a leading apostrophe, as in Rust, and deliberately not the :-suffix some languages use: a bare break outer would be ambiguous with break expr carrying a value that happens to be named outer, and the apostrophe keeps a label and a value textually distinct. It does not collide with a character literal — 'a' closes its quote and is a character, 'a does not and is a label.

A label is in scope only inside its own loop’s body. Naming a loop that does not enclose the break, or reusing a label already in scope, is an error rather than a silent miss.

return

A function’s body yields its trailing expression, so return is for leaving early. It takes the same comma list a multi-result signature declares:

divmod(a: int, b: int) -> int, int
    if b == 0 then return 0, 0

    a / b, a % b

var q, r = divmod(17, 5)
var z, w = divmod(17, 0)

print(q, r, z, w)
3 2 0 0

become f(…) is return f(…) with the jump guaranteed, which is a different statement rather than a faster one: a chain of tail calls between functions is a loop only if every one of them is eliminated, so an ordinary return of a call is a frame and become is not. It is written up under declarations, beside the tail call it generalises.

defer

ARC gives back a &T, a string and a slice’s backing without being asked. It knows nothing about the rest: a descriptor from open, a FILE* from fopen, a block from malloc, a lock taken from a mutex. Until those are released by hand, a correct program is one that never takes an early exit — which ? makes the normal way to leave a function.

defer <statement> runs that statement on the way out of the block containing it. The resource is released beside the call that took it, once, rather than at every exit.

work()
    defer print("second")
    defer print("first")

    print("body")

work()
body
first
second

What runs, and when

A deferred statement belongs to its block — a loop body, a branch arm, or the function body itself — and runs when control leaves that block by any ordinary route: falling off the end, return, break, continue, or a ? taking its failure arm.

A deferred statement may not contain a jump that leaves it — a return, or a break or continue of a loop outside it — at any depth, since it runs while its block is already being left and there is no second exit to take. A loop written inside the statement is its own, so a jump of that loop stays inside and is fine, and so is a jump inside a closure or a nested function written there:

first(c: bool) -> int
    defer if c then return 1

    2

print(first(true))
a deferred statement runs while its block is being left, so it cannot 'return' — there is no exit left to take
work()
    defer for i in 0..<5
        if i == 2 then break
        print(i)

    print("body")

work()
body
0
1

Several in one block run last-registered-first, as above, so they undo in the reverse of the order they were set up — the order that lets a later one depend on an earlier one’s resource.

A defer runs only if control reached it. It is a statement, not a declaration: one after an early return never registered, and one in a branch never taken did not either.

The whole statement runs at the exit, so everything in it is read there — not where the defer stands:

show()
    var n = 1

    defer print(n)

    n = 2

show()
2

Go differs here: it evaluates a deferred call’s arguments where the defer stands and runs the call later, which needs somewhere to keep each captured argument until then — a slot per deferred statement, sized and laid out per call. Reading everything at the exit needs nothing kept. This is Zig’s rule, and it changes nothing for the form’s purpose: defer fclose(f) releases the handle bound above it, and a program that rebinds f in between has changed which file is open, so closing the one that is actually open is the behaviour wanted.

A trap runs nothing. A trap aborts without stack cleanup, and defer does not qualify that: a broken invariant means the program’s model of itself is already wrong, and running cleanup against that state is how a corrupt program writes its corruption to disk on the way down. defer releases a resource; it does not restore an invariant. A call that leaves its frame for good while the program goes on — a thread ending — is the opposite case, and a function declared @abandons runs its caller’s deferred statements before the call.

Why the block and not the function

Go’s defer runs at function exit. The difference is invisible for the common case — a resource taken at the top of a body and released when the body ends, where the function is the block — and shows up in a loop:

for i in 0..<2
    defer print("close", i)

    print("open", i)
open 0
close 0
open 1
close 1

Block scope closes each iteration’s resource at the end of that iteration, so the program holds one at a time. Function scope would hold all of them until the function returned — and, the part that decides it, would need somewhere to record a number of pending statements that no compiler can bound. That is a per-frame list whose length is discovered while running, which is precisely the machinery that a freestanding target under no alloc has nowhere to put. Block scope needs no runtime state at all: the statement is emitted at each edge that leaves the block, exactly as ARC’s own releases already are.

Go has two reasons for its choice and sysl has neither. recover only works inside a deferred call and a panic unwinds one frame at a time, so the frame has to be the unit; and mutating a named result is how Go wraps an error on the way out. sysl has no unwinding and no named results. The languages that came later without recover — Zig, Swift — both put defer at the block.

Where it sits against the rest of the model

A deferred statement runs before the block’s ARC releases, so every local it names is still alive when it runs, including the one holding the resource being closed. Leaving from the middle unwinds outward: the innermost block runs its deferred statements and then gives up its counts, then the block outside it does the same, up to the function’s own.

It owns nothing and allocates nothing. defer takes no count, makes no box, and adds no word to any value; a program that does not use it emits nothing for it. That is what keeps it available under no alloc, where the resources it releases are the only ones there are.

What it is not is a destructor. A destructor belongs to a type and runs wherever a value of that type dies; defer belongs to one place in one body and runs for the resource that body took. Both exist and neither replaces the other: defer covers every site a program can name, and a destructor covers the deaths it cannot — a resource inside a container, or inside a struct inside a container, dies at a point with no expression in the source.


Next: declarations.

Search

Esc
↑↓ to navigate ↵ to open Esc to close