04. 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, so this is the return value
}
fn main() {
println!("{}", square(4)); // 16
}Add a semicolon to that last line and the function returns () instead of the number, which the compiler rejects:
// ❌ Does NOT compile
fn square_broken(n: i32) -> i32 {
n * n; // the semicolon makes this a statement, so the body is ()
}
// error[E0308]: mismatched types: expected `i32`, found `()`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.