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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
NoComma
}
fn main() -> !int {
print("{comma("tip,100") else -1} {comma("tip 100") else -1}")
0
}
$ lupin comma.lu
3 -1
Read int ! {NoComma} as “an int, or the failure NoComma.” The !
separates the success type from the row: the set of tags this function
may fail with, written between braces, and complete. Not “some errors” —
these errors. 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 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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
NoComma
}
fn cents(row: str) -> int ! {NoComma, 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. NoComma 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.
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.
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. Neither is free, 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 TooLong
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(0 - 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 handle it. Wolf spells them with one character, one keyword, and one keyword with a binding.
? 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, unwrapped and unwrapped again by nobody,
which is why NoComma 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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
NoComma
}
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 not a statement bolted onto the side, it is the other branch
of a value. Between them sits else { … } with no binding, for when the
fact of failure is enough. And ? is what you write when the honest
answer is “not my decision” — the case that used to be four lines of
plumbing.
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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
NoComma
}
fn cents(row: str) -> int ! {NoComma, 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 — which is the whole reason to put detail in a tag
rather than in a printed string: the fact travels to the code that owns
the policy, and the policy decides whether to print it, point at it, or
count it.
Coming from Go:
if err != nil { return err }and?do the same job, and the difference is not brevity. 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 not a style violation, it is a type error. What Go buys with the verbosity is that every propagation is visible at a glance, and this book will not pretend that is worthless — it is why?is a character at the end of the call rather than an invisible unwinding. The propagation is still written down. It is 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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
NoComma
}
fn main() -> !int {
let at = comma("tip 100")?
print("comma at {at}")
0
}
$ lupin top.lu
error: NoComma
$ echo $?
1
The tag on stderr and exit 1 — 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, and the deciding is
what §6.4 is about.
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.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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return i }
i += 1
}
NoComma
}
fn scan(text: str) -> int ! {NoComma} {
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 187..200
$ 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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return row[i + 1..].to_int() else 0 }
i += 1
}
NoComma
}
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: NoComma
$ 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 ! {NoComma} {
var i = 0
while i < row.len {
if row[i..i + 1] == "," { return row[i + 1..].to_int() else 0 }
i += 1
}
NoComma
}
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. That is not nothing — 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 ! {NoComma, 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
}
NoComma
}
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. 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
}
0 - 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. It returns two containers, which is what §5.1’s tuples are for: the tally, and the order the words arrived in.
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 = word.trim(".,;!?").lower()
if w.is_empty() { continue }
let at = index_of(order, w)
if at < 0 {
order.push(w)
tally[w] = 1
} else {
tally[w] += 1
}
}
(order, tally)
}
words() yields byte views into text and copies nothing (§2.5).
trim(".,;!?") removes any of those characters from both ends, so
wolf. and wolf are one word, and lower() makes the count
case-insensitive. The if w.is_empty() { continue } is there because a
lone - trims to nothing, and a tally of empty strings is not what
anybody asked for.
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 }
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 #
Fifty lines, four 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, and
that is what lets this program run identically under both
implementations: the reference interpreter has no filesystem, by design.
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: not the signature, not the types, and not one line oftopormain. 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.
That is Part 1. You can write single-threaded wolf now: values that hand themselves over, expressions that yield, arithmetic that refuses to lie, functions whose signatures are checked, containers, type parameters, and failures that travel as values with their names on. 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.