TL;DR
Learn how to define Rust functions with parameters, return types, and expressions — the building blocks of every Rust program
Key concepts
- Rust functions
- Rust return types
- Rust fn keyword
- Rust function parameters
- Rust expressions
Functions in Rust
Functions are the building blocks of readable, maintainable code. In Rust, functions are declared using the fn keyword and follow a clear, consistent syntax.
What You'll Learn
By the end of this lesson you will write three functions that hand values back in three different ways: one returning a single number from a tail expression, one returning two values at once as a tuple, and one that calls the other two rather than repeating their work. The part that catches people is a single character — a semicolon on a function's last line stops it returning anything at all, and the compiler reports the mismatch at the signature rather than at the line you typed.
The capstone's taskwork is a few dozen small functions passing values between them, and the choices you practise here — what a signature promises, and what the last line of a body actually hands back — are what keep those pieces fitting together. You arrive able to write conditions and loops and to convert between numeric types (Control Flow, and Variables and Data Types); what is new is packaging that work behind a name and a signature.
A Function You Can Run
Before the notation, here is the whole idea working end to end. Hit Run:
fn double(value: i32) -> i32 {
value * 2
}
fn main() {
let result = double(21);
println!("double(21) = {}", result);
}
Three things in six lines: double declares one parameter with its type, promises an i32 back with ->, and produces it with a last line carrying no semicolon. Everything below breaks those three pieces apart.
Basic Function Syntax
Here's how you define a simple function in Rust:
fn greet() {
println!("Hello, world!");
}
To call this function, you simply write greet();
Functions with Parameters
Functions can take parameters, which must have type annotations:
// name: &str, not String — greet_person only READS the text to print it, so it
// borrows a view and the caller keeps its value. Asking for String instead would
// force every caller to hand over ownership (or clone) just to say hello once.
fn greet_person(name: &str) {
println!("Hello, {}!", name);
}
That parameter choice is the first real design decision in the lesson, and it recurs everywhere. Ask what the function needs to do with the value: read it and you take a reference, keep or transform it and you take ownership. &str is the borrowed view of text, and it is the right default for a parameter you only read. Ownership and Borrowing makes the rule explicit; for now, notice that the signature is where the decision is recorded, and every caller has to live with it.
Return Values
Functions can return values. The return type is declared after an arrow ->:
// -> i32 is a promise, and the body has to keep it. The last line carries no
// semicolon, which is what makes it the value handed back rather than a
// statement whose result is thrown away.
fn add(x: i32, y: i32) -> i32 {
x + y // Note: no semicolon here!
}
Multiple Parameters
You can have multiple parameters with different types:
fn calculate_rectangle_area(width: f64, height: f64) -> f64 {
width * height
}
This one is unannotated on purpose — say the two notes yourself before reading on. Why is the parameter type f64 rather than i32, and why does the last line carry no semicolon? The first because an area is a measurement that can have a fractional part, and whole-number parameters would truncate every input before the multiplication ever ran. The second because -> f64 promises a value, and only a semicolon-free final expression supplies one.
Functions That Return Nothing
Every function returns something. A function written without an -> still has a return type — it is (), pronounced "unit", the type with exactly one value and no information in it. That matters more than it sounds, because the semicolon rule and the unit type are the same rule seen from two sides: a body ending in value returns that value, and a body ending in value; returns () instead. Trace the program below, including the order of the two printed groups, before running it.
Predict
One of these functions declares a return type and one does not. Predict every printed line, in order, before running.
fn triple(value: i32) -> i32 {
value * 3
}
fn log_it(value: i32) {
println!("logging {}", value);
}
fn main() {
let tripled = triple(5);
let logged = log_it(tripled);
println!("tripled = {:?}", tripled);
println!("logged = {:?}", logged);
}The output is logging 15, then tripled = 15, then logged = (). Two things to take from it. A call runs where it is written — let logged = log_it(tripled); executes log_it at that line, which is why its output appears above main's own printing. And a function declared without an -> does not return "nothing"; it returns (), the unit type, a real value with exactly one possible form. That is the same rule as the semicolon rule seen from the other side: a body ending in value returns the value, and a body ending in value; returns () — which is why adding a stray semicolon to the last line of an -> i32 function breaks it with expected i32, found ().
Practice Time!
Try writing a function that:
- Takes two numbers as parameters
- Returns their sum
- Prints the result
fn main() {
let result = add_numbers(5, 3);
println!("The sum is: {}", result);
}
fn add_numbers(a: i32, b: i32) -> i32 {
a + b
}
Key Points to Remember
- Functions start with
fn - Parameter types must be declared
- Return types are declared with
-> - The last expression in a function is implicitly returned
- Early returns use the
returnkeyword - Function names use snake_case by convention
Now it's your turn to experiment with functions in the playground below!
Check Yourself
The build task below needs one thing from the previous lesson. Answer this from memory first.
Recall
Without scrolling up: in Variables and Data Types you met the as cast. In a moment you will call a function that wants i32 values while holding f64 ones, so you need to bridge the two. Given let width = 3.7_f64;, what does width as i32 produce, and what kind of operation is it?
A Function That Promises More Than It Delivers
The semicolon rule has a failure mode worth meeting before you rely on it. The program below is one character away from correct, and the compiler blames a line you did not touch.
That mismatch is the routine worth carrying, so read the message in this order rather than top to bottom. Take the primary span — the --> line and the carets under it — as what the compiler expected and where that expectation came from, which is very often a signature. Then read the note: and help: lines for where the expectation was broken, which is usually somewhere else entirely and is usually where you actually edit. The two are different places on this program and on most non-trivial ones. And keep the error[EXXXX] code: it is a stable identifier that survives compiler versions when the wording does not, and rustc --explain will print the general write-up for it.
Debug
This should double a score built from a base and a bonus, printing 16. It does not compile. Read where the compiler points and notice that it is the signature line, not the line with the mistake on it. Commit a hypothesis about what the body actually produces before you change anything, then fix it so the program prints 16.
fn tally(base: i32, bonus: i32) -> i32 {
let subtotal = base + bonus;
subtotal * 2;
}
fn main() {
let score = tally(5, 3);
println!("score = {}", score);
}Expected output: score = 16
Assembling a Function's Body
Here are the two functions the next block's lines call, so you are not ordering against signatures you have to guess. Read them for what they take, because that is where the ordering constraint hides — min_max takes two i32s, which are Copy, and shout takes a String, which is not:
fn min_max(a: i32, b: i32) -> (i32, i32) {
if a < b { (a, b) } else { (b, a) }
}
fn shout(text: String) -> String {
text.to_uppercase()
}
Arrange the code
These five lines call a function returning two values at once, build a label from the pair, measure it, shout it and print both. They have been shuffled. Put them in the order that runs and prints '4 TO 9 (6 chars)' — then answer what the order is really testing. Three of the gaps hold because a name is not in scope yet. One holds for a different reason, and it is about what shout's parameter type does to label. Which pair is it, and what does the compiler call the failure?
let loud = shout(label);println!("{} ({} chars)", loud, width);let label = format!("{} to {}", smaller, larger);let width = label.len();let (smaller, larger) = min_max(9, 4);
Carrying the Semicolon Rule Somewhere New
Transfer
You have seen that a function body's last line returns its value when it carries no semicolon, and returns the unit value when it does. Now apply that rule in a setting this lesson did not show: a plain block used as an expression, as in let label = { let n = 4; n * 10 }; and its variant let label = { let n = 4; n * 10; };. Which statement predicts both correctly and names the reason?
Try It Yourself
Reading about functions is not the same as designing signatures under your own name. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one returns a single number from a tail expression, one returns two values as a tuple, and one calls the other two and formats their results into a sentence. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.
The third one is the point of the exercise: functions compose, so summarize should call perimeter and min_max rather than repeat their arithmetic. It also needs one thing from the previous lesson — min_max takes i32 values while summarize is handed f64 ones, so an as cast has to bridge them.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function's signature is promising, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
// TODO 1: return the perimeter of a rectangle: two widths plus two heights.
// Use a TAIL EXPRESSION - no 'return' keyword, no trailing semicolon.
// perimeter(3.0, 4.0) -> 14.0
fn perimeter(width: f64, height: f64) -> f64 {
let _ = width;
let _ = height;
0.0
}
// TODO 2: return BOTH the smaller and the larger of two numbers, as a tuple
// in that order. A function returns several values by returning a tuple.
// min_max(9, 4) -> (4, 9), min_max(2, 2) -> (2, 2)
fn min_max(a: i32, b: i32) -> (i32, i32) {
let _ = a;
let _ = b;
(0, 0)
}
// TODO 3: return a one-line summary built by CALLING the two functions above
// rather than recomputing anything. min_max wants i32 values, so cast the
// f64 sides with 'as' before handing them over.
// summarize(3.0, 4.0) -> "perimeter 14 spans 3 to 4"
// Build it with format! and a {} for each of the three values.
fn summarize(width: f64, height: f64) -> String {
let _ = width;
let _ = height;
String::new()
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
assert_eq!(perimeter(3.0, 4.0), 14.0, "perimeter(3, 4) is 3+3+4+4 = 14");
assert_eq!(perimeter(2.5, 2.5), 10.0, "a 2.5 square has perimeter 10");
assert_eq!(min_max(9, 4), (4, 9), "min_max must return the SMALLER value first");
assert_eq!(min_max(2, 2), (2, 2), "equal inputs give equal outputs");
assert_eq!(
summarize(3.0, 4.0),
"perimeter 14 spans 3 to 4",
"summarize should reuse perimeter and min_max rather than recompute"
);
println!("All checks passed.");
println!("{}", summarize(3.0, 4.0));
println!("{:?}", min_max(9, 4));
}Expected output: All checks passed.
perimeter 14 spans 3 to 4
(4, 9)
Once it passes, try two variations and predict each before running:
- Put a semicolon on
perimeter's last line. Change the body's final line towidth * 2.0 + height * 2.0;. Predict what the compiler says before running. It refuses to compile witherror[E0308]: mismatched types, reportingexpected f64, found ()and pointing at the function signature with the note "implicitly returns()as its body has no tail orreturnexpression" — plus a help line offering to remove the semicolon. That is the wording produced by the toolchain behind the Run button on this site; compiler messages are improved from release to release, so a different rustc version may word the note differently while reporting the sameE0308. That single character turns the returned value into a discarded statement. - Return the tuple the other way round. In
min_max, swap the branches so the larger value comes first. Predict which check fails before running. Themin_max(9, 4)check fails withleft: (9, 4), right: (4, 9), while themin_max(2, 2)check still passes because both entries are equal. Tuple positions carry meaning that the type system cannot check for you —(i32, i32)is satisfied either way, so the ordering promise lives in the name and the docs, not in the signature.
Key Takeaways
- Functions are declared with
fnand can return values using->syntax - Rust functions must declare parameter types explicitly
- The last expression in a function body is implicitly returned (no
returnneeded) - Functions can return tuples to return multiple values
- A function with no
->returns(), the unit type
Next Steps
Now that you can write functions, you're ready to learn about ownership and borrowing — the system that guarantees memory safety without a garbage collector. It's Rust's most distinctive feature, and understanding it makes every later concept click into place.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.