Control Flow
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Rust 1.99.0 and the 2024 edition, checked on 2026-10-06. Rust’s control-flow constructs
are expressions: if, match and loop all produce values, which removes the need for a ternary operator and for
many temporary mut variables. Pattern matching with match is covered in depth in
Enums & Pattern Matching; this page covers
everything else, plus the pattern-based shorthands if let, let-else, while let and let chains.
if expressions
The condition must be a bool — there is no implicit conversion from integers, Options or strings, and no
parentheses are needed around it. The braces are mandatory.
fn main() {
let copies = 2;
if copies == 0 {
println!("unavailable");
} else if copies < 3 {
println!("few copies left");
} else {
println!("available");
}
}
Source: The Book: if Expressions
Because if is an expression, it can produce a value. Both branches must then have the same type, and an if used
as a value needs an else:
fn main() {
let copies = 2;
let status = if copies == 0 { "unavailable" } else { "available" };
let fee_cents: u32 = if copies > 0 { 0 } else { 150 };
println!("{status} {fee_cents}");
}
Source: The Book: if Expressions
Mismatched branch types are error E0308 (the message says the if and
else branches have incompatible types); an if without else used as a non-unit value is error
E0317. The common coercions still apply: a branch that diverges
(return, panic!, continue) has type ! and fits any other branch.
fn main() {
let copies = 2;
let label = if copies > 0 { "available" } else { 0 }; // error[E0308]: `if` and `else` have incompatible types
println!("{label}");
}
Repetition with loop, while and for
loop and break with a value
loop repeats forever until break. Because loop is an expression, break can carry a value out of it — the
idiomatic way to retry an operation until it succeeds:
fn main() {
let attempts = ["x", "abc", "42"];
let mut i = 0;
let year: u32 = loop {
match attempts[i].parse() {
Ok(n) => break n, // the loop evaluates to n
Err(_) => i += 1,
}
};
println!("parsed {year} after {i} failures"); // parsed 42 after 2 failures
}
A loop without a reachable break has type !, so fn serve() -> ! { loop { ... } } is a valid server main
loop.
Loop labels
break and continue apply to the innermost loop. Label an outer loop with a lifetime-like name ('outer:) to
target it. Labels also work on plain blocks, so break 'label value can exit a block early (1.65):
fn main() {
let shelves = [["Dune", "Emma"], ["Ulysses", "Beloved"], ["Kindred", "Middlemarch"]];
let target = "Beloved";
let mut found = None;
'search: for (s, shelf) in shelves.iter().enumerate() {
for (p, title) in shelf.iter().enumerate() {
if *title == target {
found = Some((s, p));
break 'search; // leave both loops
}
}
}
println!("{found:?}"); // Some((1, 1))
let discount = 'calc: {
if target.len() < 5 {
break 'calc 0; // labelled block: early exit with a value
}
10
};
println!("{discount}"); // 10
}
Source: The Rust Reference: Loop labels
while
while condition { ... } loops as long as the condition is true. It always evaluates to (), so break inside
while cannot carry a value. Use it when the stopping condition is not "end of a sequence":
fn main() {
let mut balance_cents: i32 = 450;
let mut days = 0;
while balance_cents > 0 {
balance_cents -= 100;
days += 1;
}
println!("cleared after {days} days");
}
Source: The Book: Repetition with Loops
for loops over ranges and iterators
for pattern in expression iterates over anything that implements IntoIterator: ranges, arrays, collections,
iterators. It is the safest and usually the fastest loop — there is no index to get wrong and bounds checks are
elided.
fn main() {
for day in 1..=3 { // inclusive range: 1, 2, 3
print!("day {day} ");
}
println!();
for n in (0..10).rev().step_by(3) { // 9, 6, 3, 0
print!("{n} ");
}
println!();
let titles = vec![String::from("Dune"), String::from("Emma")];
for (i, title) in titles.iter().enumerate() { // borrow each element
println!("{i}: {title}");
}
for title in &titles { // same as titles.iter()
print!("{} ", title.len());
}
println!();
for title in titles { // consumes the Vec: titles is moved
drop(title);
}
// println!("{titles:?}"); // would not compile: titles was moved by the for loop (E0382)
}
| Loop over | Element type | The collection afterwards |
|---|---|---|
|
|
still usable |
|
|
still usable, possibly modified |
|
|
moved (consumed) |
The IntoIterator rules and iterator adapters are covered in
Iterators. Ranges are written a..b (exclusive end), a..=b
(inclusive end) and a.. (unbounded); ..b and .. are used for slicing. Rust 1.95 and 1.96 stabilised the core::range module, which
provides new Copy-able range types intended to replace the current ones in a future edition.
if let: matching one pattern
When you only care about one pattern, if let is shorter than a match with a _ => {} arm:
fn main() {
let rating: Option<u8> = Some(4);
if let Some(stars) = rating {
println!("{stars} stars");
} else {
println!("not rated");
}
// The match it replaces:
match rating {
Some(stars) => println!("{stars} stars"),
None => println!("not rated"),
}
}
if let gives up exhaustiveness checking, so prefer match when every variant needs handling.
let-else: bind or bail out
let PATTERN = EXPR else { DIVERGE }; binds the pattern’s variables in the enclosing scope if the pattern matches,
and otherwise runs the else block, which must diverge (return, break, continue, panic!). It is the idiomatic
way to write guard clauses without nesting (stable since 1.65):
fn parse_loan(line: &str) -> Result<(String, u16), String> {
let Some((member, days)) = line.split_once(',') else {
return Err(format!("missing comma in {line:?}"));
};
let Ok(days) = days.trim().parse::<u16>() else {
return Err(format!("bad day count in {line:?}"));
};
Ok((member.trim().to_owned(), days))
}
fn main() {
println!("{:?}", parse_loan("alice, 21")); // Ok(("alice", 21))
println!("{:?}", parse_loan("bob")); // Err("missing comma in \"bob\"")
}
Source: Rust by Example: let-else
The else block that falls through is error E0308 (the message says the
else clause of let...else does not diverge). An irrefutable pattern (one that always matches) with else triggers the
irrefutable_let_patterns warning.
while let
while let loops as long as a pattern matches — typically draining a stack or pulling from a channel or iterator:
fn main() {
let mut returns = vec!["Dune", "Emma", "Ulysses"];
while let Some(title) = returns.pop() {
println!("reshelving {title}");
}
}
Source: Rust by Example: while let
Let chains (1.88, edition 2024)
Let chains allow if let and while let conditions to be combined with each other and with ordinary boolean
expressions using &&. Bindings from earlier conditions are available in later ones. This removes the "rightward
drift" of nested if let blocks. Let chains were stabilised in Rust 1.88 and are available only in crates on the
2024 edition, because the edition changed the drop order of if let temporaries that let chains depend on.
use std::collections::HashMap;
fn due_soon(loans: &HashMap<&str, u16>, member: &str) -> bool {
if let Some(&days) = loans.get(member)
&& days <= 3
&& let Some(initial) = member.chars().next()
&& initial.is_ascii_alphabetic()
{
println!("{member}: due in {days} days");
true
} else {
false
}
}
fn main() {
let loans = HashMap::from([("alice", 2), ("bob", 14)]);
assert!(due_soon(&loans, "alice"));
assert!(!due_soon(&loans, "bob"));
}
|| is not allowed between let conditions, because a binding would not be guaranteed to exist in the body. In a
2021-edition crate, the same code is rejected with "let chains are only allowed in Rust 2024 or later".
if let guards in match arms (1.95)
A match guard is an extra if condition on a match arm. Since Rust 1.95, a guard can itself be an if let,
binding variables that the arm body can use; previously you needed a nested match or a let chain in the body:
enum Request {
Lend { isbn: String },
Search(String),
}
fn handle(req: &Request) -> String {
match req {
Request::Lend { isbn } if let Some(prefix) = isbn.strip_prefix("978") => {
format!("lend book with registration group {}", &prefix[..1])
}
Request::Lend { isbn } => format!("unsupported ISBN {isbn}"),
Request::Search(q) if q.is_empty() => "empty query".to_string(),
Request::Search(q) => format!("search for {q}"),
}
}
fn main() {
println!("{}", handle(&Request::Lend { isbn: "9780441013593".into() }));
println!("{}", handle(&Request::Lend { isbn: "123".into() }));
println!("{}", handle(&Request::Search(String::new())));
}
Source: The Rust Reference: Match guards
If the if let guard does not match, matching continues with the next arm, exactly like a boolean guard that
evaluates to false. Guards are not considered by exhaustiveness checking, so a final unguarded arm is still needed.
The 2024 edition’s temporary-scope changes
Temporaries — values created while evaluating an expression, such as the MutexGuard returned by lock() or the
Ref returned by RefCell::borrow() — are dropped at the end of their temporary scope. The 2024 edition shortened
two of those scopes, which fixes a class of surprising deadlocks and borrow errors.
if let scrutinee temporaries (if_let_rescope): in 2021, temporaries in the if let condition lived until the
end of the whole if let ... else statement, including the else block. In 2024 they are dropped before the else
block runs:
use std::sync::Mutex;
fn take_or_restock(stock: &Mutex<Vec<&'static str>>) -> &'static str {
if let Some(title) = stock.lock().unwrap().pop() {
title
} else {
// Edition 2024: the guard from `stock.lock()` above has already been dropped here,
// so locking again is fine. In edition 2021 this second lock() deadlocks.
stock.lock().unwrap().push("Dune");
"restocked"
}
}
fn main() {
let stock = Mutex::new(vec![]);
println!("{}", take_or_restock(&stock)); // restocked
println!("{}", take_or_restock(&stock)); // Dune
}
Tail-expression temporaries (tail_expr_drop_order): in 2021, temporaries in a block’s tail expression were dropped
after the block’s local variables, which made code like the following fail to compile with "borrowed value does
not live long enough" (E0597). In 2024 they are dropped before the locals:
use std::cell::RefCell;
fn count() -> usize {
let shelf = RefCell::new(vec!["Dune", "Emma"]);
shelf.borrow().len() // 2024: the `Ref` temporary is dropped before `shelf`; 2021: E0597
}
fn main() {
println!("{}", count());
}
cargo fix --edition rewrites code whose behaviour would change — for example, it turns an if let whose guard must
live through the else block into a match. The rules for temporary scopes in general are in the Reference’s
"Destructors" chapter and on Drop, RAII & Drop Check.
Choosing a construct
| You want to … | Use |
|---|---|
choose between two values |
|
handle every variant of an enum |
|
act on one pattern and ignore the rest |
|
bind a value or return early |
|
combine patterns and boolean tests |
let chains (2024) |
refine a |
|
retry until success and keep the result |
|
iterate a collection or range |
|
consume a stack, queue or channel |
|
Where the books are outdated
None of the consulted books covers let chains (1.88), if let guards (1.95) or the 2024 edition’s temporary-scope
changes, and the older editions they target (2018 and 2021) reject let chains outright. Code from them that holds a
lock in an if let condition and locks again in the else branch deadlocks on 2021 but works on 2024.
References
-
The Rust Programming Language: Concise Control Flow with if let and let…else
-
Rust by Example: Flow of Control (if/else, loop, while, for, let-else, if let, while let)