The elephant in the room is that you've been writing a function since chapter one... 😬👉👈
Every fn main() { ... } is one. And every println!(...) is a call,
though println! is a macro, which is why it has the !. So this chapter isn't
really introducing functions. It's introducing the parts of fn we've quietly
skipped: parameters, return types, the body, and a few small Rust-specific
quirks.
fn add(a: i32, b: i32) -> i32 {
a + b
}
Reading left to right: fn says "this is a function", add is its
name, the parentheses list the parameters with their types, and -> i32
declares the return type. Every parameter type and the return type are
spelled out explicitly. Rust never guesses these for you.
To call a function, write its name with the arguments in parentheses:
let sum = add(2, 3); // sum: i32 = 5
The body of a function is a block, and a block is one or more statements followed by an optional final expression. The final expression (if there is one, and it has no trailing semicolon) becomes the value of the block, which becomes the return value of the function.
fn double(n: i32) -> i32 {
n * 2 // no semicolon: this is the return value
}
You can also use an explicit return, which is occasionally useful for
early exits, but the no-semicolon form is more idiomatic for the final
value:
fn double(n: i32) -> i32 {
return n * 2; // works too, but unusual at the end
}
That semicolon thing trips up newcomers. The rule is short: a
semicolon turns an expression into a statement (which has no value).
Forgetting one at the end of the function is the correct thing to do
when you want the value to be returned. Adding one accidentally turns
the body into "do this, then return ()" and the compiler will
complain that the types don't match. The first exercise lets you feel
that error first-hand.
The three exercises after that each pull at one more thread: returning nothing, returning a value built recursively, and modifying a parameter inside the body.
Start with the smallest function you can. Single-purpose functions are easier to test and easier to read.
Use parameter names that say what the value is, not what type it
is: width: u32, not w: u32.
Prefer the least demanding parameter type that still lets you do
the job. "Least demanding" means: ask the caller for as little as
possible. If you only need to read a string, take &str, not
String. Three reasons this matters:
String would force the caller to hand over their value (or
.clone() it). Taking &str lets them keep it.&str parameter accepts string literals ("hi"), borrows of owned
strings (&my_string coerces from &String to &str), and slices of
larger buffers, all without conversion at the call site.&str is just a pointer and a length; passing one costs nothing. A
String parameter would mean moving (or cloning) a heap buffer on every
call.The same idea extends to other types: take &[T] instead of
&Vec<T>, &Path instead of &PathBuf, and so on. We'll come
back to this pattern in the vectors and ownership chapters.
This function takes an i32, declares an i32 return type, multiplies by two. Yet, the compiler refuses to compile.
Run the tests, read the error, and fix it.
The lesson hiding behind that error is the difference between an expression (which has a value) and a statement (which doesn't). One character decides which a line is, and that character decides what your function returns.
Time to write a function from scratch. countdown(n) should print
the numbers from n down to 1, each on its own line, and then
print "Liftoff!". If n == 0, it should print only "Liftoff!".
countdown(3)
3
2
1
Liftoff!
This exercise pulls in two new ideas.
Not every function hands back a value. countdown exists for its
side effect (printing), so there's nothing meaningful to return.
In Rust, that means the return type is (), the unit type, an empty
tuple. You don't have to write it. A function with no -> Type is
implicitly -> ():
fn greet() {
println!("hi");
}
// equivalent to:
fn greet() -> () {
println!("hi");
}
fn main() is exactly this: a function that returns (). You've
been writing one since chapter one.
A function in Rust can call itself. That's recursion, and it's the technique you'll use here instead of a loop.
A recursive function has two parts:
For countdown, the base case is n == 0 (just print "Liftoff!").
The recursive case prints n and then calls countdown with n - 1.
Each call gets its own fresh parameters, stacked on top of the
previous one, until the base case unwinds the whole stack.
Useful from the standard library
println!prints a line. You've already used it inmain. It's a macro (note the!), but you call it just like a function.
if/else. One branch is the base case,
the other is the recursive case.n == 0 and just prints "Liftoff!". The
recursive case prints n and calls countdown(n - 1).fn countdown(n: u32) {
if n == 0 {
println!("Liftoff!");
} else {
println!("{n}");
countdown(n - 1);
}
}
Now do recursion the other way around. Instead of printing as you go, return a value built up from smaller answers.
Write sum_to(n) so it returns 1 + 2 + ... + n, with
sum_to(0) == 0.
The base case and recursive case look like this:
sum_to(0) = 0 // base case
sum_to(n) = n + sum_to(n - 1) // for n > 0
This is the lightbulb moment for recursion: a function's answer can be defined in terms of its own answer to a smaller version of the same problem. Once the base case is reached, every pending call finishes its addition and the final total bubbles back up.
countdown: an if with a base case
(n == 0) and a recursive case that calls sum_to(n - 1).n + sum_to(n - 1). No mut, no
let, no return.fn sum_to(n: u32) -> u32 {
if n == 0 { 0 } else { n + sum_to(n - 1) }
}
Write cap_at(value, max) so that it returns value if it's at or
below max, and max otherwise. Both arguments are i32. The
logic is one if away.
Write the function the most natural way you can think of. The most natural way doesn't compile. Read the error carefully before changing anything; it tells you exactly what's wrong, and the fix is one keyword in one place.
value. Function
parameters are immutable bindings by default, just like let.mut to the parameter binding (not the type):
fn cap_at(mut value: i32, max: i32) -> i32.fn cap_at(mut value: i32, max: i32) -> i32 {
if value > max {
value = max;
}
value
}
Four small exercises, four new ideas:
stray_semicolon drove home the difference between an
expression and a statement: one trailing ; is the line between
"return this value" and "return ()".countdown introduced the unit return type () and the basic
form of a recursive function: a base case plus a recursive case.sum_to turned that into a value-returning recursion,
where each call's answer feeds into the caller's answer.cap_at showed that function parameters are immutable
bindings by default, and that adding mut to the parameter name
affects only the function's local copy, not the caller's variable.The thread running through all of this: the body of a function is a block whose final expression (no trailing semicolon) is the return value. Everything else in the chapter is a consequence of that one rule.