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 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, the counting loops repeat, range and cycle, 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. 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 0 .
  var i u32 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 usize .
do
  var i usize 0 .
  while lt i. len xs. . . do
    if eq idx 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 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.

Counting loops have heads of their own. repeat i u64 n. do runs from 0 up to (not including) n, and range i u64 a. b. do runs from a to b, both ends included. To write the next value yourself, use cycle i u64 1 while lt i. 100 . next mul i. 2 . do. The counting type (u64) is always written. There are no full stops inside a head — an expression ends at a word such as do, step, while or next. To skip elements, put if <condition> before do (for x xs. if gt x. 2 . do). for does one thing only, walking elements — the old form for i count u64 n . do is rejected with E-FOR-OLD.

examples/ch07/counting.low

module counting .
rem run: tally 4

rem counting loops --- `repeat` leaves out the end, `range` includes both ends
export fn tally input n u64 . output u64 .
do
  var s u64 0 .
  repeat i u64 n. do
    set s. add s. i. . .
  end .
  range j u64 10 1 step -3 do
    set s. add mul s. 100 . j. . .
  end .
  return s. .
end .

Output

$ lowentc --run tally counting.low 4
tally(4) = 610070401

examples/ch07/doubling.low

module doubling .
rem run: steps 100

rem a loop whose next value you write --- from 1, doubling while below `limit`
export fn steps input limit u64 . output u64 .
  requires le limit. 1000000 . .
do
  var n u64 0 .
  cycle i u64 1 while lt i. limit. . next mul i. 2 . do
    set n. add n. 1 . .
  end .
  return n. .
end .

Output

$ lowentc --run steps doubling.low 100
steps(100) = 7

7.1.1 Leaving nested loops at once#

A number after break or continue counts loops outward. 1 is the innermost loop (the same as writing no number), and 2 is the loop around it. Loops have no labels.

examples/ch07/breakout.low

module breakout .
rem run: first_cell 8

rem in nested loops, `break 2 .` leaves both at once when the cell is found
export fn first_cell input want u32 . output usize .
do
  let g lit slice array 3 u32 lit array 3 u32 1 2 3 . lit array 3 u32 4 0 6 . lit array 3 u32 7 8 9 . . .
  var hit usize 99 .
  repeat r usize len g. . do
    repeat c usize 3 do
      if eq idx idx g. r. . c. . want. . do
        set hit. add mul r. 3 . c. . .
        break 2 .
      end .
    end .
  end .
  return hit. .
end .

rem `continue 2 .` leaves the inner loop and goes on with the next round of the outer one
export fn rows_without input bad u32 . output u64 .
do
  let g lit slice array 3 u32 lit array 3 u32 1 2 3 . lit array 3 u32 4 0 6 . lit array 3 u32 7 8 9 . . .
  var n u64 0 .
  for row g. do
    for x row. do
      guard ne x. bad. . else continue 2 . .
    end .
    set n. add n. 1 . .
  end .
  return n. .
end .

Output

$ lowentc --run first_cell breakout.low 8
first_cell(8) = 7

A number larger than the loops around the statement is rejected.

examples/ch07/mistake_breakfar.low

module mistake_breakfar .
rem expect: E-LOOP-COUNT

fn f output u64 .
do
  var n u64 0 .
  repeat a u64 3 do
    repeat b u64 3 do
      set n. add n. 1 . .
      rem ✘ only two loops surround this --- three cannot be left
      break 3 .
    end .
  end .
  return n. .
end .

Output

$ lowentc --check mistake_breakfar.low
mistake_breakfar.low:11:0 E-LOOP-COUNT: the number after `break` / `continue` counts the loops around it: 1 is the innermost (the same as writing no number), 2 is the loop around that one, and so on. It is a plain decimal literal from 1 up to the number of loops this statement sits in — there is no loop that far out here

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 idx 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 idx 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 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 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:9 E-VOCAB-REMOVED: `in` is gone — a loop over elements is `for <name> <slice>. do … end .` — found `in` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

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. Putting one stop too many between the condition and do

examples/ch07/mistake_dotdo.low

module mistake_dotdo .
rem expect: E-CLOSER-EXTRA

fn big input a u64 . output u64 .
do
  rem ✘ one stop too many after the condition --- the condition is already closed by its own stop, and `do` comes next
  if gt a. 3 . . do
    return 1 .
  end .
  return 0 .
end .

Output

$ lowentc --check mistake_dotdo.low
7:16 E-CLOSER-EXTRA: this stop closes nothing — the value before it is already closed, and the block `do … end` comes next — found `.` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

The condition is a value, and a value is closed by its own stop — the last stop of gt a. 3 .. do comes next. One more stop there has nothing to close and is rejected with E-CLOSER-EXTRA. The heads of while and match and the condition of a guard work the same way. In guard gt a. 0 . else return 0 . . the three stops close, in order, the condition, the return and the guard. The fix: if gt a. 3 . do.

Counter-example. Counting with for … count

examples/ch07/mistake_forcount.low

module mistake_forcount .
rem expect: E-FOR-OLD

fn tally input n u64 . output u64 .
do
  var s u64 0 .
  rem ✘ the old form --- counting is what `repeat` does. `for` walks elements only
  for i count u64 n. do
    set s. add s. i. . .
  end .
  return s. .
end .

Output

$ lowentc --check mistake_forcount.low
8:3 E-FOR-OLD: `for` walks elements only — counting is `repeat <name> <type> <n>. do … end .`, a range is `range <name> <type> <a>. <b>. do … end .` — found `for` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

for does one thing: it walks elements. Counting is repeat, an interval is range, and a loop whose next value you write yourself is cycle — the head word says which loop it is. The fix: repeat i u64 n. do.

Counter-example. Chaining branches with elif

examples/ch07/mistake_elif.low

module mistake_elif .
rem expect: E-DOT-MISSING

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:7 E-DOT-MISSING: a stop `.` must close `if … end` — found `elif` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

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. After end comes either else or the stop that closes the form, so the program is rejected with E-DOT-MISSING at the elif.

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 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
10:3 E-STMT-ELSE: `else` belongs after the `end` of the block before it — `if c. do … end else do … end .` — found `else` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

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 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:12 E-CHAR: unexpected character

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 0 .
  var i usize 0 .
  while lt i. len xs. . . do
    if eq mod idx 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 0 .
  match c. do
    rem only the matching arm runs --- it never falls into the next arm, so no `break` is needed
    case 1 do set score. add score. 10 . . end .
    case 2 do set score. add score. 20 . . end .
    case _ do set score. add score. 1 . . end .
  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 … end .run the block when the condition is truethe condition ends at do — a full stop only closes a statement
if c do … end else do … end .one of twoopeners and closers always pair up
… end else if c2 do … end .chaining branchesjoins existing words instead of adding elif
while c do … end .repeat while the condition is truethe condition is looked at again every round
repeat i u64 n. do … end . · range i u64 a. b. do … end .n times from 0 · from a to bcounting is the head word’s job — no hand-incremented counter
cycle i u64 v while c next e do … end .a loop whose next value you writestart, condition and next value sit together in one head
for x xs. do … end .each 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
break 2 . · continue 2 .leave the second loop out · go to its next roundnested loops are left without a flag variable
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 … do … end . … end .split by casescomplete and non-overlapping — no fall-through
case 1 to 9 · case _ · case y when ca 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.