Functions and expressions
In Rust a function is a name, a list of typed parameters and a return type, and the body is an expression whose value is returned. The last line of a function usually has no semicolon for that reason, and this one rule explains most of the type errors a beginner sees in a function body.
Coming from C++? Functions, overloading and default arguments shows what happens to the two features Rust does not have, and what replaces them.
Rust has no overloading and no default arguments, which is a deliberate trade: a name means one function, and every call says what it passes. This lesson covers the syntax, the two missing features and the way main reports a status.
A function and its parts
fn area(width: u32, height: u32) -> u32 declares a function that takes two unsigned integers and returns one. The types of the parameters are mandatory, and the return type is written after an arrow. There is no implicit return type and no variadic parameter list.
The body is an expression. Here it is a single multiplication with no semicolon, so its value is the value of the function. Adding a semicolon would make the body produce (), and the compiler would reject the function: that mistake is the last section of this lesson.
fn area(width: u32, height: u32) -> u32 {
width * height
}
fn main() {
println!("area: {}", area(3, 4));
}
rustc --edition 2024 basics.rs
./basics
area: 12
Parameters are passed by value unless the type is a reference. For a u32 that means a copy of four bytes; for a String it would mean a move, which is why the next part of the course spends so long on ownership.
Statements and expressions
A statement performs an action and produces nothing; an expression produces a value. A block is an expression: it may contain statements, and its value is the value of its last expression, if that expression has no semicolon. This is how a function body, an if and a plain let initialiser all work the same way.
The program below computes a square plus one inside a block and assigns the result. The inner let is a statement and disappears; value + 1 is the expression that gives the block its value.
fn main() {
let base = 3;
let squared = {
let value = base * base;
value + 1
};
println!("squared: {squared}");
}
rustc --edition 2024 expressions.rs
./expressions
squared: 10
The same rule explains why a semicolon after the last expression is a common bug: the block then produces (), and the surrounding function or binding gets the unit type. The compiler reports it as a type mismatch, which is the message at the end of this lesson.
Early return
return leaves the function at once, with or without a value. It is a statement, so it ends with a semicolon, and the compiler checks that the returned value fits the declared type. A function may have several returns, and the last expression is still the value when none of them fires.
The classifier below returns a string slice for the first two cases and falls through to the last expression for the third. The lifetime 'static says the returned slice lives for the whole program, because all three values are literals; the lesson on lifetimes explains the annotation properly.
fn classify(n: i32) -> &'static str {
if n < 0 {
return "negative";
}
if n == 0 {
return "zero";
}
"positive"
}
fn main() {
for n in [-2, 0, 7] {
println!("{n}: {}", classify(n));
}
}
rustc --edition 2024 early_return.rs
./early_return
-2: negative
0: zero
7: positive
Clippy would suggest match for a chain like this, and the lesson on pattern matching rewrites it. The early return stays useful when the conditions are long and the happy path is the last line.
No overloading
Two functions in one module cannot share a name, whatever their parameters. The second definition is E0428, and the message shows both definitions. C++ resolves the call by the argument types; Rust resolves a call by the name alone, so there is nothing to resolve.
The replacement is a trait: one name, several implementations for different types, and a call that picks the implementation from the receiver's type. For a family of functions that differ only in argument types, generics and traits are the answer, and the lesson on traits builds one.
fn area(width: u32) -> u32 {
width * width
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
fn main() {
println!("{}", area(3));
}
rustc --edition 2024 overloading.rs
error[E0428]: the name `area` is defined multiple times
--> overloading.rs:5:1
|
1 | fn area(width: u32) -> u32 {
| -------------------------- previous definition of the value `area` here
...
5 | fn area(width: u32, height: u32) -> u32 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `area` redefined here
|
= note: `area` must be defined only once in the value namespace of this module
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0428`.
The note at the end says it plainly: the name must be defined once in the value namespace of the module. A function name and a struct name may coexist, because they live in different namespaces, which is why String::from and a variable named String do not clash.
No default arguments
A parameter with a default value does not exist, and a call must pass every argument. The workarounds are visible in the code instead of hidden in a signature: a second function with the full list, a builder with named steps, or an Option parameter that the body interprets.
The example takes the first route, because two functions with clear names are easier to read than one with a default. The lesson on factories and builders takes the second route for the case where the list of settings grows.
fn greet(name: &str) -> String {
format!("Hello, {name}")
}
fn greet_with(greeting: &str, name: &str) -> String {
format!("{greeting}, {name}")
}
fn main() {
println!("{}", greet("Rust"));
println!("{}", greet_with("Hi", "Rust"));
}
rustc --edition 2024 no_defaults.rs
./no_defaults
Hello, Rust
Hi, Rust
A default is a promise that the caller may omit information, and Rust prefers the caller to be explicit. That is a style decision with a cost, and the cost is more names; the benefit is a call that can be understood without opening the definition.
main and ExitCode
main may return () or a type that implements the Termination trait, and std::process::ExitCode is the one to use for a status. ExitCode::SUCCESS is zero and ExitCode::FAILURE is one, and any other number needs ExitCode::from.
The check function returns the code for its own decision, and main passes it to the shell. The program below reports a value that is too large, and the status line after it comes from the shell, not from the program.
use std::process::ExitCode;
fn check(value: u32) -> ExitCode {
if value > 10 {
println!("value too large: {value}");
return ExitCode::FAILURE;
}
println!("value accepted: {value}");
ExitCode::SUCCESS
}
fn main() -> ExitCode {
check(12)
}
rustc --edition 2024 exit.rs
./exit
echo "status: $?"
value too large: 12
status: 1
The next part of the course starts with ownership, because every function that takes a String or a Vec has to decide whether it owns it, borrows it or gives it away.
Exercises
- Add a third parameter to area and predict the error before compiling. Which line does the compiler point at first?
- Turn the classifier into a function that returns a String instead of a &'static str. What has to change in the signature?
- Make check return ExitCode::from(7) and read the status from the shell. Which numbers are legal?
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does a function return when its last expression has no semicolon?
Answer
The value of that expression
The body is an expression, and its value is the value of the last expression without a semicolon.
-
What is the unit type in Rust?
Answer
The type of a value that carries no information, written ()
A function without a return type returns (), and statements produce it.
-
Why can two functions in one module not share a name?
Answer
Rust resolves a call by the name alone, and traits cover the shared case
There is no overload resolution, so one name means one function; a trait provides several implementations.
-
What can
mainreturn besides()?Answer
A type that implements Termination, such as ExitCode
The Termination trait turns the value into the process status, and ExitCode is the standard implementation.
-
What does this program print?
fn area(width: u32, height: u32) -> u32 { width * height } fn main() { println!("{}", area(4, 5)); }Answer
20
The last expression is the value of the function, and four times five is twenty.
-
What does this program print?
fn classify(n: i32) -> &'static str { if n < 0 { return "negative"; } "other" } fn main() { println!("{} {}", classify(-2), classify(3)); }Answer
negative other
The early return fires for the negative value and the last expression handles the rest.
-
What does this program print?
fn main() { let base = 4; let squared = { let value = base * base; value + 2 }; println!("{squared}"); }Answer
18
The block value is sixteen plus two, and the inner let is only a statement.
-
What is the first line of the compiler output for this program?
fn area(width: u32) -> u32 { width * width } fn area(width: u32, height: u32) -> u32 { width * height } fn main() { println!("{}", area(3)); }Answer
error[E0428]: the name
areais defined multiple timesTwo functions with one name are rejected before any call is checked, and the error shows both definitions.
-
What does this program print?
fn classify(n: i32) -> &'static str { if n < 0 { return "negative"; } "other" } fn main() { println!("{} {}", classify(-1), classify(0)); }Answer
negative other
The early return fires for the negative value, and the last expression handles zero.
-
What status does a
mainthat returnsExitCode::FAILUREleave in the shell?Answer
1
SUCCESS is zero and FAILURE is one; other numbers need ExitCode::from.
The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev