3. Values and expressions
Chapter 2 was about one type. This chapter is about the language around it: what a name holds, what a piece of code hands back, and what arithmetic does when the answer will not fit.
The receipt has been printing what it read. Now it has an opinion about it:
fn kind(name: str) -> str {
match name {
"tip" => "gratuity",
"espresso" => "drink",
_ => "food",
}
}
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 }
let name = row[..i]
let cents = row[i + 1..].to_int() else 0
let mark = if cents > 300 { "*" } else { " " }
total += cents
print("{mark}{name:<10}{kind(name):<9}{cents:>5}")
}
print(" {"total":<19}{total:>5}")
0
}
$ lupin categories.lu
*espresso drink 340
pastry food 275
tip gratuity 100
total 715
Look at what kind does not contain: a return. Its whole body is one
match, and the value of that match is the value of the call. mark
is the value of an if. Neither is a special form; both are the ordinary
way wolf spells a choice, and the two rules behind them run this whole
chapter. A name holds one value for good, or holds a series of them. Code
that looks like a statement is an expression with a value you may keep or
discard.
3.1 let, var, and handing values over
Both spellings appeared in chapter 1 without explanation. Here they are side by side at the prompt:
wolf> let width = 10
wolf> width
10 : i32
wolf> var total = 0
wolf> total += 715
wolf> total
715 : i32
wolf> let frozen = total
wolf> total = 0
wolf> "{frozen} {total}"
715 0 : str
wolf> :type frozen
i32
let names a value once. var names a place that takes a series of
them: total is 0, then 715, then 0 again, and it is the same total
throughout — one name, one type, new contents. frozen is a let, so
it is 715 for as long as it exists, and the later total = 0 does not
reach it.
Reach for let first. It is not a stylistic preference; it is the
narrower claim, and the compiler holds you to it:
fn main() -> !int {
let width = 10
width = 12
print("{width}")
0
}
error[E0410]: `width` is bound with `let`, so it cannot be assigned again
--> ./s1.lu:6:5
|
5 | let width = 10
| --- the binding is made immutable here
6 | width = 12
| ^^^^^ this assignment needs a mutable binding
|
= note: `let` names a value once. Declare the binding with `var` to update it in place, or
shadow it with a second `let` if the next value is really a new thing.
help: make the binding mutable: `var`
|
5 | var width = 10
|
The note names both repairs, and they are different claims about your
program. var width says this quantity changes over time. A second
let width says the old value is finished and a new thing is taking its
name. Wolf lets you do either and asks you to say which.
So far every value has been a number or a string slice — two words at
most, copied wherever they go. Values get bigger than that. A List owns
its elements:
fn main() -> !int {
var names = List[str]()
names.push("ada")
names.push("grace")
let roster = names
print("{roster.len} names")
0
}
$ lupin roster.lu
2 names
That third line is the sentence of this section: assignment hands the
value over. roster holds the list now. Not a second list with the
same contents, and not a view onto somebody else’s list — the list, the
one names was holding, handed across.
Which leaves a question about names, and wolf answers it before the
program runs:
fn main() -> !int {
var names = List[str]()
let roster = names
print("{roster.len} {names.len}")
0
}
error[E1001]: `names.len` is used here after its value moved away
--> ./s3.lu:7:26
|
6 | let roster = names
| ----- `names` moved here
7 | print("{roster.len} {names.len}")
| ^^^^^^^^^ used after the move
|
= note: `names.len` is part of `names`; moving one empties the other. Disjoint fields stay
usable.
= note: re-initializing the place (assigning to it) also makes it usable again.
help: to keep the original, copy it at the move
|
6 | let roster = copy names
|
The interpreter enforces the same rule at the other end of the day — the program starts, reaches the same read, and stops there:
$ lupin roster.lu
roster.lu: trap(use-after-move): `names` was moved out and is uninitialized here [mem.tier0.move.2] at 96..105; `names` moved here at 65..70
$ echo $?
3
Two tools, one rule, the two enforcement moments chapter 1 set up: the
compiler proves it before the program starts, the interpreter catches it
in the act. Neither invents a value for names to have.
The second note is worth taking literally. names is not poisoned, it is
empty — hand it something and it is a working name again:
fn main() -> !int {
var names = List[str]()
names.push("ada")
let roster = names
names = List[str]()
names.push("grace")
print("{roster.len} {names.len}")
0
}
$ lupin reinit.lu
1 1
That is the whole of the story this chapter tells. There is a much
longer one — why the rule exists, what it buys, why a language would
choose it over a garbage collector, and what the copy in the
compiler’s suggestion costs — and it is chapter 7’s, which opens on this
diagnostic and does not stop for a while. Part 1 needs only the
direction of the handover and the two verdicts above. You will write a
great deal of wolf on that much.
Exercise 3-8 (comprehension · lupin) — Predict the one printed
line, then answer the pointed part: after name = "grace", what
happened to "ada" — and why does first not care?
fn main() -> !int {
var name = "ada"
let first = name
name = "grace"
print("{first} {name}")
0
}
Exercise 3-2 (comprehension · wolf + lupin) — The pack loses its
lead. On the exercise page is a short program: a struct with two string
fields, a function that takes one of them away, and two later reads —
one of the field that left, one of the field that stayed. Before
running anything, write down two predictions: what wolf says about it,
and what lupin does with it. Which line does each tool blame, and why
is the read of the other field not the one? (The program spells the
handover with one word this chapter has not taught, take; read it as
“hands the field over” and chapter 7 will do the rest.)
3.2 Everything is an expression
An expression is anything with a value. In wolf that includes the constructs other languages make statements, which is why there is no ternary operator and nothing missing where one would be:
fn main() -> !int {
let cents = 275
let band = match cents / 100 {
0 => "under a dollar",
1 => "a dollar and change",
2 => "two-ish",
_ => "expensive",
}
let mark = if cents > 300 { "*" } else { "" }
let padded = {
let body = "{cents}"
"{body:>6}"
}
print("{padded} {band}{mark}")
0
}
$ lupin bands.lu
275 two-ish
Three shapes, one rule. A match has the value of the arm that applied.
An if/else has the value of the branch that ran. A brace-delimited
block has the value of its last expression — which is why padded is a
string and why main ends with a bare 0 and no return. A block also
scopes what it declares: body exists inside those braces and nowhere
else, so a temporary name in a long computation costs nothing outside
it.
Because the value comes from the last expression, an if used for its
effect and an if used for its value are the same if. Chapter 1’s
receipt wrote if i > width { width = i } and threw the value away.
Here it is kept. Nothing about the construct changed.
The reading rule has one consequence worth meeting on purpose: if a
match is a value, its arms have to agree what type that value is.
fn main() -> !int {
let n = 1
let x = match n {
0 => "none",
_ => 7,
}
print("{x}")
0
}
error[E0401]: the arms of this `match` disagree about its type
--> ./s7.lu:8:14
|
7 | 0 => "none",
| ------ but this one is `str`
8 | _ => 7,
| ^ this arm is `{integer}`
|
= note: a `match` used as a value produces one type; neither arm is more "right" — make both
`str`, or both `{integer}`, or move the `match` into statement position.
“Neither arm is more right” is the compiler declining to guess. A language that picked one would be picking for you, in a program where one of the two arms is the typo.
Loops are the exception that proves the rule, and there are three of
them. while repeats while a condition holds. for walks a sequence —
including a range, written a..b, which yields a up to but not
including b. And loop repeats until something in the body says stop,
which is the one that carries a value out: break v makes v the value
of the whole loop.
fn main() -> !int {
let row = "espresso,340"
var a = 0
while row[a..a + 1] != "," { a += 1 }
var b = 0
for i in 0..row.len {
if row[i..i + 1] == "," {
b = i
break
}
}
var c = 0
let d = loop {
if row[c..c + 1] == "," { break c }
c += 1
}
print("{a} {b} {d}")
0
}
$ lupin three.lu
8 8 8
Three spellings of chapter 2’s comma scan, same answer, and the third is
the only one where the answer is the loop’s own value rather than
something a var was carrying out for it. break with no value leaves
the loop; continue skips to the next turn. When the count is known,
for i in 0..n says so; when the exit condition is the point, loop
plus break v says that instead.
Exercise 3-5 (design) — Wolf has no ternary operator. Write the
expression you would have used one for, in wolf, and then argue either
side: is if-as-expression enough?
3.3 Arithmetic that traps
The receipt’s till is nearly full. Two more rows go in:
fn main() -> !int {
var till: i32 = 2147483000
for row in "700\n700".lines() {
till += row.to_int() else 0
}
print("{till}")
0
}
$ lupin till.lu
till.lu: trap(overflow): `+` produced 2147483700, outside `i32` — checked arithmetic traps in every profile (X3); spell intended overflow `wrapping[i32]` [arith.checked] at 95..122
$ echo $?
3
Push an i32 past its ceiling and the program does not wrap, does not
continue, and does not negotiate. That is a trap: the fault of a
defined execution. The program was legal, it ran, and it hit a rule wolf
enforces at runtime — in release builds too, which is the part that
surprises C programmers. The line names the operation, the true product,
the type that could not hold it, the decision it enforces, and the
spelling for the rare case where wrapping was the plan. Nothing here is
undefined; the program’s last act is to tell you exactly what happened.
Most bugs should be so polite.
Division by zero is the same kind of event, and it does not matter how the zero arrived:
fn main() -> !int {
let rows = ""
var total = 0
var count = 0
for row in rows.lines() {
total += row.to_int() else 0
count += 1
}
print("mean {total / count}")
0
}
$ lupin mean.lu
mean.lu: trap(div-zero): division by zero is defined behavior in wolf: it traps [mem.ub.defined] at 183..196
$ echo $?
3
An empty input means the loop never ran, which means count is still 0,
which means the average of nothing is a trap rather than whatever the
hardware felt like. The bug is in the program — averages of empty
collections are the caller’s problem — and the trap is where you find it,
with the clause it enforces attached.
When wraparound is what you want, the type says so, and then it is not a fault at all:
fn main() -> !int {
var checksum: wrapping[i32] = 2147483647
checksum += 1
var clamped: saturating[i32] = 2147483647
clamped += 1
print("{checksum} {clamped}")
0
}
$ lupin wrap.lu
-2147483648 2147483647
wrapping[i32] wraps to the bottom of the range; saturating[i32]
stops at the top and stays. Both are ordinary types you can put on a
binding, a field, or a parameter, which means the decision is written
where the data is declared rather than at each operation, and a reader
of checksum += 1 can look up what that line meant. A hash function
wants wrapping. A gauge wants saturating. A receipt total wants
neither, which is why the trap above is the right answer.
The bill for checking. Every arithmetic operation in wolf carries a test the C version does not: compute, check the flag the hardware already set, branch to the trap on failure. The branch is never taken in a working program, which is the case processors predict best, and the check is hoistable out of loops where the compiler can prove the range — the same analysis that removes bounds checks. So the honest answer to “what does X3 cost” is a measurement, and this book does not make measured claims without the measurement: chapter 21 runs the comparison against C, prints the numbers CI produced on a dated machine, and names the cases where the check is still visible. What the decision buys is not in dispute: the alternative is not “free arithmetic”, it is arithmetic whose failures are undefined, and an undefined result is a bug that surfaces somewhere other than where it happened.
Exercise 3-3 (comprehension · lupin) — 2147483647 is i32’s
ceiling. Predict what big + 1 does in a release build. (Trick warning:
the answer is the same in every build.)
Exercise 3-4 (comprehension · lupin) — The divisor is computed, not literal. Does that change anything?
fn main() -> !int {
let n = 10
let d = n - 10
print("{n / d}")
0
}
3.4 match, exhaustively
A match compares a value against patterns in order and takes the first
that applies. The exit codes from chapter 1 make a small table of one:
fn label(code: int) -> str {
match code {
0 => "ok",
2 => "rejected",
3 => "trapped",
4 => "unsupported",
_ => "the program's own",
}
}
fn main() -> !int {
for code in 0..5 {
print("{code} {label(code)}")
}
0
}
$ lupin codes.lu
0 ok
1 the program's own
2 rejected
3 trapped
4 unsupported
The _ arm matches anything and has to come last, because an arm after
it could never run. It is also the only reason this match compiles:
delete it, and the compiler produces a counterexample.
fn label(code: int) -> str {
match code {
0 => "ok",
2 => "rejected",
3 => "trapped",
4 => "unsupported",
}
}
fn main() -> !int {
print("{label(1)}")
0
}
error[E0801]: this `match` does not cover `1`
--> ./s14.lu:5:5
|
5 | match code {
| ^^^^^^^^^^^^
6 | 0 => "ok",
...
10 | }
| ^^^^^ not every value is matched
|
= note: add arms for the missing cases, or end the `match` with a `_` arm to catch the rest
deliberately.
1 is a witness. The compiler did not say “this looks incomplete”; it
handed you a value your program has no answer for, which is a fact you
can check by hand and cannot argue with. A match on a bool gets the
same treatment with false as the witness, and the value the compiler
picks is always one of the cases you forgot.
Under the interpreter the same program starts, gets as far as the call, and declines to invent an arm:
$ lupin partial.lu
partial.lu: unsupported: no `match` arm applied; exhaustiveness is the type checker's
$ echo $?
4
That is the division of labor: totality is a static property, the type
checker owns it, and the interpreter refuses the program rather than
guessing which arm the author meant. Run the file through wolf and you
get the witness instead of the refusal.
Arms take more than one pattern when the answer is the same:
fn main() -> !int {
for code in 0..5 {
let verdict = match code {
0 => "the program's own",
2 | 3 | 4 => "the toolchain's",
_ => "the program's own",
}
print("{code} {verdict}")
}
0
}
$ lupin verdicts.lu
0 the program's own
1 the program's own
2 the toolchain's
3 the toolchain's
4 the toolchain's
Two arms say the same words here, and that is a hint about the shape of
the data rather than a flaw in the match: exit codes are not really one
number, they are a choice between “the program returned” and “the
toolchain refused”. Chapter 6 gives that kind of choice a type of its
own, and the match that walks it gets its exhaustiveness checked the
same way this one does.
Exercise 3-1 (comprehension · lupin) — Predict the one line this
prints. Both match and if are expressions here; nothing is a
statement:
fn main() -> !int {
let n = 3
let kind = match n {
0 => "none",
1 => "one",
_ => "many",
}
let parity = if n % 2 == 0 { "even" } else { "odd" }
print("{kind} and {parity}")
0
}
Exercise 3-6 (extension (break-it-on-purpose) · lupin) — Using one
i32 binding and one *, write the smallest program that traps with
overflow on i32. State, before running it, why the number you chose
is the smallest one that works, and what the trap line will say the
product was.
Exercise 3-7 (comprehension · lupin) — Predict both lines. If you arrived from Python, predict them twice:
fn main() -> !int {
let a = 0 - 7
let b = 2
print("{a / b} {a % b}")
print("{(a / b) * b + a % b}")
0
}