Why this matters
Functions are how you break a problem into named pieces you can understand, test and reuse one at a time. In Rust they also reveal a key idea of the language: almost everything is an expression that produces a value, including blocks and (in the next module) if and match. Once you see that, a famous beginner puzzle disappears: why adding a semicolon to the last line of a function can turn working code into a type error.
Defining and calling functions
fn greet(name: &str, times: u32) {
println!("Hello, {name}! (x{times})");
}
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
greet("Ada", 2);
let sum = add(2, 3);
println!("{sum}");
println!("{}", add(sum, 10));
}
Hello, Ada! (x2)
5
15
fn name(param: Type, ...) -> ReturnType { body }.- Every parameter needs a type. Inference works inside function bodies, but signatures are always written out. They are a contract: callers can rely on them without reading the body, and the compiler checks each function against its own signature, which keeps error messages local.
-> i32declares the return type. No arrow means the function returns(), unit.- Functions can be defined in any order;
maincan calladdeven ifaddcomes later in the file. - Function names are
snake_case.
&str is the type of a string literal like "Ada"; module 6 explains it fully.
Statements and expressions
Rust distinguishes two kinds of code:
- An expression evaluates to a value:
5,a + b,add(2, 3),x > 0, a block{ ... }. - A statement performs an action and has no value. The main kinds: a
letbinding, and an expression followed by;, which evaluates it and throws the value away.
fn main() {
let a = 5; // statement (let)
a + 1; // statement: computes 6 and discards it (compiler warns)
let b = a + 1; // the expression a + 1 inside a let statement
println!("{b}"); // a macro call expression, made a statement by ;
}
Because let is a statement and has no value, you cannot write let x = (let y = 6); as you might x = y = 6 in C.
Blocks are expressions
A block { ... } contains zero or more statements followed by an optional final expression without a semicolon. The block's value is that final expression, or () if there is none.
fn main() {
let x = 4;
let y = {
let doubled = x * 2;
doubled + 1
};
let z = {
let doubled = x * 2;
doubled + 1;
};
println!("{y} {:?}", z);
}
9 ()
y is 9. z is (): its block ends in a statement, so it has no final expression. (The compiler warns about the unused value, a strong hint you did not mean it.)
Function bodies return their final expression
A function body is a block, so the function returns the value of its final expression:
fn area(width: f64, height: f64) -> f64 {
width * height
}
No return keyword is needed, and idiomatic Rust leaves it out at the end. Now the puzzle. Add a semicolon:
fn area(width: f64, height: f64) -> f64 {
width * height;
}
fn main() {
println!("{}", area(2.0, 3.0));
}
error[E0308]: mismatched types
--> main.rs:1:37
|
1 | fn area(width: f64, height: f64) -> f64 {
| ---- ^^^ expected `f64`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
2 | width * height;
| - help: remove this semicolon to return this value
With the semicolon, width * height; is a statement, the block has no final expression, so it evaluates to (), but the signature promised f64. The message says exactly that ("implicitly returns () as its body has no tail or return expression") and the help tells you to remove the semicolon. Read it once carefully and you will recognize it instantly from now on.
return for leaving early
return value; exits the function immediately. It is used for early exits, typically guarding against special cases before the main computation:
fn safe_div(a: i32, b: i32) -> i32 {
if b == 0 {
return 0;
}
a / b
}
fn main() {
println!("{} {}", safe_div(10, 3), safe_div(1, 0));
}
3 0
(if gets its own lesson in the next module; the meaning here should be clear.) Returning 0 for division by zero is a questionable design that hides an error; module 8 shows the proper way with Option and Result.
Returning several values
Return a tuple and destructure it at the call site:
fn min_max(a: i32, b: i32, c: i32) -> (i32, i32) {
(a.min(b).min(c), a.max(b).max(c))
}
fn main() {
let (lo, hi) = min_max(7, -2, 5);
println!("min {lo}, max {hi}");
}
min -2, max 7
What happens when you call a function
Each call gets its own stack frame holding its parameters and local variables. Calls nest, so frames pile up on the call stack and are removed in reverse order as functions return:
fn main() calls add(2, 3)
while add runs: after add returns:
+--------------------+ +--------------------+
| add: a = 2 | <- top | main: sum = 5 |
| b = 3 | +--------------------+
| (return addr)|
+--------------------+
| main: sum = ? |
+--------------------+
When main calls add(2, 3), the argument values are copied into add's parameters a and b, the CPU remembers where to come back to, and jumps to add's code. When add finishes, its result is passed back (usually in a CPU register), its frame disappears, and main continues with sum set to 5. Parameters are local to the call: changing a parameter inside a function never changes the caller's variable. (For a parameter you want to modify, write mut before its name: fn f(mut n: i32). It still only changes the local copy.)
That "copied into the parameters" is true for simple values like integers. For values that own memory, such as text read from input, passing to a function moves them, and that is the subject of module 4. The stack picture you have here is the foundation it builds on.
Common mistakes
Missing parameter types: fn add(a, b) is an error: "expected one of :, @, or |". Every parameter needs name: Type.
The trailing semicolon in a function that returns a value: E0308 with "implicitly returns ()", as shown above.
Wrong number or type of arguments:
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
println!("{}", add(1));
}
error[E0061]: this function takes 2 arguments but 1 argument was supplied
--> main.rs:6:20
|
6 | println!("{}", add(1));
| ^^^--- argument #2 of type `i32` is missing
|
note: function defined here
--> main.rs:1:4
|
1 | fn add(a: i32, b: i32) -> i32 {
| ^^^ ------ ------
help: provide the argument
|
6 | println!("{}", add(1, /* i32 */));
| ~~~~~~~~~~~~~~
E0061 shows the signature it expected and suggests the missing argument.
Ignoring a return value. Calling add(1, 2); as a statement computes 3 and discards it. Rust does not warn for plain functions, but many library functions are marked so that ignoring their result does produce a warning (unused_must_use).
Recap
fn name(p: T, ...) -> R { ... }; parameter and return types are mandatory in signatures.- Expressions produce values; statements (
let, andexpr;) do not. - A block's value is its final expression without
;, or()if it has none. - A function returns its body's final expression;
return x;exits early. - Each call gets a stack frame; parameters are local, and arguments are copied in (for simple values) or moved in (module 4).
Going further: write fn main() -> () {} and see that it compiles: () is the return type you always had. Look up rustc --explain E0308 again; you now understand all of its examples.