if, loop, and blocks as expressions that produce values, not just statements that do things;loop, while, for) differ, and why loop can hand a value back out through break;match gives you exhaustive, compiler-checked branching that no C-style switch can match;@ bindings handle the cases that plain patterns cannot, and when if let and while let are cleaner than a full match.Learn Rust Series):Welcome back. Episode 3 was the heavy one -- ownership, moves, borrowing, and the shared-xor-mutable rule that makes data races impossible before your program even runs. If that chapter left your head spinning a little, good news: today is where we get to use the language and have some fun with it. We are going to teach a program how to decide things and how to repeat things, and then we are going to meet match, which is honestly one of my favourite features in any language I have ever touched.
Control flow sounds like the most boring topic imaginable. Every language has if and loops, right? Sure. But Rust does two things here that will quietly reshape how you write code. First, if and blocks are expressions that evaluate to values (we glimpsed this in episode 2). Second, match is not a dressed-up switch -- it is a full pattern-matching engine that the compiler forces you to make exhaustive. Miss a case and your code does not build. That single guarantee eliminates a whole species of bug that haunts C, Java, and Go programs. So let us go through it properly.
Nota bene: if you come from Python or Go, keep them in the back of your mind as usual. Python has no switch worth the name (the match statement arrived late and is looser), and Go's switch does not check exhaustiveness at all. The contrast is where the insight lives.
As always, let us clear last episode's exercises first, because they lean right into today.
Exercise 1 asked for a first_word(s: &String) -> usize that returns the byte index of the first space, or the string's length if there is no space -- while never taking ownership, so the caller can still use the string:
fn first_word(s: &String) -> usize {
for (i, c) in s.char_indices() {
if c == ' ' {
return i;
}
}
s.len()
}
fn main() {
let sentence = String::from("hello there world");
let idx = first_word(&sentence);
println!("first space at byte {idx}"); // 5
println!("original still usable: {sentence}"); // still ours -- never moved
}
The signature takes &String, a shared borrow, so nothing is moved and the last line in main still has the string. I used char_indices() rather than chars().enumerate() on purpose: char_indices yields the real byte offset of each character, which is what the exercise asked for. For plain ASCII the two happen to agree, but the moment a multi-byte character shows up they diverge, and being precise now saves confusion later. Notice the early return inside the loop -- perfectly idiomatic -- and the final s.len() with no semicolon as the fall-through value.
Exercise 2 was the mutable-borrow one: append_line(log: &mut String, line: &str) pushes line followed by a newline:
fn append_line(log: &mut String, line: &str) {
log.push_str(line);
log.push('\n');
}
fn main() {
let mut log = String::new();
append_line(&mut log, "boot sequence started");
append_line(&mut log, "loading config");
append_line(&mut log, "ready");
print!("{log}");
}
Each call takes exactly one mutable borrow (&mut log), does its work, and the borrow ends when the function returns -- which is why you can call it three times in a row without the borrow checker complaining. push_str appends a string slice, push appends a single char (note the single quotes on '\n'). And log had to be declared let mut log, because you cannot lend out a &mut to something that is not itself mutable, as we hammered on last time.
Exercise 3 was the deliberately broken snippet you had to fix by reordering (no .clone() cheating):
fn main() {
let mut name = String::from("scipio");
let borrowed = &name;
println!("{borrowed}"); // last use of the read borrow -- it ends right here
name.push_str("_nl"); // now the mutation is legal: no shared borrow alive
println!("{name}"); // scipio_nl
}
The original had name.push_str("_nl") sitting between the creation of borrowed and its final use, so a mutable borrow overlapped a live shared borrow -- forbidden. The fix is not to add anything, it is to let the read borrow finish (that last println! using borrowed) before the mutation happens. Non-lexical lifetimes mean the borrow ends at its last use, not at the closing brace. Reorder, and the conflict simply evaporates. Right -- on to deciding and repeating.
if is an expression, not just a statementWe touched this in episode 2, but it deserves a proper look because it changes how you structure code. In most languages if is a statement: it runs one block or another, full stop. In Rust if is an expression -- it evaluates to a value -- so you can assign its result directly:
fn main() {
let temperature = 18;
let feeling = if temperature < 10 {
"cold"
} else if temperature < 25 {
"pleasant"
} else {
"hot"
};
println!("it feels {feeling}");
}
There is no separate ternary operator in Rust (cond ? a : b does not exist) precisely because the ordinary if already does that job. Each branch is a block, and a block evaluates to its final expression -- here a string slice with no trailing semicolon. Coming from Python you would reach for "cold" if t < 10 else ...; in Rust the plain if/else if/else chain is the expression, and it reads far more naturally once you are used to it.
There is one rule the compiler is strict about, though: every branch of an if expression must produce the same type. This does not compile:
fn main() {
let flag = true;
let number = if flag { 5 } else { "six" }; // ERROR: `if` and `else` have incompatible types
println!("{number}");
}
One branch yields an integer, the other a string slice -- and since the whole expression has to have a single, known type, Rust refuses. In a dynamically typed language this would happily blow up much later at runtime with something bizarre; Rust catches it right there at the definition. Having said that, when you only want the side effect and no value, an if with no else works fine as a statement, exactly like everywhere else.
Rust gives you three loop keywords, and each has a clear job. The most basic is loop, which just repeats forever until you break out:
fn main() {
let mut attempts = 0;
let result = loop {
attempts += 1;
if attempts * attempts > 50 {
break attempts;
}
};
println!("stopped after {result} attempts"); // 8
}
Look closely at break attempts -- this is the neat part. In Rust, loop is also an expression, and break can carry a value out of it. So let result = loop { ... break x; }; assigns whatever you broke with to result. That is genuinely useful for "keep trying until something succeeds, then give me the answer" patterns, and it is something neither Python nor Go can express so cleanly (both force you to declare a variable outside the loop and mutate it).
When you have a condition to check up front, while is the tool:
fn main() {
let mut countdown = 3;
while countdown > 0 {
println!("{countdown}...");
countdown -= 1;
}
println!("liftoff!");
}
Nothing surprising there -- it is the while you already know. But in practice, the loop you will reach for most often is for, which walks over anything iterable. Rust's for is a for-each, not a C-style index-counter:
fn main() {
for i in 1..=5 { // inclusive range: 1, 2, 3, 4, 5
println!("tick {i}");
}
let colors = ["red", "green", "blue"];
for c in colors { // iterate the array directly, by value
println!("{c}");
}
}
The 1..=5 is an inclusive range (both ends included); 1..5 would be exclusive (1 to 4). Ranges are first-class values in Rust, and iterating one is the idiomatic replacement for for (int i = 0; i <= 5; i++). You almost never write a manual index in Rust, which means you almost never make an off-by-one error or read past the end of an array. When you do want the index alongside the element, enumerate gives you both:
fn main() {
let letters = ['a', 'b', 'c'];
for (i, ch) in letters.iter().enumerate() {
println!("{i}: {ch}");
}
}
Notice the (i, ch) on the left of the in -- that is destructuring again, pulling the index and the value apart from the tuple that enumerate produces on each step. Destructuring keeps sneaking into everything, and that is the perfect segue, because the real star of today is built entirely on it.
match: exhaustive branching the compiler checksHere we go -- this is the feature I promised. match compares a value against a series of patterns and runs the arm for the first one that fits. At its simplest it looks like a switch:
fn main() {
let dice = 4;
match dice {
1 => println!("lowest"),
6 => println!("highest"),
_ => println!("something in between"),
}
}
The _ is the wildcard pattern -- it matches anything, so it acts as the catch-all "default". So far this could be a switch from any language. But now watch what happens when I leave a case out and there is no catch-all:
fn describe(n: i32) -> &'static str {
match n {
0 => "zero",
1 => "one",
} // ERROR: non-exhaustive patterns: `i32::MIN..=-1_i32` and others not covered
}
This does not compile, and the error is glorious. Rust tells you exactly which values you failed to handle. A match must be exhaustive -- every possible value of the type has to be covered, either explicitly or by a wildcard. This is not the compiler being fussy for its own sake; it is a safety net. In Go, a switch that forgets a case just silently falls through and does nothing, and you find out in production. In Rust, forgetting a case is a compile error. When you later add a new variant to an enum, every match that does not yet handle it lights up red immediately, pointing you at every place that needs updating. That refactoring superpower alone is worth learning the language for.
A match arm is far more expressive than a single constant. You can match a range with ..=, or several alternatives with | (read it as "or"):
fn main() {
let n = 7;
match n {
0 => println!("nothing"),
1 | 2 | 3 => println!("a little"),
4..=9 => println!("quite a few"),
_ => println!("lots"),
}
}
The arm 1 | 2 | 3 fires for any of those three values; 4..=9 fires for anything in that inclusive range. This reads top to bottom and stops at the first match, so ordering matters when arms could overlap. Try rewriting that as a stack of if/else if and you will see immediately how much cleaner the match is -- and unlike the if chain, the compiler guarantees you did not forget a value.
matchThis is where pattern matching leaves switch in the dust. A pattern can reach into a compound value, pull it apart, and bind the pieces to names -- all in the arm itself. Remember tuples from episode 2? Match on one:
fn main() {
let point = (0, 5);
let location = match point {
(0, 0) => "origin".to_string(),
(0, y) => format!("on the y-axis at {y}"),
(x, 0) => format!("on the x-axis at {x}"),
(x, y) => format!("somewhere at ({x}, {y})"),
};
println!("{location}");
}
Read those arms carefully, because a lot is going on in very little space. The first arm matches the exact tuple (0, 0). The second matches any tuple whose first element is 0, and it binds the second element to a new variable y that you can use on the right-hand side. The third does the mirror image. The last arm binds both fields to x and y, catching everything else -- which also makes the match exhaustive, since between them the four arms cover every possible (i32, i32). And notice the whole match is an expression assigned to location; each arm just has to produce a String. This ability to test structure and extract values in one motion is the beating heart of idiomatic Rust.
Where match truly shines is on enums -- Rust's way of saying "a value that is exactly one of these named variants". We will give enums their own full episode very soon, so I will keep this light, but you cannot appreciate match without seeing it. Here is a tiny one:
enum Signal {
Red,
Yellow,
Green,
}
fn action(light: Signal) -> &'static str {
match light {
Signal::Red => "stop",
Signal::Yellow => "slow down",
Signal::Green => "go",
}
}
fn main() {
println!("{}", action(Signal::Green)); // go
}
No _ wildcard here, and none is needed: Signal has exactly three variants and we handled all three, so the match is exhaustive. Now imagine you later add a Signal::Flashing variant. Every match on Signal across your entire codebase instantly fails to compile until you handle the new case. In a language without this, you would grep through the code hoping to find every switch, and you would miss one. This is the exhaustiveness guarantee paying rent, and it is why Rust programmers lean on enums and match so heavily.
Option, the null-replacement from episode 1Remember Option from episode 1 -- the type that replaces null? It is itself an enum with two variants, Some(value) and None, and match is the classic way to safely open it up:
fn main() {
let maybe: Option<i32> = Some(10);
match maybe {
Some(value) => println!("got {value}"),
None => println!("got nothing"),
}
}
The Some(value) pattern does two jobs at once: it checks whether the Option is the Some variant and, if so, pulls the inner number out and binds it to value. There is no way to reach the 10 without acknowledging the possibility of None first -- the compiler will not let you forget the empty case. That is how Rust makes the billion-dollar null mistake structurally impossible, and pattern matching is the mechanism that makes working with it pleasant rather than a chore.
Sometimes a pattern gets you most of the way, but you need an extra condition -- a comparison, a computed check. That is a match guard: an if clause tacked onto an arm:
fn main() {
let pair = (5, -5);
match pair {
(x, y) if x + y == 0 => println!("they cancel out"),
(x, _) if x > 0 => println!("first is positive"),
_ => println!("no special relationship"),
}
}
The first arm only fires if the tuple destructures and the guard x + y == 0 holds. A guard can express things a pattern alone never could -- relationships between the bound values, arithmetic, calls to other functions. The (x, _) in the second arm shows another handy trick: the _ inside a pattern means "there is a value here but I do not care about it", so we bind only x and ignore the second field. Guards are checked after the pattern matches, and if the guard fails, match simply moves on to the next arm.
@ bindings: match a range and keep the valueHere is a subtle one that trips people up until they see it. Suppose you want to match a range but also use the concrete value that matched. If you write 1..=50 => ... you tested the range but you did not name the value. The @ operator lets you do both -- bind a name to whatever matched the pattern:
fn main() {
let id = 42;
match id {
n @ 1..=50 => println!("{n} is in the low range"),
n @ 51..=100 => println!("{n} is in the high range"),
n => println!("{n} is out of range"),
}
}
Read n @ 1..=50 as "match anything in 1 to 50, and bind that matched value to n". Without the @ you would have to choose between testing the range and capturing the value; with it you get both. It is a small piece of syntax, but when you need it, nothing else is as tidy.
if let and while let: the shortcutsA full match is sometimes overkill. When you only care about one pattern and want to ignore everything else, if let is the concise form. Compare it mentally to a two-arm match where one arm does nothing:
fn main() {
let config: Option<i32> = Some(9000);
if let Some(limit) = config {
println!("limit is {limit}");
} else {
println!("no limit set");
}
}
That if let Some(limit) = config says "if config matches the pattern Some(limit), run this block with limit bound; otherwise run the else". It is exactly a match with Some(limit) => ... and None => ..., just shorter and easier on the eyes when there is only one interesting case. The else is optional -- drop it if you have nothing to do in the other case.
Its sibling while let keeps looping as long as a pattern keeps matching, which is perfect for draining a collection:
fn main() {
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("popped {top}");
}
}
stack.pop() returns Some(value) while there are elements left and None once the stack is empty. So while let Some(top) = stack.pop() runs the body for each popped element and stops automatically the moment pop hands back None. No manual "is it empty?" check, no index bookkeeping -- the pattern match is the loop condition. (That vec![...] is a growable vector, which gets its own proper episode soon; for now just enjoy that pop returns an Option and while let handles it beautifully.)
So when do you reach for which? A quick rule of thumb, from someone who has written quit some Rust:
if / else if for simple boolean conditions with no structure to pull apart.match when you are branching on the shape or value of something, especially an enum or an Option/Result -- and lean on its exhaustiveness to catch mistakes.if let when you only care about one pattern and a match would be noise.while let to loop until a pattern stops matching.loop with break value when you need to retry until success and carry a result out.You will drift toward match more than you expect. Once the exhaustiveness checking has saved you from a forgotten case a few times, you start reaching for enums and match by reflex, and your programs get more correct almost by accident. ;-)
Three exercises, gentle to chewier. Full solutions at the top of the next episode, as always.
Write a function fizzbuzz(n: u32) that prints "fizz" if n is divisible by 3, "buzz" if divisible by 5, "fizzbuzz" if divisible by both, and the number itself otherwise. Do it with a single match on the tuple (n % 3, n % 5) -- match (0, 0), (0, _), (_, 0), and _. Then call it in a for loop over 1..=20. (This is the classic interview question, and the tuple-match version is genuinely elegant.)
Write a function grade(score: u32) -> char that returns 'A' for 90 and above, 'B' for 80 to 89, 'C' for 70 to 79, and 'F' below 70, using a match with inclusive ranges (90..=100, 80..=89, and so on) plus a catch-all. Return the char directly as the value of the match expression -- no intermediate variable needed.
Given let items: Vec<Option<i32>> = vec![Some(3), None, Some(7), None, Some(1)];, loop over it and sum only the numbers that are actually present, ignoring the Nones. Use a for loop and an if let Some(n) = item inside it to add n to a running total, then print the sum (it should be 11). Bonus: figure out why you may need for item in &items rather than for item in items -- think back to ownership from episode 3.
Exercise 1 is the one to sit with. Expressing FizzBuzz as a single tuple match instead of a ladder of if/else if is a small "aha" that shows you how pattern matching reshapes ordinary code.
if and blocks are expressions in Rust: they evaluate to values, all branches must share a type, and there is no separate ternary because if already fills that role.loop (repeat until break, and break value carries a result out), while (condition up front), and for (for-each over ranges and collections, no manual indices).match is exhaustive, compiler-checked branching -- forget a case and it does not compile, which turns adding an enum variant into a guided refactor instead of a bug hunt.| or-patterns, ..= ranges, _ wildcards, and destructuring that reaches into tuples and enum variants to bind their pieces by name.if on an arm) add conditions patterns cannot express, and @ bindings let you match a range and capture the value.if let and while let are the concise forms for when you only care about one pattern -- and matching Some/None is how you safely open the Option that replaces null.Pattern matching is one of those features that feels like a nicety at first and becomes load-bearing fast. Combined with the ownership rules from last time, you now have most of what you need to write real, correct Rust programs -- you can hold data, decide on it, and loop over it, all with the compiler watching your back. Next time we start giving shape to our data, defining our own types to bundle related values together, and you will see match become even more powerful once it has richer things to match against.
Have fun with match -- it really is one of the best parts of the language. De groeten! ;-)