<< Home

Chapter 4: Control Flow and Pattern Matching

Control flow in Rust covers the familiar tools such as if, loops and iteration. Rust also includes structural pattern matching, which lets you describe shapes of data and act on them cleanly. This chapter brings these ideas together so your programs can make choices, repeat work, open up complex values and react to success or failure with clarity.

Using if, loop, while, and for

Rust’s branching and looping tools are small and direct. The if expression chooses between paths; loop repeats forever until you break; while repeats while a condition is true; for walks through an iterator. Because each construct is an expression, you can capture values produced inside them when it suits the design.

Basic if expressions

An if expression evaluates a condition and picks one branch. Each branch must yield compatible types if you use the expression’s value.

let n = 7;
let parity = if n % 2 == 0 { "even" } else { "odd" };
println!("{parity}");

This example returns a string slice from either branch. You can extend the chain with else if when more conditions are needed.

Using loop for open-ended repetition

loop is a simple infinite loop. You end it with break, optionally returning a value from the break expression.

let mut count = 0;
let final = loop {
  count += 1;
  if count == 3 {
    break count * 2;
  }
};
println!("{final}");

The value after break becomes the loop’s result. This makes loop useful when you want a clear exit point that produces something.

while loops

while evaluates its condition first. If it is true the body runs; if not the loop ends. This is helpful when reading from input or slicing through data until a condition no longer holds.

let mut n = 5;
while n > 0 {
  println!("{n}");
  n -= 1;
}

Because the condition is rechecked each time, this loop adapts to changing values naturally.

for loops over iterators

for works with any type that becomes an iterator. It retrieves each item in turn and binds it to a pattern of your choice.

for x in 1..4 {
  println!("{x}");  // prints 1, 2, 3
}

Ranges create simple iterators. Many container types also implement iteration so you can walk through their items directly.

💡 for loops automatically borrow collections, so you do not consume them unless you iterate by value.

match fundamentals

The match expression compares a value against a set of patterns and runs the code for the first matching branch. Patterns can test literal values, ranges, enum variants, tuple shapes and more. Every match must be exhaustive; you must cover all possible shapes or include a final wildcard pattern.

Literal and range matches

You can match single values or ranges inside a branch. This works well for selective branching without nested if constructs.

let score = 8;

let label = match score {
  0 => "none",
  1..=5 => "low",
  6..=10 => "high",
  _ => "unknown"
};

println!("{label}");

Here the wildcard branch _ ensures the match is complete.

Matching enum variants

Enums are a natural fit for match because each variant becomes a distinct pattern. This produces clean control flow when you handle structured data.

enum Direction { North, South, East, West }

let d = Direction::East;

match d {
  Direction::North => println!("up"),
  Direction::South => println!("down"),
  Direction::East => println!("right"),
  Direction::West => println!("left")
}

Each branch handles one variant and nothing else.

Destructuring patterns

Patterns can open complex structures so you can work with the inner pieces directly. This technique is called destructuring and it works with tuples, structs, arrays and enum variants that hold data.

Destructuring tuples

Tuples break apart naturally inside patterns. You can bind each element to a name or ignore parts you do not need.

let point = (3, 4);

let (x, y) = point;
println!("{x}, {y}");

You can also use _ to mark unused fields without introducing a binding:

let triple = (10, 20, 30);

// We only care about the first value.
// The middle value is named, and the last value is ignored.
let (first, middle, _) = triple;

println!("{first} and {middle}");

Destructuring struct fields

Struct patterns allow you to list the fields you care about. Any field you omit is ignored.

struct User { name: String, id: u32 }

let u = User { name: "Ada".into(), id: 7 };

let User { name, id } = u;
println!("{name} #{id}");

You may also rename fields inside the pattern if needed, using the form field: new_name.

⚠️ Destructuring follows ownership rules. Fields that are Copy duplicate automatically; other fields move unless you borrow them.

Destructuring enum variants

Enum variants that hold data unpack cleanly inside a match. This lets you bind the contained values directly.

enum Event { Click(x: i32, y: i32), Quit }

let e = Event::Click(10, 20);

match e {
  Event::Click(x, y) => println!("clicked at {x}, {y}"),
  Event::Quit => println!("goodbye")
}

This approach keeps branching logic tidy even when values become more complex.

Using if let and while let

if let and while let are shortcuts for matching a single pattern and ignoring everything else. They shine when you want to unwrap one shape of data and skip the rest without writing a full match expression.

Using if let

if let runs its body only when the pattern matches. This is compact when you expect one case most of the time.

let v = Some(10);

if let Some(n) = v {
  println!("{n}");
}

Here the inner n is bound only if the option contains a value.

Using while let

while let repeats as long as the pattern keeps matching. It is often used to pull values from iterators or stateful types.

let mut v = vec![1, 2, 3];

while let Some(x) = v.pop() {
  println!("{x}");
}

This continues until pop() returns None.

💡 if let and while let reduce nesting. Use them when a single pattern matters and everything else can be ignored.

Option and Result in practice

Option and Result encode presence or absence of values, and success or failure of operations. They make control flow explicit by including every possibility in the type system. Pattern matching is the natural way to handle them, and many methods on these types help with common tasks.

Working with Option<T>

Option has two variants: Some(T) for a present value and None for no value. Match on them to act on each case.

let maybe = Some("Rust");

match maybe {
  Some(name) => println!("{name}"),
  None => println!("nothing here")
}

Methods like unwrap_or, map and and_then help you chain operations without branching by hand.

Working with Result<T, E>

Result expresses either success Ok(T) or failure Err(E). This pattern avoids hidden exceptions and encourages explicit handling.

fn parse(s: &str) -> Result {
  s.parse().map_err(|_| "not a number")
}

match parse("42") {
  Ok(n) => println!("got {n}"),
  Err(e) => println!("{e}")
}

The ? operator propagates errors automatically by returning early when an Err appears.

⚠️ Option expresses absence; Result expresses failure. Choosing the right one keeps intent clear for readers and tools.