03. Functions and Control Flow
📋 Jump to Takeaways🎁 Why doesn't Rust have a ternary operator (condition ? a : b)? Because if/else already IS an expression that returns a value. Every control flow construct in Rust produces something — and that changes how you write code.
Function Signatures
Every function in Rust starts with fn, followed by a name, parameters with explicit types, and an optional return type. Rust never infers parameter types — you must be explicit.
fn add(x: i32, y: i32) -> i32 {
x + y
}
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let result = add(5, 3);
println!("{}", result); // 8
greet("Rustacean"); // Hello, Rustacean!
}Functions without a -> Type annotation implicitly return the unit type () — Rust's way of saying "nothing meaningful."
Implicit Return
The last expression in a function body becomes the return value — no return keyword needed. The key: no semicolon on the final expression. A semicolon turns an expression into a statement, which returns ().
fn square(n: i32) -> i32 {
n * n // no semicolon = this is the return value
}
fn square_broken(n: i32) -> i32 {
n * n; // semicolon = returns (), compiler error!
}
fn main() {
println!("{}", square(4)); // 16
}You can still use return for early exits:
fn absolute(n: i32) -> i32 {
if n < 0 {
return -n; // early return
}
n // implicit return
}
fn main() {
println!("{}", absolute(-7)); // 7
println!("{}", absolute(3)); // 3
}If/Else as an Expression
Since if/else returns a value, you can assign it directly to a variable. Both branches must return the same type.
fn main() {
let age = 20;
let status = if age >= 18 {
"adult"
} else {
"minor"
};
println!("{}", status); // adult
// Use it inline
let price = if age >= 65 { 0 } else { 10 };
println!("Ticket: ${}", price); // Ticket: $10
}This is why Rust doesn't need a ternary operator — if/else already does the job, and it's more readable.
Loops: loop, while, for
Rust has three loop types. loop runs forever until you break. while runs while a condition is true. for iterates over a range or collection.
fn main() {
// loop — infinite until break
let mut count = 0;
loop {
count += 1;
if count == 3 {
break;
}
}
println!("count: {}", count); // count: 3
// while
let mut n = 5;
while n > 0 {
print!("{} ", n);
n -= 1;
}
println!(); // 5 4 3 2 1
// for with a range
for i in 1..4 {
print!("{} ", i);
}
println!(); // 1 2 3
}Loop with Break Returning a Value
loop is also an expression. You can return a value from break, which makes it perfect for retry logic or searching.
fn main() {
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2; // returns 20
}
};
println!("result: {}", result); // result: 20
}No other language does this quite as cleanly. The break keyword carries the value out of the loop.
Ranges
Rust ranges come in two flavors: exclusive (..) and inclusive (..=).
fn main() {
// 1..5 → 1, 2, 3, 4 (excludes 5)
for i in 1..5 {
print!("{} ", i);
}
println!(); // 1 2 3 4
// 1..=5 → 1, 2, 3, 4, 5 (includes 5)
for i in 1..=5 {
print!("{} ", i);
}
println!(); // 1 2 3 4 5
// Ranges work with characters too
for c in 'a'..='d' {
print!("{} ", c);
}
println!(); // a b c d
}Use ..= when you want to include the upper bound. Off-by-one errors become impossible when you pick the right range type.
Match: Pattern Matching
match is Rust's superpower for control flow. It's like a switch statement, but the compiler guarantees you handle every possible case.
fn main() {
let number = 3;
let word = match number {
1 => "one",
2 => "two",
3 => "three",
_ => "other", // _ catches everything else
};
println!("{}", word); // three
}match must be exhaustive — miss a case and the compiler rejects your code. The _ wildcard handles "everything else."
Match with Enums
match truly shines with enums. The compiler ensures you handle every variant.
enum Direction {
North,
South,
East,
West,
}
fn describe(dir: Direction) -> &'static str {
match dir {
Direction::North => "going up",
Direction::South => "going down",
Direction::East => "going right",
Direction::West => "going left",
}
}
fn main() {
println!("{}", describe(Direction::East)); // going right
}Remove one arm from that match and the compiler will refuse to compile — it knows you forgot a case. This eliminates an entire category of bugs.
Match with Multiple Patterns and Guards
You can combine patterns with | and add conditions with if guards.
fn main() {
let score = 85;
let grade = match score {
90..=100 => "A",
80..=89 => "B",
70..=79 => "C",
60..=69 => "D",
_ => "F",
};
println!("Grade: {}", grade); // Grade: B
// Multiple patterns with |
let day = 6;
let kind = match day {
1..=5 => "weekday",
6 | 7 => "weekend",
_ => "invalid",
};
println!("{}", kind); // weekend
}Match guards add an if condition to an arm:
fn main() {
let temperature = 105;
let status = match temperature {
t if t > 100 => "boiling",
t if t < 0 => "freezing",
_ => "normal",
};
println!("{}", status); // boiling
}The guard (if t > 100) only runs if the pattern matches first. This lets you filter within a match arm without nesting if/else inside it.
Key Takeaways
- Every function parameter needs an explicit type annotation
- The last expression without a semicolon is the implicit return value
if/elseis an expression — assign it directly to variablesloopcan return values viabreak value..excludes the upper bound,..=includes itmatchmust be exhaustive — the compiler enforces complete coverage- Pattern matching with enums catches missing cases at compile time
- Rust has no ternary operator because
if/elsealready returns a value
🎁 What happens when you assign one variable to another in Rust and then try to use the original? The compiler just... deletes it. No runtime error — it refuses to compile. Next lesson: Ownership and Borrowing — the rule that makes Rust memory-safe without a garbage collector.