Rust · lesson 6 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Control Flow in Rust
Master if/else expressions, loop, while, for, ranges, and breaking with values in Rust.
By SkillByExample · Updated
if / else as an Expression
Unlike C or Java, if/else in Rust is an expression — it produces a value that can be assigned to a variable or returned from a function. This eliminates the need for a separate ternary operator and makes code more compositional. Both arms of an if expression must produce the same type; mismatched types are a compile error.
fn main() {
let number = 7;
// Statement form — used for side effects
if number < 10 {
println!("single digit");
} else if number < 100 {
println!("double digit");
} else {
println!("three or more digits");
}
// Expression form — both arms produce the same type (&str here)
let description = if number % 2 == 0 { "even" } else { "odd" };
println!("{} is {}", number, description);
// Used directly in a let binding — replaces the ternary operator
let abs = if number >= 0 { number } else { -number };
println!("abs = {}", abs);
}
$ rustc control_flow_if.rs && ./control_flow_if
single digit
7 is odd
abs = 7
loop — Infinite Loop
loop runs forever until an explicit break. It is the idiomatic way to express “keep trying until a condition is met” — for example, retrying an operation or polling for input. The key advantage over while true is that loop can return a value, and the compiler recognises it as a type that never naturally falls through, which helps with type inference.
fn main() {
let mut counter = 0;
loop {
counter += 1;
if counter == 5 {
break; // exit the loop
}
}
println!("counter = {}", counter);
}
$ rustc control_flow_loop.rs && ./control_flow_loop
counter = 5
Returning a Value from loop
Pass a value to break to return it from the loop expression. This is useful for retry loops where you want to capture the result of the successful attempt.
fn main() {
let mut attempt = 0;
// The result of the loop expression is whatever value was passed to break
let result = loop {
attempt += 1;
if attempt * attempt > 50 {
break attempt; // break with a value — this becomes the loop expression's value
}
};
println!("first n where n² > 50: {}", result);
}
$ rustc control_flow_loopval.rs && ./control_flow_loopval
first n where n² > 50: 8
Loop Labels
Labels ('label:) let you break or continue an outer loop from within a nested loop, which is cleaner than managing boolean flags to signal early exit.
fn main() {
'outer: for x in 0..5 {
for y in 0..5 {
if x + y == 6 {
println!("breaking outer at x={}, y={}", x, y);
break 'outer; // exits the outer loop entirely
}
}
}
}
$ rustc control_flow_labels.rs && ./control_flow_labels
breaking outer at x=2, y=4
while Loop
while executes its body as long as a condition is true. It is the right tool when the number of iterations is not known upfront but the termination condition is a simple boolean expression.
fn main() {
let mut n = 1;
while n < 100 {
n *= 2; // keep doubling
}
println!("first power of 2 >= 100: {}", n);
}
$ rustc control_flow_while.rs && ./control_flow_while
first power of 2 >= 100: 128
while let is a pattern-matching variant that loops as long as a destructuring pattern matches. It is the idiomatic way to consume an iterator or drain a stack:
fn main() {
let mut stack = vec![1, 2, 3];
// Loops until stack.pop() returns None (when the vec is empty)
while let Some(top) = stack.pop() {
println!("{}", top);
}
}
$ rustc control_flow_whilelet.rs && ./control_flow_whilelet
3
2
1
for Loop and Iterators
for iterates over anything that implements the IntoIterator trait — arrays, vectors, ranges, hash maps, and more. It is the most idiomatic loop in Rust because it removes the possibility of off-by-one errors and out-of-bounds indexing entirely. You never manage an index manually.
fn main() {
let fruits = ["apple", "banana", "cherry"];
// &fruits borrows the array — fruits is still usable after the loop
for fruit in &fruits {
println!("{}", fruit);
}
}
$ rustc control_flow_for.rs && ./control_flow_for
apple
banana
cherry
Iterating with Index
When you need both the element and its position, use .enumerate(). This is safer and more idiomatic than maintaining a separate counter variable.
fn main() {
let words = ["zero", "one", "two", "three"];
// enumerate() yields (index, &element) tuples
for (i, word) in words.iter().enumerate() {
println!("{}: {}", i, word);
}
}
$ rustc control_flow_enumerate.rs && ./control_flow_enumerate
0: zero
1: one
2: two
3: three
Ranges
Ranges give you a concise way to iterate over a sequence of numbers without allocating anything. The exclusive form 0..n is most common; the inclusive form 0..=n is used when the upper bound should be included.
fn main() {
// Exclusive range: produces 0, 1, 2, 3, 4
for i in 0..5 {
print!("{} ", i);
}
println!();
// Inclusive range: produces 0, 1, 2, 3, 4, 5
for i in 0..=5 {
print!("{} ", i);
}
println!();
// Reverse iteration
for i in (0..5).rev() {
print!("{} ", i);
}
println!();
// Step by a custom amount
for i in (0..20).step_by(3) {
print!("{} ", i);
}
println!();
}
$ rustc control_flow_ranges.rs && ./control_flow_ranges
0 1 2 3 4
0 1 2 3 4 5
4 3 2 1 0
0 3 6 9 12 15 18
Consuming vs. Borrowing
How you write the for loop determines whether the collection is borrowed (left intact) or consumed (moved into the loop):
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// Borrow with &numbers — the vec is still usable after the loop
for n in &numbers {
print!("{} ", n);
}
println!();
println!("still have: {:?}", numbers);
// Consume — numbers is moved into the loop and dropped when it ends
for n in numbers {
print!("{} ", n);
}
println!();
// println!("{:?}", numbers); // ERROR: value moved into the for loop
}
$ rustc control_flow_consume.rs && ./control_flow_consume
1 2 3 4 5
still have: [1, 2, 3, 4, 5]
1 2 3 4 5
continue — Skip to Next Iteration
continue skips the remainder of the current loop body and jumps to the next iteration. Like break, it can be used with labels to affect an outer loop.
fn main() {
for i in 0..10 {
if i % 2 == 0 {
continue; // skip even numbers — jump straight to the next i
}
print!("{} ", i);
}
println!();
}
$ rustc control_flow_continue.rs && ./control_flow_continue
1 3 5 7 9
match as Control Flow
match is a powerful expression for branching on values. It is exhaustive — the compiler requires every possible value to be handled. Full coverage is in the pattern matching tutorial; here is a quick look at how it reads as control flow:
fn classify(n: i32) -> &'static str {
match n {
i32::MIN..=-1 => "negative",
0 => "zero",
1..=9 => "single digit",
_ => "large", // _ catches everything else
}
}
fn main() {
println!("{}", classify(-5));
println!("{}", classify(0));
println!("{}", classify(7));
println!("{}", classify(100));
}
$ rustc control_flow_match.rs && ./control_flow_match
negative
zero
single digit
large
Practical Example: FizzBuzz
This classic problem demonstrates combining multiple control flow features in a concise, idiomatic way. The match on a tuple of remainders is a particularly clean Rust solution.
fn fizzbuzz(n: u32) -> String {
// Match on a tuple of both remainders simultaneously
match (n % 3, n % 5) {
(0, 0) => String::from("FizzBuzz"), // divisible by both
(0, _) => String::from("Fizz"), // divisible by 3 only
(_, 0) => String::from("Buzz"), // divisible by 5 only
_ => n.to_string(), // not divisible by either
}
}
fn main() {
for i in 1..=20 {
println!("{}", fizzbuzz(i));
}
}
$ rustc control_flow_fizzbuzz.rs && ./control_flow_fizzbuzz
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Summary
| Construct | Key Property |
|---|---|
if/else | Expression; both arms must have the same type |
loop | Infinite; can return a value via break value |
while cond | Loops while condition is true |
while let pat | Loops while pattern matches |
for x in iter | Iterates any IntoIterator; most idiomatic |
break / continue | Works with optional labels for nested loops |
match | Pattern-matching expression; must be exhaustive |
Practice
-
Give the two arms of an
if/elseexpression different types:let x = if true { 5 } else { "no" };. What does the compiler say?Solution
error[E0308]: \if` and `else` have incompatible types—expected integer, found `&str`. Becauseif/else` is an expression with a single result type, every arm must agree; there’s no implicit “pick whichever” the way a dynamic language would allow. -
Remove the
_ => "large"catch-all arm fromclassify, leaving only0,1..=9, and negative numbers unhandled. What does the compiler say aboutclassify(100)?Solution
error[E0004]: non-exhaustive patternsnaming the specific uncovered ranges (here, everything belowi32::MIN..=-1— already covered in the real lesson — and above9).matchmust be provably exhaustive at compile time; the compiler doesn’t wait to find out at runtime whether every case was handled, unlike aswitchin most other languages that silently falls through. -
In the nested-loop label example, replace
break 'outer;withcontinue 'outer;. Predict how the printed output changes.Solution
With
break 'outer, the loop exits entirely the first time the condition is hit. Withcontinue 'outer, the outer loop instead moves on to its next iteration ofx— so usingif y == 1 { continue 'outer; }inside a0..3/0..3nested loop prints only they=0line for everyx(x=0 y=0,x=1 y=0,x=2 y=0) instead of stopping after the first outer iteration. -
The consuming/borrowing example uses
for n in &numbersto read without taking ownership. Write a loop that mutates each element in place usingfor n in &mut numbersand*n *= 10.Solution
let mut nums = vec![1, 2, 3]; for n in &mut nums { *n *= 10; } println!("{:?}", nums); // [10, 20, 30]&mut numbersyields&mut i32items;*ndereferences to the actuali32so*= 10mutates the vec’s storage directly, no index needed. -
Rewrite the FizzBuzz function to use chained
if/else ifinstead ofmatchon a tuple. Which version reads more clearly, and why might the tuple-match version be preferred in Rust specifically (hint: think about what the compiler can check for you)?Solution
An
if/else ifchain checkingn % 15 == 0, thenn % 3 == 0, thenn % 5 == 0, then the fallback works identically — this is a style choice, not a correctness one. The tuple-matchversion is often preferred in Rust becausematchforces you to (exhaustively) consider every combination of(n % 3, n % 5)explicitly by name —(0, 0),(0, _),(_, 0),_— making the four distinct cases visually explicit rather than implicit in a chain of conditions, and the compiler will refuse to compile if a future edit leaves a combination unhandled.