Functions
Declaring Functions
Section titled “Declaring Functions”Use fn to declare a function. Every parameter must have an explicit type annotation — the compiler does not infer parameter types. The return type follows ->.
fn add(a: i32, b: i32) -> i32 { a + b}Functions can be defined before or after they are used — Rust does not require forward declarations.
Statements vs Expressions
Section titled “Statements vs Expressions”This is the most important distinction in Rust function bodies:
- A statement performs an action and does not produce a value.
let x = 5;is a statement — it ends with a semicolon. - An expression evaluates to a value.
5 + 3is an expression. Blocks{ ... }are expressions too.
fn main() { // This is a statement — no value produced let y = 6;
// This is an expression block — produces 12 let z = { let x = 3; x * 4 // no semicolon — this is the block's value };
println!("y={}, z={}", y, z);}Adding a semicolon to the last line of a block converts it from an expression to a statement — the block now returns the unit type () instead of the computed value.
What the Compiler Says: A Stray Semicolon Changes the Return Type
Section titled “What the Compiler Says: A Stray Semicolon Changes the Return Type”Because the function body is itself a block, this rule applies to fn bodies too — and the compiler reports it as a type mismatch against (), not against whatever type you expected:
// @expect-error E0308fn square(n: i32) -> i32 { n * n;}
fn main() { println!("{}", square(4));}error[E0308]: mismatched types --> src/main.rs:2:22 |2 | fn square(n: i32) -> i32 { | ------ ^^^ expected `i32`, found `()` | | | implicitly returns `()` as its body has no tail or `return` expression3 | n * n; | - help: remove this semicolon to return this value
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.Notice the compiler’s own diagnosis: with the semicolon present, the function body has no tail expression at all, so it implicitly returns () — the fix is deleting the semicolon, which the help: line points at directly. This is one of the most common first compiler errors a newcomer hits, and it is always this same one-character fix.
The Trailing-Expression Return
Section titled “The Trailing-Expression Return”The last expression in a function body — written without a semicolon — is the return value. You can also use the return keyword for early returns, but idiomatic Rust prefers the trailing expression for the main return path.
fn square(n: i32) -> i32 { n * n // returned because it has no semicolon}
fn max_of_two(a: i32, b: i32) -> i32 { if a > b { a } else { b } // if is an expression}The describe function in the playground below returns &'static str: a reference to a string literal, which is baked into the binary and lives for the entire program. 'static means exactly this — “valid for the entire program” — and string literals always qualify; you’ll see 'static and other lifetimes properly in the Traits & Generics module.
The Unit Type
Section titled “The Unit Type”Functions that do not return a meaningful value implicitly return () (the unit type). The signature fn foo() is shorthand for fn foo() -> (). The unit type is similar to void in other languages but is a real type with exactly one value: ().
fn greet(name: &str) { // implicitly returns () println!("Hello, {}!", name);}todo! and unimplemented!
Section titled “todo! and unimplemented!”While sketching out a function’s shape before filling in the body, todo!() and unimplemented!() let the code compile (they type-check as any type, including -> !, covered next) while panicking loudly if that path is ever actually reached at runtime:
// @expect-panicfn compute_total(items: &[i32]) -> i32 { if items.is_empty() { return 0; } todo!("implement the real calculation")}
fn main() { println!("{}", compute_total(&[1, 2, 3]));}thread 'main' (<id>) panicked at src/main.rs:6:5:not yet implemented: implement the real calculationnote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThis is strictly better than a bare panic!("todo"): todo! and unimplemented! both signal intent in the message and in the type checker’s eyes (a reader — or a grep) can immediately tell “this path is unfinished” apart from “this path is a genuine runtime error.”
The Never Type !
Section titled “The Never Type !”A function that never returns at all — because it always panics, loops forever, or calls std::process::exit — can be annotated -> !, the never type. ! is special: it coerces to any other type, which is exactly what lets a fail(...) branch stand in for a String branch inside an if/else or match whose other arms return a real value:
fn fail(msg: &str) -> ! { panic!("{}", msg);}
fn describe_or_fail(n: i32) -> &'static str { if n >= 0 { "non-negative" } else { fail("negative numbers are not supported") }}Function Pointers
Section titled “Function Pointers”A function’s name, without calling it, can itself be passed around as a value of type fn(ArgTypes) -> ReturnType — a plain function pointer, distinct from (and simpler than) the closures covered in the Collections & Iterators module:
fn double(n: i32) -> i32 { n * 2 }fn triple(n: i32) -> i32 { n * 3 }
fn apply(f: fn(i32) -> i32, value: i32) -> i32 { f(value)}
fn main() { println!("{}", apply(double, 5)); // 10 println!("{}", apply(triple, 5)); // 15}flowchart TD
B["function body { ... }"] --> T{"last line has a ;?"}
T -->|"no"| E["tail expression
-> its value is returned"]
T -->|"yes"| U["last line is a statement
-> body returns ()"]
E --> M{"does the fn signature
say -> SomeType?"}
U --> M
M -->|"types match"| OK["compiles"]
M -->|"types don't match"| ERR["E0308: mismatched types"] fn add(a: i32, b: i32) -> i32 { a + b // trailing expression -- no semicolon -- is the return value}
fn describe(n: i32) -> &'static str { if n > 0 { "positive" } else if n < 0 { "negative" } else { "zero" }}
fn unit_example() { // Functions without -> return the unit type () println!("unit example");}
fn main() { let sum = add(3, 4); println!("add(3, 4) = {}", sum);
println!("describe(10) = {}", describe(10)); println!("describe(-3) = {}", describe(-3)); println!("describe(0) = {}", describe(0));
unit_example();}Compiling…
Mental Model
Section titled “Mental Model”Every block in Rust — a function body, an if branch, a loop, a bare { } — follows the exact same rule: its value is its last expression, unless that expression is terminated by a semicolon, in which case the block’s value is (). A function signature’s -> T is simply the compiler’s promise-check applied to the outermost block. Once you see functions this way, if-as-expression, block-scoped shadowing, and the semicolon rule all collapse into one idea instead of three separate ones to memorize.
Trade-offs
Section titled “Trade-offs”Using a semicolon as the single character that decides “value” versus “no value” is extremely terse — you never write return for the common case — but it means a single stray or missing semicolon changes a function’s meaning rather than merely its formatting, unlike languages where whitespace or semicolons are cosmetic. The payoff is that expressions compose uniformly: an if, a match, a loop, and a plain block are all interchangeable wherever a value is expected, with no special-casing needed for any of them.
Common Misconceptions
Section titled “Common Misconceptions”- “Semicolons in Rust are just statement terminators, like in C or JavaScript.” They usually are — but on the very last line of a block, a semicolon is also the switch that discards that line’s value and makes the block return
()instead. - “
todo!()is the same as leaving a// TODOcomment.” A comment does not stop execution;todo!()(andunimplemented!()) type-checks as any type so the surrounding code compiles today, but panics the instant that exact line actually runs — which is the point: it is safe to ship code with unreachabletodo!()s, unsafe to ship code that silently does the wrong thing. - “A function pointer (
fn(i32) -> i32) and a closure are the same thing.” A closure can capture variables from its environment; a plainfnpointer cannot capture anything — it is just the address of a standalone function. Every non-capturing closure can coerce to a function pointer, but the reverse is not true.
Reading AI-generated code
Section titled “Reading AI-generated code”AI assistants sometimes leave a stray value at the end of a function that was refactored from “return something” to “just print it,” because the trailing line was not touched during the edit — the semicolon that should have been added to turn it back into a ()-returning statement gets forgotten.
🐛 Spot the bug
fn log_status(code: i32) { println!("status: {}", code); code}Show the answer
log_status has no -> i32 in its signature, so it is expected to return () — but the trailing code (no semicolon) makes it the tail expression, and code is an i32. This fails to compile with error[E0308]: mismatched types, expected (), found i32, and the compiler’s own help: even offers “try adding a return type: -> i32” as one fix. The other fix — the one that matches the function’s actual intent here — is simply adding a semicolon after code so the value is discarded.