4. Functions
The receipt has been one long main for three chapters. It has also been
doing the same thing twice: chapter 2 wrote a comma scan, then wrote it
again to measure a column. Code that appears twice is a function that has
not been named yet.
fn comma(row: str) -> int {
var i = 0
while row[i..i + 1] != "," { i += 1 }
i
}
fn name_of(row: str) -> str { row[..comma(row)] }
fn cents_of(row: str) -> int { row[comma(row) + 1..].to_int() else 0 }
fn main() -> !int {
let rows = """
espresso,340
pastry,275
tip,100
"""
var total = 0
for row in rows.lines() {
total += cents_of(row)
print("{name_of(row):<10}{cents_of(row):>5}")
}
print("{"total":<10}{total:>5}")
0
}
$ lupin fields.lu
espresso 340
pastry 275
tip 100
total 715
The loop body is now two lines that say what they mean, and the byte arithmetic lives in one place where it can be got right once. Nothing in those three signatures is ceremony: every word in them is load-bearing, and this chapter is about which word does what.
4.1 Signatures are the contract
A function declares its parameter types and its return type. It declares nothing about its body:
fn widest(rows: str) -> int {
var width = 0
for row in rows.lines() {
var i = 0
while row[i..i + 1] != "," { i += 1 }
if i > width { width = i }
}
width
}
fn main() -> !int {
let rows = """
espresso,340
pastry,275
tip,100
"""
print("{widest(rows)}")
0
}
$ lupin widest.lu
8
width is an int because 0 is, row is a str because lines()
yields those, i is an int because it starts at zero and gets one
added to it. None of that is written down, and writing it down would add
no information — the compiler works it out from the same evidence you
did. The rule is that inference stops at the boundary: inside a body it
does everything, and at a signature it does nothing.
That line is drawn where it is on purpose. A signature is the only part of a function its callers read, and a function whose parameter types were inferred from its body would change its interface every time somebody edited an expression inside it. So the boundary is spelled, and leaving it out is an error rather than an invitation to guess:
fn cents_of(row) -> int { 0 }
fn main() -> !int {
print("{cents_of("tip,100")}")
0
}
error[E0201]: expected `:` and a type after the parameter name
--> ./s3.lu:4:16
|
4 | fn cents_of(row) -> int { 0 }
| ^
|
Once written, the signature is not documentation that hopes to stay true. It is documentation the compiler enforces, at every call:
fn tax(cents: int) -> int { cents + cents / 20 }
fn main() -> !int {
print("{tax("340")}")
0
}
error[E0401]: this is `str`, but `tax` needs its 1st argument to be `int`
--> ./s4.lu:6:17
|
4 | fn tax(cents: int) -> int { cents + cents / 20 }
| ----- the parameter is declared here
5 | fn main() -> !int {
6 | print("{tax("340")}")
| ^^^^^ found `str` here
|
= note: expected `int`, found `str`
The diagnostic shows both ends: where the promise was made and where it was broken. That is the whole argument for mandatory signatures in one picture — the error names a disagreement between two places, which is possible only because both places said something.
Coming from Python: the annotations look like Python’s type hints and behave nothing like them. A hint is advice for a separate tool; a wolf signature is checked by the compiler that produces the program, so
tax("340")does not run and then fail somewhere insidetaxwith a message about/. The part Python users tend to miss on the way in is the other half of the trade: because bodies are inferred, the annotation burden is a per-function constant rather than per-expression. You write types where a reader needs them and nowhere else.
Exercise 4-5 (comprehension · lupin) — Euclid’s algorithm, in the expression style:
fn gcd(a: int, b: int) -> int {
if b == 0 { a } else { gcd(b, a % b) }
}
Trace gcd(1071, 462) on paper — write down every (a, b) pair the
recursion visits — and state the result before running it.
4.2 Functions as values
A function value is written fn(params) expression, with the types left
off, because at the point where you write one the compiler already knows
what it is being used as:
fn adjusted(cents: int, f: fn(int) -> int) -> int { f(cents) }
fn main() -> !int {
let discount = fn(c) c - c / 10
let tax = fn(c) c + c / 20
print("{adjusted(340, discount)} {adjusted(340, tax)}")
let both = fn(c) tax(discount(c))
print("{adjusted(340, both)}")
0
}
$ lupin policy.lu
306 357
321
adjusted takes a function as its second parameter, and the type of
that parameter is written the way the function is called: fn(int) -> int takes an int and gives an int. both composes two of them with no
apparatus at all — a function value is an ordinary value, so it goes in
a let, into a call, and out of one:
fn rounder(to: int) -> fn(int) -> int {
fn(c) (c + to / 2) / to * to
}
fn main() -> !int {
let nickel = rounder(5)
let dollar = rounder(100)
print("{nickel(273)} {dollar(273)} {dollar(nickel(273))}")
0
}
$ lupin rounder.lu
275 300 300
rounder returns a closure: a function value that carries part of its
environment with it. The to inside the returned body is rounder’s
parameter, and it is still 5 when nickel is called three lines later,
long after rounder has returned. A closure whose body needs a block
takes one — fn(c) { … } with the value as the last expression — and
everything §3.2 said about blocks applies inside it.
Two limits are worth stating while the form is in front of you. A
function value takes no signature of its own: writing
fn(c: int) -> int { … } is a parse error, because the form is defined
as parameters and an expression, and the types come from the context. And
a closure captures a value, never a place — a var read inside a closure
is the value the var held when the closure was made, and a write inside
the closure lands on the closure’s own copy. That rule is what makes a
closure safe to hand to a task, which is chapter 10’s business; here it
means a counter you increment inside a closure is not the counter
outside it.
Exercise 4-1 (fingers · lupin) — Functions are values. Write
compose so that compose(double, double) returns a function, and apply
it to 10.
4.3 defer
Cleanup belongs next to the thing that needs cleaning up, not at the bottom of the function where you will forget it during a refactor:
fn till() -> int {
print("till opened")
defer print("till closed")
print("counting")
715
}
fn main() -> !int {
print("total {till()}")
0
}
$ lupin till.lu
till opened
counting
till closed
total 715
defer takes an expression (or a block) and runs it when the enclosing
function exits, whichever way it exits. The order of the output is the
point: “till closed” is written after “counting” and before the
caller sees the value, because the deferred work happens at the exit,
not where it was written.
Several defers unwind in reverse:
fn main() -> !int {
defer print("3: till closed")
defer print("2: drawer locked")
defer print("1: lights off")
print("0: working")
0
}
$ lupin closing.lu
0: working
1: lights off
2: drawer locked
3: till closed
defer is a stack, because teardown has to unwind what setup wound: the
drawer was locked while the till was open, so it gets unlocked first.
Write acquisitions in order, and the releases arrange themselves.
The stack is built at runtime, not at compile time, and that is the part worth a program of its own:
fn audit(rows: str) -> int {
defer print("audit finished")
var total = 0
for row in rows.lines() {
defer print("row done")
total += row.to_int() else 0
}
total
}
fn main() -> !int {
print("{audit("340\n275")}")
0
}
$ lupin audit.lu
row done
row done
audit finished
615
Two rows, two “row done” lines: a defer inside a loop registers once
per turn. And they all run at the function’s exit rather than at the end
of each turn — defer is scoped to the function, which is what makes it
a resource idiom rather than a block-local one. A defer that was never
reached never registers, which is exercise 4-6’s business.
This is where files would close themselves. The samples above print
instead, because they run under the reference interpreter, which has no
filesystem by design; chapter 26 opens real files under the compiler, and
the shape there is the shape here. The error path — a cleanup that should
run only when things went wrong — is errdefer, which waits for chapter
6 because it needs an error to defer on.
Exercise 4-2 (comprehension · lupin) — Predict the order of the three lines:
fn main() -> !int {
defer print("first registered")
defer print("second registered")
print("body")
0
}
Exercise 4-6 (comprehension · lupin) — A defer is registered
when execution reaches it. Predict all five output lines, in order:
fn work(n: int) -> int {
defer print("one")
if n == 0 { return 10 }
defer print("two")
20
}
fn main() -> !int {
print("{work(0)}")
print("{work(1)}")
0
}
4.4 Borrow by default
Count the sigils in this program:
fn total(rows: List[str]) -> int {
var t = 0
for row in rows { t += row.to_int() else 0 }
t
}
fn widest(rows: List[str]) -> int {
var w = 0
for row in rows { if row.len > w { w = row.len } }
w
}
fn main() -> !int {
var rows = List[str]()
rows.push("340")
rows.push("2750")
print("{total(rows)} {widest(rows)} {rows.len}")
0
}
$ lupin twice.lu
3090 4 2
There are none, and one list was read by two functions and then read
again by main. Compare that with §3.1, where handing a list to a let
ended the original’s usefulness. The rule is one sentence:
A parameter borrows; a return moves.
Passing rows to total lends it for the duration of the call. The
function can read it, cannot keep it, and gives it back by returning —
so the next line still has a list. That is why the two calls compose and
why nothing had to be spelled: reading somebody else’s value for the
length of a call is the overwhelmingly common case, and wolf spends no
syntax on the common case.
Returning is the other direction. A function that builds a value hands it out, exactly as an assignment hands one over:
fn amounts(text: str) -> List[int] {
var out = List[int]()
for row in text.lines() { out.push(row.to_int() else 0) }
out
}
fn main() -> !int {
let cents = amounts("340\n275\n100")
print("{cents.len} rows, first {cents[0]}")
0
}
$ lupin amounts.lu
3 rows, first 340
out is local to amounts and would be finished when the function
returns, except that the last expression hands it to the caller instead.
cents owns it now. No copy was made and no annotation was written:
“returns move” is the same rule as §3.1’s, applied at a function
boundary rather than an assignment.
What the machine does. A borrow is an address. Passing
rowstototalpasses the machine words that locate the list, not the elements — the cost is the same whether the list holds two rows or two million, which is why callingtotal(rows)twice in one line is not a performance question. A return is a move, and a move is a copy of those same few words plus a promise that the source will not be read again — the promise §3.1’s diagnostic enforces. So neither direction copies the elements, and the thing the compiler is actually tracking is not data movement, it is who is allowed to read this next.
One thing this chapter has not shown is a function that changes its
argument. Both total and widest only read, and every sample in Part 1
is like them — which is not a coincidence, and not the whole language.
Mutation through a parameter is written at both ends in wolf, at the
definition and at every call, and both the mechanism and the argument
for it are §7.4’s. Until then: parameters borrow, returns move, and Part
1 does not need a third rule.
Exercise 4-7 (extension · lupin) — Build day_of_year(month, day, leap) from two functions: days_in(month, leap) as one match, and a
loop that sums the months before yours. Verify: March 1st is day 60 in a
common year. Which date is day 60 in a leap year, and where in your code
did that difference come from?
Exercises 4-3 and 4-4 belong to this section’s material and are set in
§7.4 instead, where the one word they need — a call-site mut — has
been taught. They keep their numbers; Part 1 does not print them.