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."
Unlike Go, Rust functions return only one value. Need multiple? Return a tuple:
fn split_name(full: &str) -> (&str, &str) {
let parts: Vec<&str> = full.splitn(2, ' ').collect();
(parts[0], parts[1])
}
fn main() {
let (first, last) = split_name("Alice Smith");
println!("{}, {}", first, last); // Alice, Smith
}For the common Go pattern of (value, error), Rust uses Result<T, E> instead of a tuple. You'll see this in the Error Handling lesson.
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.
while and for are also expressions, but they always return () (unit). You can use break to exit them early, but you can't use break value to carry a result out. Only loop supports that because it guarantees an exit via break. With while or for, the loop might run zero times if the condition is false immediately, and Rust wouldn't know what value to return in that case.
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.
Operators
Rust has two categories that trip people up: logical operators and bitwise operators. They look similar but do very different things.
Logical operators work on bool values and short-circuit — the right side is only evaluated if needed:
fn main() {
let x = 5;
// && short-circuits: if left is false, right is never evaluated
if x > 0 && x < 10 {
println!("single digit positive"); // single digit positive
}
// || short-circuits: if left is true, right is never evaluated
if x < 0 || x > 3 {
println!("out of range"); // out of range
}
// ! negates a bool
let is_empty = false;
println!("{}", !is_empty); // true
}Bitwise operators work on integers, bit by bit, and never short-circuit:
fn main() {
let a: u8 = 0b1100; // 12
let b: u8 = 0b1010; // 10
println!("{:08b}", a & b); // 00001000 — AND: bits set in both
println!("{:08b}", a | b); // 00001110 — OR: bits set in either
println!("{:08b}", a ^ b); // 00000110 — XOR: bits set in one but not both
println!("{:08b}", !a); // 11110011 — NOT: flip all bits
println!("{:08b}", a << 1); // 00011000 — left shift: multiply by 2
println!("{:08b}", a >> 1); // 00000110 — right shift: divide by 2
}The key distinction: & on integers is bitwise AND. && on bools is logical AND with short-circuit. They are different operators for different types — using & on bools works but evaluates both sides always.
fn side_effect() -> bool {
println!("evaluated!");
true
}
fn main() {
false && side_effect(); // nothing printed — short-circuits
false & side_effect(); // "evaluated!" — & does not short-circuit
}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. Without _, you'd need to cover every possible value for that type. For an i32, that's over 4 billion values. The _ wildcard handles "everything else" so you don't have to. For enums with a small number of variants, you can cover all cases explicitly without _, and the compiler will tell you if you miss one.
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
}The return type &'static str means "a string reference that lives for the entire program." Every string literal ("going up", etc.) is baked into the binary at compile time, so it's valid forever. The 'static part is a lifetime annotation — you'll learn lifetimes in detail later, but for now just read it as "this string never goes away."
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 it&&and||are logical operators on bools and short-circuit.&,|,^,!,<<,>>are bitwise operators on integers&on bools works but never short-circuits — use&&for conditional evaluationmatchmust 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 value moves, and the compiler won't let you touch the original again. No runtime error, it just refuses to compile. Next lesson: Ownership and Borrowing, the rule that makes Rust memory-safe without a garbage collector.