Lowent Manual←↑→

7 Flow — branches, loops and leaving early

What to know first

chapter 4, Numbers · booleans are not numbers
chapter 6, Locals · if is a statement that yields no value

Looking 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

Flow statements exist in every language, so there seems to be nothing new to learn. But Lowent’s flow statements come with checks. Conditions must be booleans, the 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

You will learn to use 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

  1. How do you write a loop counting from 0 to n with for?
  2. Besides exhaustiveness, what makes match better than an if chain?

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]

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

Being shorter is a side effect. The real point of 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

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 .
end

At 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#

ShapeMeaningWhy
if c . do … endrun the block when the condition is truethe condition is a form closed by a stop; the body opens with do
if c . do … end else do … endone of twoopeners and closers always pair up
… end else if c2 . do … endchaining branchesjoins existing words instead of adding elif
while c . do … endrepeat while the condition is truethe one shape for counting loops
for x xs do … endeach element of a slice in turnmoving to the next element is the language’s job
break . · continue .leave the loop · go to the next roundstatements that change the flow
guard c . else return … .leave unless the condition holds — afterwards it is a factelse must always leave
return e .return a value and finishon every path of an op that produces a value
match v do case … . statement … endsplit by casescomplete and non-overlapping — no fall-through
case 1 to 9 . · case _ . · case y when c .a range · everything else · a guarded armintegers 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

Conditions must be 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.