Functions in Rust: Parameters, Returns, and Expressions
A practical guide to Rust functions — fn syntax, parameter types, return values, the difference between statements and expressions, the trailing-expression rule, and early returns.
What you'll learn
- ✓How to define functions with fn
- ✓How to declare parameter types and return types
- ✓The difference between statements and expressions — the rule that shapes every Rust function
- ✓Why functions usually end without a semicolon
- ✓When to use early return
- ✓How to return multiple values with a tuple
Prerequisites
- •Variables and types — see Variables and Basic Types
Functions are the unit of reuse in Rust. They let you name a chunk of behaviour, hide its details behind a clear signature, and call it from anywhere. Rust functions look familiar at a glance — fn, parameters, return type — but the way the body is structured around expressions is what makes idiomatic Rust look the way it does.
This post covers the syntax in full.
Defining a function
The keyword is fn, followed by a name, a parenthesised parameter list, optionally a return type, and a body in braces:
fn main() {
greet();
}
fn greet() {
println!("Hello!");
}
You can define functions in any order in the same file. Rust does not require forward declarations the way C does — the compiler reads the whole file before resolving names.
The naming convention is snake_case. The compiler will warn if you write CamelCase:
fn ParseNumber() {} // warning: function `ParseNumber` should have a snake case name
Rename it to parse_number and the warning goes away.
Parameters
Every parameter must have a type. Rust does not infer parameter types — they are part of the function’s public contract:
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let sum = add(3, 4);
println!("{}", sum);
// output: 7
}
The -> i32 after the parameters declares the return type. If you leave it off, the function returns the unit type () — Rust’s “nothing.”
Multiple parameters are separated by commas:
fn rectangle_area(width: f64, height: f64) -> f64 {
width * height
}
There are no default values or keyword arguments in Rust. Every call passes every argument by position. This is intentional — the language prefers explicitness — and you work around it with builder patterns or Option parameters when you really need flexibility.
Statements vs expressions
This is the most important rule in Rust syntax. Get it once and the rest makes sense.
- A statement performs an action and does not produce a value. It ends with a semicolon.
- An expression evaluates to a value. It does not end with a semicolon.
let x = 5; // a let statement
x + 1 // an expression (evaluates to 6)
The body of a function is a sequence of statements, optionally ending with a single expression. If the body ends with an expression, that value is returned. If it ends with a statement (or nothing), the function returns ().
This explains why Rust functions usually look like this:
fn double(n: i32) -> i32 {
n * 2
}
There is no return and no semicolon on the last line. n * 2 is an expression whose value becomes the return value.
Add a semicolon and you break it:
fn double(n: i32) -> i32 {
n * 2; // now this is a statement that throws the value away
}
// error: expected `i32`, found `()`
The compiler error here is delightfully clear. It tells you the function promised to return i32 but ended with ().
return exists, but is usually unnecessary
You can use return if you want — and you should for early exits — but the idiomatic Rust function ends with a trailing expression and no return:
// idiomatic
fn add(a: i32, b: i32) -> i32 {
a + b
}
// also valid, but unusual
fn add(a: i32, b: i32) -> i32 {
return a + b;
}
Both compile to the same code. Pick the first form. Save return for the cases below.
Early return
When a function has a few “this case is handled, done” branches, return as soon as you reach each one:
fn classify(score: i32) -> &'static str {
if score < 0 || score > 100 {
return "invalid";
}
if score < 50 {
return "fail";
}
if score < 70 {
return "pass";
}
if score < 85 {
return "merit";
}
"distinction"
}
fn main() {
println!("{}", classify(72));
// output: merit
}
Notice the last line: no return, no semicolon. The trailing expression is the default-case return.
Compare this to the deeply nested if/else if/else equivalent — same logic, much harder to scan.
Blocks are expressions too
A block — a pair of braces with statements inside — is itself an expression that evaluates to its trailing expression. This is why if is an expression in Rust, not a statement:
fn abs(n: i32) -> i32 {
if n < 0 { -n } else { n }
}
Both arms are expressions, both have the same type, and the whole if produces a value. No return needed.
Returning multiple values
Rust has no special syntax for multiple return values. Use a tuple:
fn min_max(numbers: &[i32]) -> (i32, i32) {
let mut min = numbers[0];
let mut max = numbers[0];
for &n in numbers {
if n < min { min = n; }
if n > max { max = n; }
}
(min, max)
}
fn main() {
let (lo, hi) = min_max(&[4, 9, 1, 7, 3]);
println!("low {}, high {}", lo, hi);
// output: low 1, high 9
}
The caller destructures the tuple at the binding site. See Variables and Basic Types for the tuple syntax.
Once a tuple grows beyond three fields, switch to a named struct. A function returning (String, i32, bool, Vec<u8>) is begging to be misread.
Parameter types: &str vs String
A common stumble for beginners is passing a string to a function. Take &str for read-only input and return String when you have to allocate:
fn shout(message: &str) -> String {
message.to_uppercase()
}
The & at the call site borrows a String as a &str. See Rust Ownership Basics for the full story.
A single responsibility
A function should do one thing well. The clearest test is whether you can describe it in a single sentence without using “and.”
fn is_vowel(c: char) -> bool {
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u')
}
fn count_vowels(text: &str) -> usize {
text.chars().filter(|&c| is_vowel(c)).count()
}
fn main() {
println!("{}", count_vowels("Hello, World!"));
// output: 3
}
count_vowels reads almost like English because the small helper carries the detail. That is the payoff.
Try this. Write a function word_count(text: &str) -> usize that returns the number of whitespace-separated words in text. Then write average_word_length(text: &str) -> f64 that calls word_count and the total character count to compute the average. Use the trailing-expression rule — neither function should contain the word return.
Documenting functions
Comments starting with /// are documentation comments. They support Markdown, feed into cargo doc, and the code blocks inside them are run as tests by cargo test:
/// Returns the absolute value of `n`.
fn absolute(n: i32) -> i32 {
if n < 0 { -n } else { n }
}
Your examples can’t drift out of sync with the code, because the test suite would fail.
A worked example
A small program that exercises everything:
fn median(values: &[i32]) -> f64 {
let mut sorted = values.to_vec();
sorted.sort();
let n = sorted.len();
if n % 2 == 1 {
sorted[n / 2] as f64
} else {
(sorted[n / 2 - 1] as f64 + sorted[n / 2] as f64) / 2.0
}
}
fn main() {
let sample = [4, 9, 1, 7, 3, 6];
println!("median = {}", median(&sample));
// output: median = 5
}
median uses the if expression to produce a value, with no return anywhere.
Recap
You now know:
fn name(params) -> Type { body }defines a function; bodies live inside braces.- Every parameter and the return type must be declared; only the body’s types are inferred.
- Statements end with
;and produce no value; expressions don’t end with;and produce a value. - A function’s body is statements followed by an optional trailing expression — that expression is the return value.
returnworks for early exit but is rare in idiomatic code.- Return multiple values with a tuple; switch to a struct once the tuple gets crowded.
- Documentation comments with
///feedcargo docand double as tests.
Next steps
The next post is about the idea that makes Rust different from every other language you have used: ownership. It covers the three rules, move semantics, borrowing, and the borrow checker — including friendly explanations of the errors you will see in your first week.
Questions or feedback? Email codeloomdevv@gmail.com.