Functions in Rust
A function in Rust is a named, reusable block of code that takes some inputs (parameters), does work, and optionally produces an output (a return value). Functions are the basic unit of organization in every Rust program — even main is one — and Rust’s rules about how values move into and out of functions are your first real encounter with the ownership system that makes the whole language distinctive.
Overview: how functions work in Rust
Every Rust program has at least one function, fn main(), which is the entry point the compiler looks for when it builds an executable. You define additional functions with the fn keyword, and you can place them before or after the code that calls them — unlike variables, function definitions are visible throughout the module regardless of the order they appear in the source file. This is different from Python or JavaScript, where a function generally has to be defined before it is used at the top level.
The detail that surprises newcomers most is that Rust draws a sharp line between statements and expressions, and that line determines how a function returns a value. A statement performs an action and produces no value; an expression evaluates to a value. In Rust, adding a semicolon to the end of a line turns whatever expression was there into a statement, throwing away its value. A function body is a block, and the value of a block is the value of its final expression — if that final line has no trailing semicolon. That is why you will see functions like this:
fn add(a: i32, b: i32) -> i32 {
a + b
}
There is no return keyword here. The expression a + b is the last line of the block and has no semicolon, so its value becomes the value the block evaluates to, and that becomes the function’s return value. You can still use an explicit return statement, and it works exactly like in C or Java — it exits the function immediately with the given value, which is essential for early exits from inside loops or conditionals. Both styles are idiomatic; explicit return is normal for early exits, and the bare trailing expression is normal for the function’s “main” result.
The second thing to build a correct mental model of is what happens to the arguments you pass in. When you call a function with a value whose type does not implement the Copy trait (like String or Vec<T>), passing it by value moves ownership of that value into the function’s parameter. The variable back in the caller becomes invalid the instant the call happens — not because Rust deletes anything at that point, but because the compiler now considers the caller’s binding used-up, so it refuses to let you read from it again. When the function returns, whatever it owned that wasn’t returned gets dropped. This is exactly the same rule that governs plain assignment (let s2 = s1; moves s1 into s2); a function call is just another place a value can change owners. If you only want the function to look at the data without taking it over, you pass a reference (&value) instead, and the function’s parameter type becomes a reference type (like &str or &Vec<i32>) — the caller keeps ownership and gets the value back afterward, fully usable.
Syntax
fn function_name(param1: Type1, param2: Type2) -> ReturnType {
// statements
final_expression
}
| Part | Meaning |
|---|---|
fn |
Keyword that begins a function definition. |
function_name |
Identifier in snake_case by Rust convention. |
(param1: Type1, ...) |
Parameters. Every parameter’s type must be written explicitly — Rust never infers parameter types. |
-> ReturnType |
Optional. Omitted entirely when the function returns the unit type () (i.e., returns nothing meaningful). |
Function body { ... } |
A block. Its value (if the last line has no semicolon) becomes the return value; must match ReturnType exactly. |
return expr; |
Optional explicit early return; can appear anywhere, commonly inside if blocks or loops. |
Examples
Example 1: parameters, a return type, and an expression-based return
fn main() {
let sum = add(5, 7);
println!("Sum: {}", sum);
}
fn add(a: i32, b: i32) -> i32 {
a + b
}
Sum: 12
add takes two i32 values by value (integers implement Copy, so a and b are simply duplicated into the function, and the caller’s 5 and 7 literals aren’t affected either way). The block’s only line, a + b, has no semicolon, so it is an expression whose value becomes the function’s return value, which flows back into sum in main.
Example 2: borrowing a string instead of taking ownership
fn main() {
let name = String::from("Ferris");
greet(&name);
println!("Still have name: {}", name);
}
fn greet(name: &str) {
println!("Hello, {}!", name);
}
Hello, Ferris!
Still have name: Ferris
greet takes a &str, a borrowed string slice, rather than an owned String. In main we pass &name, which borrows name for the duration of the call instead of moving it. Because Rust automatically converts a &String into a &str (this is called deref coercion), the call compiles without any manual conversion. Since only a reference was handed over, main still owns name and can print it again afterward — had greet taken name: String and been called as greet(name), the second println! would fail to compile because name would have been moved.
Example 3: an early return with Option<T>
fn main() {
let numbers = vec![4, 9, 15, 22, 7];
match find_first_even(&numbers) {
Some(n) => println!("First even number: {}", n),
None => println!("No even number found"),
}
}
fn find_first_even(numbers: &[i32]) -> Option<i32> {
for &n in numbers {
if n % 2 == 0 {
return Some(n);
}
}
None
}
First even number: 4
find_first_even takes a slice reference &[i32] rather than a &Vec<i32> — this is the idiomatic choice because a slice works for arrays, vectors, and parts of either, so the function accepts more callers. Rust’s deref coercion again lets us pass &numbers (a &Vec<i32>) directly. Instead of a null or a sentinel value, the function signals “maybe nothing” with Option<i32>: it returns early with return Some(n) the moment it finds an even number, and falls through to the bare expression None (no semicolon, so it’s the block’s value) if the loop finishes without finding one. The caller is forced by the type system to handle both cases via match.
How it works step by step
- When the compiler sees a call like
add(5, 7), it checks the argument types and count against the function’s declared signature at compile time — there is no runtime overload resolution or type coercion beyond the small, well-defined set of coercions like&Stringto&str. - Arguments are bound to the parameter names as new local variables inside the function’s own stack frame. For
Copytypes, this is a bitwise duplication; for non-Copytypes passed by value, the compiler statically marks the caller’s original variable as moved-from and will reject any later use of it. - The function body executes top to bottom. Each statement runs for its side effect; the block’s overall value is determined by whether the last line is a semicolon-terminated statement (value is
()) or a bare trailing expression (value is that expression’s result). - If a
returnstatement is reached first, execution leaves the function immediately with that value — the rest of the body, including any trailing expression, is never evaluated. - When the function returns, the compiler drops any values the function still owns that were not moved out via the return value, running their destructors (freeing heap memory for a
String, for example) before control passes back to the caller.
Common Mistakes
Mistake 1: an accidental semicolon after the final expression
fn add(a: i32, b: i32) -> i32 {
a + b;
}
error[E0308]: mismatched types
--> src/main.rs:2:5
|
1 | fn add(a: i32, b: i32) -> i32 {
| --- expected `i32` because of return type
2 | a + b;
| ^ help: consider removing this semicolon
| ----- expected `i32`, found `()`
Adding a semicolon turns a + b from an expression into a statement, so the block’s value becomes the unit type () instead of i32. Since the signature promised an i32, the compiler rejects the mismatch. The fix is simply to drop the trailing semicolon so the block’s last line is the bare expression:
fn add(a: i32, b: i32) -> i32 {
a + b
}
This is one of the most common early mistakes in Rust precisely because every other line in the function typically does end with a semicolon — only the final, “returned” expression should not.
Mistake 2: using a value after it has been moved into a function
fn main() {
let s = String::from("hello");
takes_ownership(s);
println!("{}", s);
}
fn takes_ownership(s: String) {
println!("{}", s);
}
error[E0382]: borrow of moved value: `s`
--> src/main.rs:4:20
|
2 | let s = String::from("hello");
| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 | takes_ownership(s);
| - value moved here
4 | println!("{}", s);
| ^ value borrowed here after move
takes_ownership declares its parameter as s: String, so calling it with s moves the String out of main entirely; once the function returns, that data has already been dropped. The subsequent println! tries to read a variable the compiler knows is no longer valid, so it refuses to compile rather than risk a use-after-free. The usual fix is to have the function borrow instead of take ownership, unless it genuinely needs to consume or store the value:
fn main() {
let s = String::from("hello");
takes_ownership(&s);
println!("{}", s);
}
fn takes_ownership(s: &str) {
println!("{}", s);
}
hello
hello
Changing the parameter type to &str and passing &s means main only lends the string out for the duration of the call and keeps ownership, so the final println! is valid.
Best Practices
- Prefer borrowing (
&str,&[T],&T) over taking ownership for parameters a function only needs to read; reserve owned parameters (String,Vec<T>) for functions that genuinely need to store or consume the value. - Use the trailing-expression style (no semicolon, no
return) for a function’s main result, and reserve explicitreturnfor early exits — this keeps the “normal path” value visually distinct from bail-out cases. - Return
Option<T>orResult<T, E>instead of panicking or returning a sentinel value (like-1or an empty string) when a function might legitimately have no answer or might fail. - Keep functions short and focused on one task; if you find yourself scrolling to see the whole body, it is usually a sign it should be split.
- Name functions and parameters with descriptive
snake_caseidentifiers — Rust’s own tooling (rustc,clippy) will warn you if you stray from this convention. - Write parameter and return types explicitly and precisely; Rust never infers them, and being deliberate about
&strvsStringat a function boundary documents intent for every caller.
Practice Exercises
- Write a function
fn max_f64(a: f64, b: f64) -> f64that returns the larger of the two arguments, and call it frommainwith a couple of test pairs, printing each result. - Write a function
fn is_palindrome(s: &str) -> boolthat returns whether a string reads the same forwards and backwards (you can compare it against its reverse). Test it with"racecar"(expecttrue) and"rust"(expectfalse). - Write a function
fn sum_positive(numbers: &[i32]) -> i32that borrows a slice and returns the sum of only its positive elements. Call it with aVec<i32>containing a mix of positive and negative numbers and print the result.
Summary
- Functions are declared with
fn, take explicitly-typed parameters, and optionally declare a return type after->. - A block’s value is its final expression only if that line has no trailing semicolon; adding a semicolon discards the value and produces
(), which is the source of a very common type-mismatch error. returnexits a function immediately with a value and is normal for early exits, while the trailing-expression style is normal for a function’s main result.- Passing a non-
Copyvalue (likeStringorVec<T>) into a function by value moves ownership; the caller’s original binding becomes unusable afterward. - Passing a reference (
&value) borrows instead of moving, so the caller keeps ownership and can keep using the value after the call returns. - Prefer borrowed parameter types (
&str,&[T]) for read-only access, and reach forOption<T>/Result<T, E>instead of sentinel values or panics to represent absence or failure.
