Skip to content
academia.sh

Lesson 09 / 12

Pattern Matching

The same Circle(5.0) value is taken apart by three separate pattern forms — exact, binding, guarded — and match itself, as an expression, gives the same type across every arm. The limiting measurement shows that a catch-all arm provides exhaustiveness but can silently swallow a new variant.

Published Updated

In this lesson

  • Shows that match is an expression and that all of its arms must give the same type
  • Verifies that the same value can be taken apart with an exact, a binding, and a guarded pattern, and that all three belong to the same class
  • Explains why the compiler requires exhaustiveness in match arms
  • Shows with an example that a catch-all arm provides exhaustiveness but cannot protect against a new variant
Contents

The Enums lesson measured how many separate forms a Shape value can take and how those forms are constructed in the source — but it left reading which variant the constructed value carries to two helper functions, in the background. match itself was never opened: which pattern forms exist, when one arm comes before another, why the compiler requires every variant to be handled? This lesson opens exactly these three questions.

match is the construct in Rust that compares a value against patterns and runs the body of the first matching arm. The Control Flow lesson already introduced match as an expression — it produces a value and every arm must give the same type. This lesson deepens that definition: with how many different pattern forms can the same value be caught, and do all of these forms really fall into the same class?

Taking values apart with patterns is not itself a new concept in this course; the idea of branching on the shape of a value has already been established. What this lesson measures is a requirement specific to Rust’s own implementation of that idea: an arm left out prevents the source from compiling. The scope and limits of this requirement are measured in detail below.

match Is an Expression, All Arms Give the Same Type

The result of a match expression depends on which arm matches — but the type of the result does not depend on which arm matches. The compiler checks at compile time that every arm of a match gives a value of the same type; if one arm gives &str and another gives i32, the source does not compile. This rule is a direct consequence of match being an expression: an expression has a single type, and that type cannot change depending on which arm runs.

The run below takes apart a single Shape::Circle(5.0) value with three separate functions — three separate pattern forms.

// three_pattern_forms.rs — match is an expression, the same value is taken apart three ways
enum Shape {
    Empty,
    Circle(f64),
    Rectangle(f64, f64),
}

fn classify_exact(s: &Shape) -> &'static str {
    match s {
        Shape::Empty => "empty",
        Shape::Circle(_) => "circle",
        Shape::Rectangle(_, _) => "rectangle",
    }
}

fn classify_binding(s: &Shape) -> String {
    match s {
        Shape::Empty => "empty".to_string(),
        Shape::Circle(radius) => format!("circle radius={}", radius),
        Shape::Rectangle(width, height) => format!("rectangle {}x{}", width, height),
    }
}

fn classify_guarded(s: &Shape) -> &'static str {
    match s {
        Shape::Circle(radius) if *radius > 3.0 => "large circle",
        Shape::Circle(_) => "small circle",
        Shape::Rectangle(width, height) if width == height => "square",
        Shape::Rectangle(_, _) => "rectangle",
        Shape::Empty => "empty",
    }
}

fn main() {
    let value = Shape::Circle(5.0);
    println!("{:<20}: {}", "exact pattern", classify_exact(&value));
    println!("{:<20}: {}", "binding pattern", classify_binding(&value));
    println!("{:<20}: {}", "guarded pattern", classify_guarded(&value));

    let small = Shape::Circle(1.0);
    println!("{:<20}: {}", "guarded (small)", classify_guarded(&small));

    let square = Shape::Rectangle(4.0, 4.0);
    println!("{:<20}: {}", "guarded (square)", classify_guarded(&square));

    let empty = Shape::Empty;
    println!("{:<20}: {}", "exact (empty)", classify_exact(&empty));
}
exact pattern       : circle
binding pattern     : circle radius=5
guarded pattern     : large circle
guarded (small)     : small circle
guarded (square)    : square
exact (empty)       : empty

DM8. — The claim “every arm of match gives the same type” is measured here by the three functions’ own return-type signatures: classify_exact and classify_guarded declare in their signature that they return &'static str, classify_binding declares that it returns String. Each match expression inside the three functions matches the single type in its own signature; otherwise the source would not compile at all.

The Same Value Taken Apart by Three Pattern Forms

classify_exact, classify_binding, and classify_guarded all operated on the same value — Shape::Circle(5.0) — but each caught the value with a different pattern form.

The exact pattern looks only at which variant matched; it never touches the carried data. In the arm Shape::Circle(_) => "circle", the underscore (_) means “whatever is in this position” — whether radius’s value is 5.0 or 0.3 makes no difference, the arm only checks whether the variant is Circle. This is exactly what the variant_name function did in the previous lesson; there too, every variant was distinguished only by its name, without ever reading the data it carried.

The binding pattern does not stop at matching the variant, it also binds the carried data to a name. In the arm Shape::Circle(radius) => ..., the name radius is bound to the 5.0 inside the matched value, and it can be used inside the arm’s body, placed into a text template. The circle radius=5 seen in the output is exactly the result of this binding.

