6. Errors are values
The last page of Mahler's Ninth is marked "ersterbend" — dying away. Nothing about it is a surprise; the movement has been saying so for twenty minutes.
Chapter 1 admitted that the receipt tells two lies, and promised this chapter would take them apart. Here is the second one, doing its work:
fn main() -> !int {
let rows = """
espresso,340
pastry,lots
tip,100
"""
var total = 0
for row in rows.lines() {
var i = 0
while row[i..i + 1] != "," { i += 1 }
total += row[i + 1..].to_int() else 0
}
print("{"total":<10}{total:>5}")
0
}
$ lupin lying.lu
total 440
The receipt is wrong and it is confident. A row said lots, else 0
decided that meant nothing, and 440 went out the door as though somebody
had checked it. Nobody did. The program has no bug in the sense of a
crash; it has a bug in the sense that it lies to its user, quietly, for
as long as the input stays malformed.
Wolf’s answer is that failure is a value with a type, the type is written in the signature, and the caller cannot fail to notice it.
6.1 !T and the row
to_int() has a type this book has been rounding off since chapter 1.
Here is one written out in full:
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn main() -> !int {
print("{comma("tip,100") else -1} {comma("tip 100") else -1}")
0
}
$ lupin comma.lu
3 -1
Read int ! {no_comma} as “an int, or the failure no_comma.” The !
separates the success type from the row: the set of tags this function
may fail with, written between braces, and complete. A tag is returned like any other value, either with
return or as the last expression, and no wrapping ceremony stands
between you and it. The tag is lowercase because it carries nothing: the
compiler reads a CapCase mark as a promise that a payload waits inside,
and W0603 is what it says when the case and the payload disagree.
The row grows when the function can fail in more than one way, and a tag carries a payload when the caller needs the details:
struct Bad { at: int, found: str }
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
let at = comma(row)?
let text = row[at + 1..]
text.to_int() else { return NotANumber(Bad { at: at + 1, found: text }) }
}
fn main() -> !int {
print("{cents("espresso,340") else -1}")
print("{cents("tip 100") else -1}")
print("{cents("pastry,lots") else -1}")
0
}
$ lupin cents.lu
340
-1
-1
cents announces two failures. no_comma is a bare tag: the fact that
there is no comma is the whole story. NotANumber(Bad) carries a struct
with the byte offset and the text found there, because a caller that
wants to point at the problem needs to know where it is. The signature is
now a complete account of what a call can do (one int and two named
ways of not producing one), and it fits on a line.
When several signatures share a row, the row can be named once.
error ReadErrors = {no_comma, NotANumber(Bad)} declares no new type.
It is a spelling: int ! ReadErrors and int ! {no_comma, NotANumber(Bad)} are the same type, in both directions, with nothing to
convert. A name inside a row stands for its tags, so a wider row can be
built out of a narrower one, and ? moves between them the way it moves
between any two rows:
struct Bad { at: int, found: str }
error ReadErrors = {no_comma, NotANumber(Bad)}
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn cents(row: str) -> int ! ReadErrors {
let at = comma(row)?
let text = row[at + 1..]
text.to_int() else { return NotANumber(Bad { at: at + 1, found: text }) }
}
fn short(row: str) -> int ! {ReadErrors, too_long} {
if row.len > 12 { return too_long }
cents(row)?
}
fn main() -> !int {
print("{cents("espresso,340") else -1} {cents("tip 100") else -1} {short("pastry,lots") else -1} {short("espresso,340,x") else -2}")
0
}
$ wolf run readerrors.lu
340 -1 -1 -2
short’s row is {no_comma, NotANumber(Bad), too_long}, and a
diagnostic about it names tags, never the alias: a match that forgets
one is told which, too_long by name, because the tags are what a
caller has to handle. The alias saves the
writer a line and costs the reader nothing.
The whole row need not be closed. A row that ends in .. is open: it
says these tags, and possibly more, which is what a function wraps
around a subsystem still under construction has to say. The price is
symmetric and immediate: no caller of an open row can ever handle it
exhaustively by name, so every one of them needs a catch-all. Closed
rows are the default in this book for that reason. A tag no row declares
is CapCase whatever it carries, which is the pact’s one exception: with
no signature to name it, its case is the only thing that says it is a
tag and not a name.
Coming from Python: an exception is not in the signature, so the complete list of what a call can raise is a property of the whole call tree below it, discoverable by reading all of it or by running into the ones that matter in production. Wolf puts the list in the type. The cost is that a function’s failures are part of its interface: add a tag and every exhaustive caller stops compiling, which is disruptive in exactly the way that a new failure mode is disruptive. Python defers that conversation and wolf holds it at compile time. Both cost something, and the one that surprises you at 3am is not the one with the list.
Exercise 6-3 (extension · lupin). Grow the row: add a too_long
variant for inputs over four bytes and handle it. What else did you have
to change, and what told you?
Exercise 6-4 (comprehension · lupin). The error carries a
payload. Predict both printed lines of a digit(s, i) whose
NotDigit(Bad) records the byte offset and the character it found, when
it is called on byte 1 of "4x".
Exercise 6-6 (comprehension · lupin). The row below ends in ..,
which makes it open: probe may return tags the signature never lists.
Predict all three numbers, and say which arm of the match handles
probe(-1) and why the program compiles at all when Weird appears
nowhere in any signature:
fn probe(n: int) -> int ! {Io(int), ..} {
if n < 0 { return Weird }
if n == 0 { return Io(4) }
n
}
fn code_for(n: int) -> int {
probe(n) else |err| {
match err {
Io(code) => 0 - code,
_ => 0 - 99,
}
}
}
fn main() -> !int {
print("{code_for(7)} {code_for(0)} {code_for(-1)}")
0
}
6.2 ?, else, else |err|
A caller of a fallible function has exactly three things it can want:
hand the failure to its caller, substitute a value, or decide by
case. Wolf spells them with one character, one keyword, and match,
and those three are the whole of how a row is handled: no operator
reads a !int, no cast narrows one, and nothing happens to the failure
that the code does not spell. This section is the first two, and the
third in the form it takes on a bound failure; §6.6 is the third on
its own.
? hands it up, and it has already appeared: the first line of cents
above is let at = comma(row)?. Read ? as “or hand it up.” If comma succeeds, at is the byte offset
and the next line runs. If it fails, cents stops there and returns that
failure itself: the same tag, with no wrapping to undo,
which is why no_comma appears in cents’s row without cents ever
mentioning it in code. Rows compose by union: everything the callee can
fail with joins everything the caller can, and the signature stays a
complete account without anybody maintaining it by hand.
else substitutes. All of its forms are one construct:
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn main() -> !int {
let a = comma("tip,100") else 0
let b = comma("tip 100") else 0
let c = comma("tip 100") else |err| {
print("no comma; treating the whole row as a name")
"tip 100".len
}
print("{a} {b} {c}")
0
}
$ lupin else.lu
no comma; treating the whole row as a name
3 0 7
else 0 is a default. else |err| { … } is a handler: the failure is
bound to a name and the block’s value becomes the expression’s, so a
handler is the other branch of a value, not a statement bolted onto the
side. Between them sits else { … } with no binding, for when the
fact of failure is enough. And ? is what you write when the
answer is “not my decision”, the case that used to be four lines of
plumbing.
A handler whose answer is to stop has no value to give, and it does not
need one. A function declared -> never never returns
([type.fn.never]), so a call to it fits wherever a value is wanted,
an int here:
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn die(why: str) -> never {
print(why)
assert(false)
}
fn main() -> !int {
let at = comma("tip,100") else |_| die("no comma: the ledger is corrupt")
print("comma at {at}")
0
}
$ wolf run die.lu
comma at 3
never is written only as a return type, and the compiler holds the
body to it: a -> never body that can reach its end, or that holds a
return, is E0401. die ends in assert(false), which stops the
program with trap(assert); a call to another -> never function, or
a loop with no break, would end it as well. Feed it "tip 100" and
the handler prints its line and nothing after the call runs.
When the answer is your decision and the tags differ, match on the
bound failure and the exhaustiveness rules from §3.4 apply to the row:
struct Bad { at: int, found: str }
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
let at = comma(row)?
let text = row[at + 1..]
text.to_int() else { return NotANumber(Bad { at: at + 1, found: text }) }
}
Both functions are §6.1’s, unchanged. The new part is the caller:
fn report(row: str) -> int {
cents(row) else |err| {
match err {
NotANumber(bad) => {
print("row `{row}`: byte {bad.at} starts `{bad.found}`, which is not a number")
0
},
_ => {
print("row `{row}`: no comma anywhere")
0
},
}
}
}
fn main() -> !int {
print("{report("espresso,340")}")
print("{report("pastry,lots")}")
print("{report("tip 100")}")
0
}
$ lupin report.lu
340
row `pastry,lots`: byte 7 starts `lots`, which is not a number
0
row `tip 100`: no comma anywhere
0
The payload is in scope inside its arm; bad.at and bad.found are
the struct’s fields. Detail belongs in a tag, not in a printed
string: the fact travels to the code that owns
the policy, which decides whether to print it, point at it, or
count it.
Coming from Go:
if err != nil { return err }and?do the same job. Go’s version is a statement you can forget to write, and the compiler cannot tell the difference between a_you meant and a_you regretted; wolf’s version is part of the expression, so an unhandled failure is a type error, not a style violation. What Go buys with the verbosity is that every propagation is visible at a glance, which is why?is a character at the end of the call rather than an invisible unwinding: the propagation is still written down, one glyph long.
Finally, the failure that nobody handles. main returns !int, so ?
works there too, and a failure that reaches the top ends the program:
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn main() -> !int {
let at = comma("tip 100")?
print("comma at {at}")
0
}
$ lupin top.lu
error: no_comma
$ echo $?
1
The tag on stderr and exit 1 is the fourth verdict alongside chapter
1’s three, and the barest of them: a tag, a stream, and a number, with
no clause tag and no span, because nothing here is a fault. The program
did what it was told and the thing it was told to do failed. A failure
that reaches main is a program that declined to decide; §6.4 is the
deciding.
Exercise 6-14 (comprehension + extension · lupin). An RPN
evaluator is a loop and a stack, and the stack is a List[int];
words() (§2.5) is the tokenizer, so "3 4 + 2 *".words() is the
input. Each operator pops twice, and pop answers int ! {none}, so
spell it (mut stack).pop()? and give evaluate the row ! {none, parse}, because to_int fails on a token that is not a number. Trace
the stack after each token on paper, then run. Then feed it 3 + and
3 x + and say, from the row and not from the code, which call
answers each one and where your else |err| prints.
Exercise 6-1 (fingers · lupin). Write parse so empty input is an
error, and give two call sites: one defaulting with else 0, one with
else 7. Predict both prints first.
Exercise 6-2 (comprehension · lupin). chain calls parse
through ?. Predict a and b, and name which row variant b’s
handler sees.
Exercise 6-7 (extension · lupin). head prints a file’s first
n lines, and a file with fewer than n lines is not a crash, it is an
answer. Write head(text, n) whose error carries how many lines actually
existed, and a caller that asks for 2 lines (succeeds) and 5 lines
(handled). Why does the payload belong in the error instead of being
printed by head itself?
6.6 match over the value
report in §6.2 made its decision in two steps: else |err| told the
value from the failure, and a match on err told the failures from
each other. When every outcome is a case, including the value, the
match can stand on the call itself. cents is §6.1’s, unchanged:
struct Bad { at: int, found: str }
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," {
let text = row[i + 1..]
return text.to_int() else { return NotANumber(Bad { at: i + 1, found: text }) }
}
i += 1
}
no_comma
}
The caller matches on the call:
fn describe(row: str) -> str {
match cents(row) {
no_comma => "no comma",
NotANumber(bad) => "`{bad.found}` at byte {bad.at} is not a number",
0 => "nothing owed",
n => "{n} cents",
}
}
fn main() -> !int {
print(describe("espresso,340"))
print(describe("water,0"))
print(describe("pastry,lots"))
print(describe("tip 100"))
0
}
$ lupin rowmatch.lu
340 cents
nothing owed
`lots` at byte 7 is not a number
no comma
The scrutinee is an int ! {no_comma, NotANumber(Bad)}, and the arms
are of two kinds. A row arm names a tag of the row and binds its
payload when it has one: no_comma and NotANumber(bad) are the two
here, and bad is a Bad inside its arm exactly as it was in §6.2. A
value arm is any pattern over the int, written as a match over an
int would write it: 0 is a literal and n binds the rest. Which
kind an identifier is, the row decides. An arm that names a tag of the
scrutinee’s row is a row arm; anything else is a value pattern, so n
binds the number because no tag is called n. A tag whose name is
also a constructor reachable from the value’s type is refused by name
rather than guessed at, which is the one way the rule can be
ambiguous and the compiler’s answer to it.
Coverage is §3.4’s rule applied twice. The match must cover every
tag of the row and the whole of the int, and here two tags and a
binding do it. _ covers what is left on both halves at once: it is
what a caller of an open row writes, because no list of names is
complete there (§6.1), and what you write when the value does not
matter.
The expression’s type is the arms’ type, str, and the row is gone.
describe returns a plain str with no ! in its signature, because
every tag was answered inside the match. That is the same consumption
else performs, done by case.
Forget a tag and the compiler names it. The n arm is no defense: it
binds every int and no tag, because the two halves are covered
separately.
struct Bad { at: int, found: str }
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," {
let text = row[i + 1..]
return text.to_int() else { return NotANumber(Bad { at: i + 1, found: text }) }
}
i += 1
}
no_comma
}
fn main() -> !int {
let owed = match cents("tip 100") {
NotANumber(bad) => 0 - bad.at,
n => n,
}
print("{owed}")
0
}
error[E0801]: this `match` does not cover `no_comma`
--> ./s7.lu:17:16
|
17 | let owed = match cents("tip 100") {
| ^^^^^^^^^^^^^^^^^^^^^^^^
18 | NotANumber(bad) => 0 - bad.at,
19 | n => n,
20 | }
| ^^^^^ not every value is matched
|
= note: over a fallible value a row arm covers one tag of the row, a value pattern covers values
of the ok type, and `_` covers whatever is left on both halves ([type.row.match]).
= note: add arms for the missing cases, or end the `match` with a `_` arm to catch the rest
deliberately.
The missing arm is named by its tag, never by an alias: a row spelled
ReadErrors would still be reported as no_comma, for §6.1’s reason,
that the tags are what a caller has to handle.
else |err| match err { … } keeps working beside this, and the choice
between them is about shape. Reach for match over the call when the
value is one of the cases: when 0 means something different from
340, or when the arms all produce the same kind of answer and the
value is one answer among several. Reach for else |err| when the
value is the whole function and the failure is a detour: report
wants to return the number and spend its words on the failures, and
binding the failure keeps the number out of the arms. A match whose
value arm is v => v beside three row arms is else |err| match err
wearing more braces, and the shorter spelling is the honest one.
Exercise 6-15 (comprehension · wolf + lupin). Exercise 6-6’s
code_for handles
probe’s open row through else |err| and a match. Rewrite it as
one match over probe(n) itself, with the arms Io(code), v and
_. Predict the three numbers, then say what _ covers when v
already binds every int, and why the program does not compile
without it.
6.3 errdefer
defer from §4.3 runs on every exit. Some cleanup should run only on
the bad one: a half-written file wants deleting, a half-built index
wants discarding, and neither should happen when the work succeeded:
fn comma(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
no_comma
}
fn scan(text: str) -> int ! {no_comma} {
print("scan opened")
defer print("scan closed")
errdefer print("partial results discarded")
var total = 0
for row in text.lines() {
total += comma(row)?
}
total
}
fn main() -> !int {
print("a={scan("tip,100") else -1}")
print("b={scan("tip 100") else -1}")
0
}
$ lupin errdefer.lu
scan opened
scan closed
a=3
scan opened
partial results discarded
scan closed
b=-1
Two runs of the same function, and the difference is one line of output.
The success path fires the defer and skips the errdefer. The failure
path fires both, errdefer first: they unwind as one stack in reverse
registration order, so the error-only cleanup that was registered later
runs before the unconditional cleanup registered earlier. Ordering
follows the same rule defer already taught; the only new thing is the
condition.
The shape to keep is the pairing. Acquire, then errdefer the release,
in adjacent lines, so the failure path is correct by construction rather
than by remembering to write a handler at every ? below it. There are
five ?s in a real parser and one errdefer.
Credit where it is due:
errdeferis Zig’s, name and all, and so is the discipline of an error path you can read without following a stack unwinder. Wolf’s departure is the row: Zig’serrorset is inferred by default, which makes the set cheap to write and hard to read at a boundary; wolf spells it on items for the same reason it spells parameter types on items. Thetrykeyword’s job is?’s job, and thecatch |err|shape is whereelse |err|comes from.
Exercise 6-5 (comprehension · lupin). errdefer runs only on the
error path. work(true) succeeds; work(false) fails after the
errdefer is registered. Predict all four output lines.
6.4 Hardening by refactor
The receipt at the head of this chapter has both of chapter 1’s lies in it. Here they are, retired one construct at a time. Nothing below is a rewrite: each step changes one thing and each step runs.
Step 0 — the lie, and the trap under it. Feed the original a row
with no comma at all, and the while walks off the end of the string:
fn main() -> !int {
let rows = """
espresso,340
pastry 275
tip,100
"""
var total = 0
for row in rows.lines() {
var i = 0
while row[i..i + 1] != "," { i += 1 }
total += row[i + 1..].to_int() else 0
}
print("{total}")
0
}
$ lupin step0.lu
step0.lu: trap(bounds): byte range 10..11 is outside a 10-byte string [mem.ub.defined] at 10:15
$ echo $?
3
The trap is doing us a favor by being loud, and it is still the wrong answer: a malformed row is not a defect in the program, it is data the program should have an opinion about.
Step 1 — name the failure. Bound the scan, and give the function a row:
fn cents(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return row[i + 1..].to_int() else 0 }
i += 1
}
no_comma
}
fn main() -> !int {
let rows = """
espresso,340
pastry 275
tip,100
"""
var total = 0
for row in rows.lines() {
total += cents(row)?
}
print("{total}")
0
}
$ lupin step1.lu
error: no_comma
$ echo $?
1
The trap is gone and the program now stops on purpose, which is progress
even though the output got worse. ? was the right first move because it
is the smallest one: it says “this is not my decision” and pushes the
question up one level, where the next step can answer it.
Step 2 — decide, badly. The one-word answer is else:
fn cents(row: str) -> int ! {no_comma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return row[i + 1..].to_int() else 0 }
i += 1
}
no_comma
}
fn main() -> !int {
let rows = """
espresso,340
pastry 275
tip,100
"""
var total = 0
for row in rows.lines() {
total += cents(row) else 0
}
print("{total}")
0
}
$ lupin step2.lu
440
Which is where we started: 440, confidently wrong, and now wrong on
purpose in a place you can find. The decision has
moved from to_int’s default into the caller’s loop, where the person
who knows what a receipt is can see it. But else 0 is still the
posture that turns “your input is broken” into “your total is smaller”.
Step 3 — decide, out loud. Two tags, a handler that reports what it skipped, and a count so the total is never quoted without its caveat:
struct Bad { at: int, found: str }
fn cents(row: str) -> int ! {no_comma, NotANumber(Bad)} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," {
let text = row[i + 1..]
return text.to_int() else { return NotANumber(Bad { at: i + 1, found: text }) }
}
i += 1
}
no_comma
}
cents grew one tag and one nested else, and its signature says so.
The loop is where the decision now lives:
fn main() -> !int {
let rows = """
espresso,340
pastry 275
tip,100
"""
var total = 0
var skipped = 0
for row in rows.lines() {
total += cents(row) else |err| {
skipped += 1
match err {
NotANumber(bad) => print("skipped `{row}`: `{bad.found}` is not a number"),
_ => print("skipped `{row}`: no comma"),
}
0
}
}
print("{"total":<10}{total:>5} ({skipped} row(s) skipped)")
0
}
$ lupin step3.lu
skipped `pastry 275`: no comma
total 440 (1 row(s) skipped)
The number is the same 440 and the program is a different program. It
knows what it skipped, it says so on the way past, and it reports the
total with the fact attached. Three constructs did that: a row in the
signature, ? inside cents for the failure it will not decide, and one
else |err| at the level that can.
Rank the three postures the next time you write one. ? says the caller
decides. A handler says I decide, here, visibly. else 0 says nobody
decides. And a default is a decision, made in the one place that cannot
know whether zero is safe.
Exercise 6-9 (extension · lupin). Three postures toward the input
"7x": trap on it, default it to zero, or refuse it out loud. The mark
the third one hands up keeps nothing, so it is a lowercase bare word,
by §6.1’s pact. Predict all three printed lines of a main that calls
parse_or_zero("7x") and then parse("7x") with a handler. Then the
pointed part: rank the three postures for a program that reads numbers
from a config file, and defend last place.
6.5 Capstone: wordcount
Part 1 ends with the program that started the language. Before wolf had
a compiler it had a syntax argument, and the argument was settled by
writing one program three ways: count the words in some text and report
the most frequent. Everything in it is something you have now written:
let and var, expressions, functions, closures, a List and a Map,
a generic-shaped helper, and errors that are values.
The usage text and the helper that decides whether a word is new:
let USAGE = """
usage: wordcount TEXT
Count the words in TEXT and report the most frequent.
"""
fn index_of(xs: List[str], s: str) -> int {
var i = 0
while i < xs.len {
if xs[i] == s { return i }
i += 1
}
-1
}
USAGE is a top-level let: a value the whole file can read, dedented
by its closing """ exactly as in chapter 2.
The counter, and the two small helpers it strips punctuation with.
count returns two containers, which is what §5.1’s tuples are for: the
tally, and the order the words arrived in.
fn is_mark(c: str) -> bool {
c == "." || c == "," || c == ";" || c == "!" || c == "?"
}
fn bare(word: str) -> str {
var a = 0
var b = word.len
while a < b && is_mark(word[a..a + 1]) { a += 1 }
while b > a && is_mark(word[b - 1..b]) { b -= 1 }
word[a..b]
}
fn count(text: str) -> (List[str], Map[str, int]) {
var order = List[str]()
var tally = Map[str, int]()
for word in text.words() {
let w = bare(word).lower()
if w.is_empty() { continue }
if index_of(order, w) < 0 { (mut order).push(w) }
tally[w] = (tally[w] else 0) + 1
}
(order, tally)
}
words() yields byte views into text and copies nothing (§2.5).
bare walks two indices inward past any of .,;!? and returns the
slice between them, so wolf. and wolf are one word, and lower()
makes the count case-insensitive. It is a helper and not a call to
trim, because trim and its family take no argument
([mem.str.ws]): they strip whitespace and nothing else, and a call
that passes the characters to strip is refused. bare allocates
nothing either: a slice is a view. The if w.is_empty() { continue }
is there because a lone ! strips to nothing, and a tally of empty strings is not what
anybody asked for. The counting line is §5.1’s, unchanged and for §5.1’s
reason: tally[w] answers none on a word’s first appearance, else 0
reads that answer as zero, and the write inserts or replaces. There is no
first-time branch, because there is nothing for one to do — the list
records first-seen order and the map needs no help.
The ranking, by the scan §5.2 wrote:
fn top(order: List[str], tally: Map[str, int], n: int) -> List[str] {
var picked = List[str]()
while picked.len < n {
var best = ""
for w in order {
if index_of(picked, w) < 0 {
if best == "" || tally[w] > tally[best] { best = w }
}
}
if best == "" { break }
(mut picked).push(best)
}
picked
}
top takes both containers and returns a new List; a return moves, so
the caller owns the result and order is untouched for the next call.
The if best == "" { break } is the case where the text has fewer than
n distinct words, and break leaves the loop with what it has.
And main:
fn main() -> !int {
let text = """
The wolf runs, and the moon watches over the wolf.
The pack answers; the moon does not.
"""
if text.is_empty() {
print(USAGE)
return 2
}
let (order, tally) = count(text)
print("{text.words().count()} words, {order.len} distinct")
for w in top(order, tally, 4) {
let name = w[..min(w.len, 12)]
print("{name:<14}{tally[w]:>7} {"#".repeat(tally[w])}")
}
0
}
$ lupin wordcount.lu
17 words, 11 distinct
the 5 #####
wolf 2 ##
moon 2 ##
runs 1 #
Sixty lines, six functions, and a histogram. min(w.len, 12) keeps the
column honest for a long word by slicing it: checked, as always, and
free, because a slice is a view. The return 2 on the usage path is
chapter 1’s exit-code contract: the program declined to do the work, and
it says so with the code the shell can read. The text is embedded, which is what keeps this program’s
output the same on every run and on either tool. Until lupin 0.1.36 it
was load-bearing for a second reason as well — the reference
interpreter had no filesystem at all — and that reason has retired:
the interpreter reads and writes real files now.
Chapter 26 reads real files, chapter 30 takes its names from the command
line, and main’s if is where a caller’s input arrives in both.
The promise this loop owes you. The
for word in text.words()loop incountvisits every word on one core. In Part 3 it becomes parallel by changing one call:text.words()becomes aparover the input’s pieces, the tallies merge, and nothing else in this program changes. Signature, types,top, andmainall stand. Chapter 13 is where that diff gets checked, and it is the chapter’s own obligation to print it. Hold us to it. If parallelizing this program turns out to need a rewrite, the claim was false and the book will say so on that page.
Exercise 6-10 (extension · lupin). The wordcount loop, grown by
one requirement: count words, and separately count words of four bytes
or more. Predict both numbers for the line the wolf runs and the moon watches over, then run.
Exercise 6-8 (design). Suppose cents moves into a library,
behind a public API used by fifty programs. Argue both sides: should the
public signature keep the two-tag row, or coarsen to a single Invalid
tag with the detail inside? Name one concrete caller each design serves
better, and what each design costs when a third failure mode appears.
Exercise 6-11 (extension · lupin). to_int has no inverse on
this shelf, so build one: itoa(n), peeling digits with % 10 and
/ 10 and prepending. Handle zero and negatives, then close the loop:
feed each result back through to_int and make main exit nonzero on
any mismatch. Which of your three test values forced a line of code
the other two never touch?
Exercise 6-12 (extension · lupin). Exercise 5-11’s decoder
answers garbage with garbage: "4w" decodes to nothing, silently.
Harden it by refactor: decode(s) -> str ! {empty, bad_run}, where
empty input, a count with no letter before it, and a zero-length run
are refusals with names. Table four malformed inputs through
else |err| and a match. Which refusal required adding a check,
and which two fell out of checks the loop already had?
Exercise 6-13 (extension · lupin). A date validator:
parse_date("2026-02-29") should refuse, and say why. Split on
-, parse the three fields, and return
(int, int, int) ! {bad_shape, bad_month, bad_day} — reusing 4-7’s
days_in for the day ceiling, leap rule included. Why is bad_shape
checked first, and what happens to your month test if it is not?
That is Part 1. You can write single-threaded wolf now: values that hand themselves over, expressions that yield, arithmetic that refuses to lie, checked signatures, containers, type parameters, and failures that travel as values. You have not heard the word “lifetime” once, and there is exactly one sentence you have been asked to take on trust: that assignment hands the value over, and the reason is chapter 7’s.