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

  1. Add a third parameter to area and predict the error before compiling. Which line does the compiler point at first?
  2. Turn the classifier into a function that returns a String instead of a &'static str. What has to change in the signature?
  3. 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.

  1. 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.

  2. 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.

  3. 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.

  4. What can main return 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 area is defined multiple times

    Two functions with one name are rejected before any call is checked, and the error shows both definitions.

  9. 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.

  10. What status does a main that returns ExitCode::FAILURE leave 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