The difference between the binding pattern and the exact pattern is not just “whether a name is written or not”; the bound name is a variable genuinely usable in the arm’s body. In the classify_binding function, radius is passed directly as an argument filling a text template — it behaves exactly like an ordinary function parameter. The underscore (_) in the exact pattern, on the other hand, is not a name; it binds to nothing — it only says “there is a value here, but I am not interested in it.”

The guarded pattern adds a condition (if) on top of a binding pattern; in Rust this is called a match guard. The arm Shape::Circle(radius) if *radius > 3.0 => "large circle" matches only when both the variant is Circle and the bound radius is greater than 3.0. Since value’s radius is 5.0, this condition is satisfied and the result is "large circle"; the small value (Circle(1.0)) does not fall into the same arm, because the condition is not satisfied, and it falls to the arm below, Shape::Circle(_) => "small circle".

DM9. — The claim that the three pattern forms are all “in the same class” is measured by all three producing a value as the body of a match expression: none of them was written for a side effect, all three were directly the return value of a function. Whether a pattern is exact, binding, or guarded does not change whether match itself is an expression — all three are instances of the same match ... { pattern => value, ... } form.

The square example takes this a step further: the arm Shape::Rectangle(width, height) if width == height => "square" binds two fields at once and compares them to each other. Since the square value is Rectangle(4.0, 4.0), this condition is also satisfied. A match guard can use not just a single bound name but several bound names at once — the binding itself is the pattern’s job, the condition itself is an ordinary logical expression. The comparison width == height is not a syntax specific to match; it follows the same rules as comparing any two decimal numbers in any context, it is just applied here to the two names the pattern bound.

The arm order in the classify_guarded function is not arbitrary. The arm Shape::Circle (radius) if *radius > 3.0 is written before the unconditioned arm Shape::Circle(_) => "small circle"; match arms are tried in order from top to bottom, and the first matching arm wins. If these two arms swapped places, the pattern Circle(_), carrying no condition at all, would catch every Circle value — large or small — and no value would ever reach the conditioned arm below. The same rule applies to the Rectangle arms: the square check has to come before the unconditioned Rectangle arm, otherwise every rectangle — square or not — would fall into the general arm.

This ordering requirement does not show up in the classify_exact and classify_binding functions, because none of the arms there is a subset of another: Empty, Circle(_), and Rectangle(_, _) are disjoint variants, and they catch the same value no matter what order they are written in. Ordering only becomes significant when more than one arm targets the same variant — as with the guarded patterns.

Exhaustiveness: Why a Missing Arm Fails to Compile

match carries a constraint specific to Rust: every arm together must cover every possible variant of the matched value. This is called exhaustiveness. The classify_exact function above wrote three arms — Shape::Empty, Shape::Circle(_), and Shape::Rectangle(_, _) — all three variants in Shape’s declaration. If one of these arms, say the line Shape::Rectangle(_, _) => "rectangle", were removed from the source, the remaining source would not compile.

This lesson does not place source that fails to compile into a code block, because source that does not compile never enters a block in the first place — a rejected piece of writing has no output, and therefore produces no number to verify by running it; but the reason for the rejection can still be described here. When the compiler sees an incomplete piece of writing like match s { Shape::Empty => ..., Shape::Circle(_) => ... }, it knows that s can carry a Shape::Rectangle value at runtime — because that is a variant the enum Shape declaration allows. Since none of the arms present covers that possibility, the compiler cannot say which arm would run, and it rejects the source. This rejection ensures that an “unexpected value” error never occurs at runtime at all: a missing arm is caught before the program runs, during compilation.

DM10. — The claim that exhaustiveness is genuinely required is not measured here by a separate compiler invocation — since source that fails to compile does not enter a code block — but there is indirect proof: each of the three functions above covers all three of Shape’s variants. If exhaustiveness were not required, there would be no reason to write out all three arms in full; all three are there only because they are necessary.

The exhaustiveness check operates by looking at the type of the value the match in the source matches; it does not look at what values the carried data can take. Shape::Circle (f64) can carry an uncountably large number of radius values, but the compiler does not check this infinity one by one — it only asks whether the Circle variant is handled in some arm. The distinction measured in the Enums lesson — “variant count is not state count” — holds here too: exhaustiveness is a guarantee at the variant level, not the state level. As long as one arm writes Shape::Circle(radius), that variant is considered covered, regardless of which value radius carries.

Limiting Measurement: a Catch-All Arm Provides Exhaustiveness but Does Not Protect

There is a shortcut for satisfying the exhaustiveness rule: writing _ => ... as the last arm. This arm covers all remaining variants — however many there are in the source — in a single stroke and satisfies the compiler. But this satisfaction does not mean the arm genuinely considers every variant separately; it only means none of them was left out.

// exhaustiveness_catchall.rs — a catch-all arm provides exhaustiveness but does not protect
enum OldShape {
    Circle(f64),
    Rectangle(f64, f64),
}

