Pattern Matching in Rust, and Why C++ Still Can't Compete

January 26, 2026 ยท Freek van Keulen

Pattern matching lets you express "what shape of data am I dealing with, and what should happen in each case" in a single construct. In Rust, this idea is baked into the language and reused everywhere. In C++, similar logic is expressed through a mix of statements, library calls, and conventions.

The following sections show this difference concretely.

What Rust Means by "Patterns"

In Rust, patterns destructure data and bind names at the same time.

let (x, y) = (3, 4);

The same idea appears in many places, always with the same rules.

C++ supports structured bindings, but only in limited contexts.

auto [x, y] = std::pair{3, 4};

This looks similar, but the similarity ends quickly, because C++ does not reuse this mechanism consistently elsewhere.

match in Rust vs switch in C++

Rust

enum State {
    Idle,
    Working(u32),
    Failed(String),
}

fn describe(state: State) -> String {
    match state {
        State::Idle => "idle".to_string(),
        State::Working(progress) => format!("working: {}", progress),
        State::Failed(reason) => format!("failed: {}", reason),
    }
}

This match is exhaustive. If a new variant is added, the compiler forces you to update this code.

C++

enum class State { Idle, Working, Failed };

std::string describe(State state) {
    switch (state) {
        case State::Idle: return "idle";
        case State::Working: return "working";
        case State::Failed: return "failed";
        default: return "unknown";
    }
}

There is no exhaustiveness guarantee, no destructuring, and the default branch silently hides missing cases.

Conditional Matching: if let vs Idiomatic C++

Rust

let value = Some(42);

if let Some(x) = value {
    println!("value is {}", x);
}

This expresses intent directly: proceed only if the pattern matches.

C++

std::optional<int> value = 42;

if (value.has_value()) {
    std::cout << "value is " << *value << "\n";
}

The check and the extraction are separate operations. The compiler does not enforce their relationship.

Looping with while let

Rust

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

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

The loop condition is a pattern match.

C++

std::vector<int> stack{1, 2, 3};

while (!stack.empty()) {
    int x = stack.back();
    stack.pop_back();
    std::cout << x << "\n";
}

The same logic is expressed imperatively and requires manual coordination.

Destructuring in Function Parameters

Rust

fn length((x, y): (i32, i32)) -> f64 {
    ((x * x + y * y) as f64).sqrt()
}

The tuple is destructured at the boundary.

C++

double length(const std::pair<int, int>& p) {
    int x = p.first;
    int y = p.second;
    return std::sqrt(x * x + y * y);
}

C++ cannot destructure parameters directly.

let else: Refutable Binding Made Explicit

Rust

let Some(id) = maybe_id else {
    return;
};

This enforces handling of the failure path immediately.

C++

if (!maybe_id.has_value()) {
    return;
}
int id = *maybe_id;

The structure is manual and unenforced.

Pattern Matching in for Loops

Rust

let points = vec![(1, 2), (3, 4)];

for (x, y) in points {
    println!("{}, {}", x, y);
}

The loop variable is a pattern.

C++

std::vector<std::pair<int, int>> points{{1, 2}, {3, 4}};

for (auto [x, y] : points) {
    std::cout << x << ", " << y << "\n";
}

This is one of the few places where C++ comes close.

Guards and Refinement

Rust

match value {
    Some(x) if x > 10 => println!("large"),
    Some(_) => println!("small"),
    None => println!("none"),
}

Structural matching and conditional refinement coexist.

C++

if (value.has_value()) {
    if (*value > 10) {
        std::cout << "large\n";
    } else {
        std::cout << "small\n";
    }
} else {
    std::cout << "none\n";
}

The logic must be nested manually.

@ Bindings

Rust

match value {
    Some(x @ 1..=10) => println!("small: {}", x),
    Some(x) => println!("large: {}", x),
    None => {}
}

The value is matched and retained in one step.

C++ has no equivalent construct.

Error Handling as Pattern Matching

Rust

fn read() -> Result<i32, String> {
    Ok(5)
}

match read() {
    Ok(x) => println!("value: {}", x),
    Err(e) => println!("error: {}", e),
}

Errors are part of normal control flow.

C++

int read() {
    return 5;
}

try {
    int x = read();
    std::cout << "value: " << x << "\n";
} catch (const std::exception& e) {
    std::cout << "error\n";
}

Exceptions bypass structured control flow rather than integrating with it.

Why This Difference Persists

Rust's patterns are a language-wide abstraction. Once learned, they apply everywhere. C++ solutions remain local, library-driven, and optional.

That is why Rust code often scales better as systems grow. The compiler keeps more invariants intact, and refactoring pressure is immediate rather than deferred.

Conclusion

Rust does not merely provide pattern matching. It treats patterns as a unifying principle across control flow, binding, and error handling. C++ can approximate parts of this experience, but without a shared semantic core, those approximations never compose into a whole.