7 Flow — branches, loops and leaving early
What to know first
if is a statement that yields no valueLooking back
In chapter 6, why was let x u64 be if gt a 1 . 5 else 6 . rejected, and how do you set a value per branch?
A. if is a statement that yields no value, so it cannot stand in an expression position (E-IF-VALUE). To set a value per branch, make a var with a default and set it in the branches, or return from each branch. This chapter covers the branches themselves, loops, and the rules for leaving early.
The need for this chapter, and its context
else of a guard must leave, an op that returns a value must return on every path, and a match must cover every case. These checks remove “on this path there is no value” defects at translation time. The promises of flow are set up before data (Part III).By the end of this chapter
if … else, while, for, break and continue. You will see how guard turns a condition into a fact about the code below it, and why an else that does not leave is rejected. You will also meet the rule that every path must return a value, match over numbers and ranges with its exhaustiveness check, and the fact that panic is an effect.The questions this chapter answers
- How do you write a loop counting from 0 to
nwithfor? - Besides exhaustiveness, what makes
matchbetter than anifchain?
7.1 Conditions and loops#
if runs a block when its condition is true, and else gives the block for false. while repeats while its condition is true, and for walks the elements of a slice in order. Every condition must be a bool.
examples/ch07/loops.low
module loops .
rem run: count_big 10
rem run: first_zero [5,3,0,9]
rem run: first_zero [5,3]
rem run: odd_sum [1,2,3,4,5]
fn count_big input n u32 . output u32 .
requires le n 100 .
do
var total u32 be 0 .
var i u32 be 0 .
while lt i n . do
if gt i 5 . do
set total (add total 1) .
end
set i (add i 1) .
end
return total .
end
fn first_zero input xs slice u8 . output u64 .
do
var i u64 be 0 .
while lt i (len xs) . do
if eq (index xs i) 0 . do
break .
end
set i (add i 1) .
end
return i .
end
fn odd_sum input xs slice u8 . output u64 .
do
var acc u64 be 0 .
for x xs do
if eq (mod x 2) 0 . do
continue .
end
set acc (add acc (widen u64 x)) .
end
return acc .
end
Output
$ lowentc --run count_big loops.low 10
count_big(10) = 4
$ lowentc --run first_zero loops.low [5,3,0,9]
first_zero([5,3,0,9]) = 2
arg0 (written) = [5,3,0,9]
$ lowentc --run first_zero loops.low [5,3]
first_zero([5,3]) = 2
arg0 (written) = [5,3]
$ lowentc --run odd_sum loops.low [1,2,3,4,5]
odd_sum([1,2,3,4,5]) = 9
arg0 (written) = [1,2,3,4,5]
count_bigcounts from 0 up tonand counts only numbers greater than 5. Look at the shapewhile lt i n . do … end— the condition is a form too, so it is closed with a full stop, anddoopens the body after it.first_zeroleaves the loop withbreakwhen it meets a 0, and returns the length if there is none.odd_sumwalks the elements withfor x xs do … endand skips even ones withcontinue. Thefornamexhas the slice’s element type (u8) and lives only inside the block.
Writing for x in xs is rejected with E-VOCAB-REMOVED. The thing to walk comes right after the name.
Q. How do you write a loop counting from 0 to n with for?
A. for is a tool for walking slices and does not take a numeric range directly. Counting loops are written with while and var. Having one shape for counting loops makes the range of the counter easy for the compiler to see and keeps the analysis that removes bounds checks simple. Filtering, counting and collecting over a slice are pipe’s job (chapter 24).
7.2 guard — turning a condition into a fact#
guard <condition> . else <leave> . leaves on the spot if the condition is not true. The leaving statements are return, break, continue and panic.
examples/ch07/guards.low
module guards .
rem run: head_or_zero [7,8]
rem run: head_or_zero []
rem run: grade 95
rem run: grade 42
fn head_or_zero input data slice u8 . output u8 .
do
guard ge (len data) 1 . else return 0 .
return index data 0 .
end
fn grade input score u8 . output u8 .
do
guard le score 100 . else return 0 .
if ge score 90 . do
return 65 .
end else do
if ge score 60 . do
return 66 .
end
end
return 70 .
end
Output
$ lowentc --run head_or_zero guards.low [7,8]
head_or_zero([7,8]) = 7
arg0 (written) = [7,8]
$ lowentc --run head_or_zero guards.low []
head_or_zero([]) = 0
arg0 (written) = []
$ lowentc --run grade guards.low 95
grade(95) = 65
$ lowentc --run grade guards.low 42
grade(42) = 70
The code below the guard in head_or_zero lives only in a world where the slice is not empty. That is why index data 0 is safe. grade filters out out-of-range scores first with guard, and splits the rest with if … end else do … end below it.
guard is not another name for if not, because its else must leave. If it does not, the code is rejected.
examples/ch07/fallthrough.low
module fallthrough .
rem expect: E-GUARD-FALLTHROUGH
fn clamp5 input n u32 . output u32 .
do
guard le n 5 . else do
let capped u32 be 5 .
end
return n .
end
Output
$ lowentc --check fallthrough.low
fallthrough.low:6:0 E-GUARD-FALLTHROUGH: this `guard`'s `else` FALLS THROUGH — it must LEAVE (`return` / `break` / `continue` / `panic`). If it falls through, the code AFTER the guard wrongly believes the condition holds. Leaving is the WHOLE point of `guard`: it is what lets the rest of the op assume the condition. (SPEC-002 D6 always said so and nothing enforced it — which made `guard` an exact synonym of `if not`.) If you do not mean to leave, write `if`
If the else flows on instead of leaving, the code below the guard believes n is at most 5 while it is not. Rather than letting code believe an unreliable fact, translation is refused. If you do not mean to leave, use if.
A common misconception. guard is syntactic sugar for shorter code
guard is handing a fact to the compiler and the reader. After a guard the compiler knows the condition is true and uses it to remove bounds checks. You can write the same meaning with if … do return … end and get the same behaviour, but leaving is not guaranteed by the grammar.7.3 Every path returns a value#
An op that returns a value must return one on every path.
examples/ch07/partial.low
module partial .
rem expect: E-RETURN-PARTIAL
fn sign_flag input a i32 . output u8 .
do
if gt a 0 . do
return 1 .
end
end
Output
$ lowentc --check partial.low
partial.low:4:0 E-RETURN-PARTIAL: this op says it OUTPUTS a value, but some path through its body reaches the end without a `return`. Until now the tool quietly returned 0 there — a value that appears NOWHERE in your source (RFC-0019 G-TOTAL). Give every path a `return`, or say `output void` if it really produces nothing. An exhaustive `match` whose every arm returns counts as returning
On the path where a is 0 or less, there is no return. The old tool quietly returned 0 on that path — a value that appears nowhere in the source. Now it is rejected. The statements recognised as returning are return, an if … else where both branches return, and a match where every arm returns. while, for and guard have ways out, so they do not count as returning by themselves. That is why a loop is always followed by a return.
An op that returns nothing (output void) may run to the end of its body without return. Where it ends is where it returns.
7.4 match — every case, none missing#
match splits a value into cases. Unlike an if chain, it must handle every case.
examples/ch07/bands.low
module bands .
rem run: band 9
rem run: band 10
rem run: band 200
rem run: half 128
rem run: big 101
fn band input x u8 . output u8 .
do
match x do
case 0 to 9 . do return 1 . end
case 10 to 19 . do return 2 . end
case _ . do return 0 . end
end
end
fn half input b u8 . output u8 .
do
match b do
case 0 to 127 . do return 0 . end
case 128 to 255 . do return 1 . end
end
end
fn big input x u8 . output u8 .
do
match x do
case y when gt y 100 . do return 1 . end
case _ . do return 0 . end
end
end
Output
$ lowentc --run band bands.low 9
band(9) = 1
$ lowentc --run band bands.low 10
band(10) = 2
$ lowentc --run band bands.low 200
band(200) = 0
$ lowentc --run half bands.low 128
half(128) = 1
$ lowentc --run big bands.low 101
big(101) = 1
bandsplits bylo to hiranges (inclusive at both ends). Integers have many cases, so acase _ .covering the rest is required.halfneeds no_because its two ranges coveru8′s 0 … 255 with no gap.case y when gt y 100 .inbigbinds the whole value toyand narrows it further with a guard. An arm with a guard might be false, so it does not count towards exhaustiveness;_is still needed.
A gap is rejected.
examples/ch07/inexhaustive.low
module inexhaustive .
rem expect: E-MATCH-INEXHAUSTIVE
fn band input x u8 . output u8 .
do
match x do
case 0 to 9 . do return 1 . end
case 11 to 255 . do return 2 . end
end
end
Output
$ lowentc --check inexhaustive.low
inexhaustive.low:6:0 E-MATCH-INEXHAUSTIVE: this `match` on an integer is not exhaustive — the literals/ranges leave a gap, so a `case _ .` wildcard (or `else`) is required, OR the ranges must TILE the whole domain of the scrutinee's type (e.g. `0 to 127` + `128 to 255` on a u8) (RFC-0020 §6.4; MM4 tiling)
10 falls in no arm. Writing the same case twice, or an arm after _, is rejected with E-MATCH-REDUNDANT. It is an error, not a warning — an arm that can never run usually hides a defect.
Q. Besides exhaustiveness, what makes match better than an if chain?
A. It shows when cases grow. Add a variant to an enum and every match over that enum stops at translation and tells you where to fix it (chapter 10). An if chain lets the new case slide silently into its last else. Also, --ir has the tool report the dispatch cost, such as the number of arms and the worst-case number of comparisons.
7.5 panic is an effect#
panic stops the program immediately. It is an irreversible stop, so it is an effect, and an op that uses it must be a proc declaring effects panic.
examples/ch07/panics.low
module panics .
rem run: checked 3
rem trap: checked 0
proc checked input n u32 . output u32 . effects panic .
do
if eq n 0 . do
panic "n must not be zero" .
end
return div 100 n .
end
Output
$ lowentc --run checked panics.low 3
checked(3) = 33
$ lowentc --run checked panics.low 0
== ir diagnostics (1) ==
0:0 E-VM-PANIC: the program called `panic` — this is an unrecoverable stop, and it is NOT a contract violation (the code chose to stop, it did not break a promise)
The VM reports E-VM-PANIC and makes clear that this is not a contract violation — the code chose to stop; it did not break a promise. A pure fn cannot panic. But stops the processor raises for overflow or contract violations still happen inside a fn. Those are not something the op did but the processor making promises hold.
Use panic only for situations that cannot be recovered from. Failures a caller can handle are returned as values (chapter 11). How to tell the two apart is covered in chapter 17.
7.6 Common mistakes#
Counter-example. Writing for x in xs
examples/ch07/mistake_forin.low
module mistake_forin .
rem expect: E-VOCAB-REMOVED
fn total input xs slice u8 . output u64 .
do
var t u64 be 0 .
rem ✘ wrote `in` --- `for` is `for <name> <slice> do`
for x in xs do
set t (add t (widen u64 x)) .
end
return t .
end
Output
$ lowentc --check mistake_forin.low
8:0 E-VOCAB-REMOVED: `in` is gone. In a loop write `for <name> <slice> do` — the slice follows the name and `do` marks the body, so `in` carried nothing. As access, `b in a` was a reverse spelling of `field a b`: write `field a b` / `index a i` (the glued dot `a.b` is refused too, `E-FIELD-GLUED`)
for is for <name> <slice> do. The thing to walk comes right after the name, and do already marks where the body starts, so in would carry nothing. It was removed so that one meaning has one spelling. The fix: for x xs do.
Counter-example. Chaining branches with elif
examples/ch07/mistake_elif.low
module mistake_elif .
rem expect: E-BLOCK-NOHEAD
fn grade input n u64 . output u64 .
do
if lt n 50 . do
return 0 .
rem ✘ `elif` is not a word --- a following branch is `end else if … do`
end elif lt n 80 . do
return 1 .
end
return 2 .
end
Output
$ lowentc --check mistake_elif.low
9:22 E-BLOCK-NOHEAD: a `do … end` block needs a head that owns it — `if … do`, `while … do`, `fn … do`, `make T do`, `region … do` … A bare block is not in the grammar; it used to pass `--check` and then could not be lowered. Put its statements where they belong, or give it its head
elif, elsif and else if: vary between languages. Lowent joins words it already has: close the previous block with end and add else if. elif is not a word, so elif lt n 80 . is read as a statement of its own, and the do … end after it becomes a block with no head to open it (E-BLOCK-NOHEAD) — a do … end is always opened by a head such as if, while or fn.
examples/ch07/elseif.low
module elseif .
rem run: grade 30
rem run: grade 70
rem run: grade 95
fn grade input n u64 . output u64 .
do
if lt n 50 . do
return 0 .
rem to chain branches, close the previous block with `end` and add `else if`
end else if lt n 80 . do
return 1 .
end else do
return 2 .
end
end
Output
$ lowentc --run grade elseif.low 30
grade(30) = 0
$ lowentc --run grade elseif.low 70
grade(70) = 1
$ lowentc --run grade elseif.low 95
grade(95) = 2
The last branch is end else do … end. When there are three or more branches that all split one value, match is a better fit.
Counter-example. Putting else inside the block, C style
examples/ch07/mistake_innerelse.low
module mistake_innerelse .
rem expect: E-STMT-ELSE
fn pick input a u8 . output u8 .
do
var r u8 be 0 .
if gt a 5 . do
set r 1 .
rem ✘ `else` sits on its own line inside the block, C style --- the previous block must be closed with `end else do`
else
set r 2 .
end
return r .
end
Output
$ lowentc --check mistake_innerelse.low
mistake_innerelse.low:10:0 E-STMT-ELSE: `else` sits INSIDE the block, the way C writes it. Here a block is closed before the other arm opens: `if <cond> . do … end else do … end`. Written this way the arm used to be accepted by every static check and then dropped at lowering — the VM stopped with an unsupported body and the native build silently left the op out
In C and several other languages else follows the previous block, as in if … { … } else { … }. In Lowent else comes after the previous block is closed with end (end else do). With else on its own line inside the block the program is rejected with E-STMT-ELSE; it used to translate and then stop at run time with E-VM-UNSUP, while the native build left the op out entirely.
Counter-example. Using a symbol such as < in a condition
examples/ch07/mistake_less.low
module mistake_less .
rem expect: E-CHAR
fn upto input n u64 . output u64 .
do
var i u64 be 0 .
rem ✘ `<` is not a symbol of this language --- the comparison is `lt i n`
while i < n . do
set i (add i 1) .
end
return i .
end
Output
$ lowentc --check mistake_less.low
8:11 E-CHAR: unexpected character
8:11 E-FORM-UNEXPECTED: unexpected token in form
Comparisons are words — lt (less than), le (less or equal), gt, ge, eq, ne. < is a character the language does not know, hence E-CHAR. The choice spares you from memorising symbol precedence, and long arithmetic has the expr island (chapter 8). The fix: while lt i n . do.
Counter-example. continue skipping the increment
If you count with while and continue in the middle of the body, you also skip the set i (add i 1) . placed below it.
fn odd_count input xs slice u8 . output u64 .
do
var n u64 be 0 .
var i u64 be 0 .
while lt i (len xs) . do
if eq (mod (index xs i) 2) 0 . do
continue .
end
set n (add n 1) .
set i (add i 1) .
end
return n .
endAt the first even number, i stops increasing and the loop never ends. Neither compilation nor run-time checks catch this — running forever is not an overflow. Move the increment to the top of the body (keeping the pre-increment value for indexing), or, if you are walking elements, use for x xs do in the first place. With for, moving to the next element is the language’s job, so this bug has nowhere to live.
There are two ways to write the slot that takes the rest. case _ . and a final else do the same thing.
examples/ch07/matchelse.low
module matchelse .
rem run: band 1
rem run: band 3
rem run: band 99
fn band input n u64 . output u64 . do
match n do
case 1 do
return 10 .
end
case 2 to 5 do
return 20 .
end
rem catch the rest with `else` --- the same place as `case _ .`
else do
return 99 .
end
end
end
Output
$ lowentc --run band matchelse.low 1
band(1) = 10
$ lowentc --run band matchelse.low 3
band(3) = 20
$ lowentc --run band matchelse.low 99
band(99) = 99
Nothing may follow an else — it has already taken everything, so a later arm never runs and is refused with E-MATCH-REDUNDANT.
A common misconception. match arms fall through like C’s switch
examples/ch07/nofall.low
module nofall .
rem run: label 1
rem run: label 2
rem run: label 9
fn label input c u64 . output u64 .
do
var score u64 be 0 .
match c do
rem only the matching arm runs --- it never falls into the next arm, so no `break` is needed
case 1 . set score (add score 10) .
case 2 . set score (add score 20) .
case _ . set score (add score 1) .
end
return score .
end
Output
$ lowentc --run label nofall.low 1
label(1) = 10
$ lowentc --run label nofall.low 2
label(2) = 20
$ lowentc --run label nofall.low 9
label(9) = 1
Only the one matching arm runs, and it never falls into the next. There is no break to remember and no bug from forgetting it. To do the same thing for two cases, join the arms with or (chapter 11).
7.7 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
if c . do … end | run the block when the condition is true | the condition is a form closed by a stop; the body opens with do |
if c . do … end else do … end | one of two | openers and closers always pair up |
… end else if c2 . do … end | chaining branches | joins existing words instead of adding elif |
while c . do … end | repeat while the condition is true | the one shape for counting loops |
for x xs do … end | each element of a slice in turn | moving to the next element is the language’s job |
break . · continue . | leave the loop · go to the next round | statements that change the flow |
guard c . else return … . | leave unless the condition holds — afterwards it is a fact | else must always leave |
return e . | return a value and finish | on every path of an op that produces a value |
match v do case … . statement … end | split by cases | complete and non-overlapping — no fall-through |
case 1 to 9 . · case _ . · case y when c . | a range · everything else · a guarded arm | integers have many cases, so _ is often needed |
panic "…" . | an irreversible stop (an effect) | only in a proc that declares effects panic |
Table 7.1 — Control-flow syntax — shape · meaning · why it looks this way
Recap
bools. while repeats on a condition and for over a slice, and break and continue change the flow. The else of a guard must leave, and after the guard the condition is a fact. An op that returns a value must return on every path. match must cover cases with no gaps and no overlaps. panic is an effect.