enum NewShape {
    Circle(f64),
    Rectangle(f64, f64),
    #[allow(dead_code)]
    Triangle(f64, f64, f64),
}

fn label_old(s: &OldShape) -> String {
    match s {
        OldShape::Circle(radius) => format!("circle r={}", radius),
        OldShape::Rectangle(width, height) => format!("rectangle {}x{}", width, height),
    }
}

fn label_new(s: &NewShape) -> String {
    match s {
        NewShape::Circle(radius) => format!("circle r={}", radius),
        NewShape::Rectangle(width, height) => format!("rectangle {}x{}", width, height),
        _ => "other".to_string(),
    }
}

fn main() {
    let old_circle = OldShape::Circle(2.0);
    let old_rectangle = OldShape::Rectangle(2.0, 2.0);
    println!("{:<20}: {}", "old: circle", label_old(&old_circle));
    println!("{:<20}: {}", "old: rectangle", label_old(&old_rectangle));

    let new_circle = NewShape::Circle(2.0);
    let new_rectangle = NewShape::Rectangle(2.0, 2.0);
    let new_triangle = NewShape::Triangle(3.0, 4.0, 5.0);
    println!("{:<20}: {}", "new: circle", label_new(&new_circle));
    println!("{:<20}: {}", "new: rectangle", label_new(&new_rectangle));
    println!("{:<20}: {}", "new: triangle", label_new(&new_triangle));
}
old: circle         : circle r=2
old: rectangle      : rectangle 2x2
new: circle         : circle r=2
new: rectangle      : rectangle 2x2
new: triangle       : other

OldShape carries two variants, and label_old handles both explicitly, by name — there is no catch-all arm, because the two arms already provide exhaustiveness on their own. Then the source moves to NewShape: the same two variants (Circle, Rectangle) are kept, but a third variant (Triangle) is added. The label_new function carries the same two arms as label_old, and instead of writing a third arm, adds _ => "other".to_string().

This source compiles without a problem — exhaustiveness is satisfied, because _ covers every variant. But when a value with real data like Triangle(3.0, 4.0, 5.0) enters this code, the result is only "other": the triangle’s three side lengths (3.0, 4.0, 5.0) are never read anywhere, never factored into any computation. Even though Triangle is a different variant from Circle and Rectangle, it receives no separate treatment at the code level.

DM11. — The claim “a catch-all arm provides exhaustiveness but does not protect” is measured here by running two enums side by side: OldShape and NewShape carry the same two variants (Circle, Rectangle) and give the same result for the same two inputs (2.0 and 2.0, 2.0) — "circle r=2" and "rectangle 2x2". The only difference is that NewShape adds a third variant and label_new leaves that variant to _. The output’s last line — new: triangle : other — directly shows that this new variant falls to the catch-all without receiving treatment of its own.

This sets firm boundaries on what exhaustiveness guarantees. The compiler only answers yes to the question “is there one arm for every variant”; it does not answer the question “was every variant handled with its own logic.” Adding a variant to an enum later lets a match with a _ arm pass through without a compile error — no notice comes from the compiler for the developer to notice the new variant, because the exhaustiveness rule is already satisfied. The situation is different for a match without a catch-all: the moment a new variant is added, every match that does not cover that variant stops compiling and takes the developer directly to that line.

This difference is also visible in how label_old and label_new are written. label_old, because it writes both of OldShape‘s variants by name, would stop compiling if a third variant were added to OldShape — the code would directly show which line was missing. label_new, because it carries a _ arm, keeps compiling even if a fourth variant were added to NewShape; the new variant, even though it resembles none of the previous three, falls into the same "other" result. Both functions satisfy the exhaustiveness rule, but only one of them puts a future change squarely in front of the developer’s eyes.

These three lessons — structs, enums, pattern matching — each answered a separate question about a data model: which fields a value carries, how many separate forms it can take, and how those forms are read from the source. So far every measurement was made on a single value — a single Point, a single Shape. But a real program’s data is almost never made up of a single value; a large number of values have to be held together at once.

Summary

  • match is an expression; all of its arms must give a value of the same type, otherwise the source does not compile.
  • The same value can be taken apart with an exact pattern (checking only the variant), a binding pattern (binding the data to a name), and a guarded pattern (adding a condition to the bound data); all three are instances of the same match structure.
  • Exhaustiveness requires a match to cover every possible variant of the matched value; a variant left out makes the source fail to compile.
  • A catch-all arm (_) provides exhaustiveness but does not protect: it silently covers a variant added later without handling it with its own logic.

Next Step

This lesson measured how a single Shape value is taken apart. But real programs hold not a single value but a large number of values at once — every shape in a list, every name in a dictionary. So far every lesson has treated a single value bound to a single variable; the next lesson lifts that limit and carries the same question to a group of values: how are a large number of values held at the same time, and which class do the mutating methods of these collections (adding, removing) fall into?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close