Syntactic musings on match expressions
— 2025-04-29
Introduction
One of the things that stands out to me is how similar match
and if..else
are semantically, while being diverging a fair bit syntactically. The reasons for that seem mostly accidental, and I have a sneaking suspicion we could make things a little easier by making both constructs look more similar.
In this post I'll be showing some control flow examples based on a queue that can either be online of offline. This is defined by an enum QueueState
which stores the length of the queue as a u32:
enum QueueState {
Online(u32),
Offline(u32),
}
Depending on whether the queue is actively having new data added to it (online),
we will want to reason differently about the queue's limits (u32
). We also want to know
change the behavior based on whether the queue is full or not, and in order to
do that we will want to call a function:
fn is_full(n: u32) -> bool { .. }
Logical AND
Take the humble if
-guard. We can use this in match
arms to chain
conditions, which is useful when we want to do something with a value contained
within a structure. Here the is_full
method returns a bool. But in order to
call it we have first access the number contained in the Online
variant:
match state {
QueueState::Online(count) if !is_full(count) => {
// ^^^^^^^^^^^^^^^^^^
// if-guard
println!("queue is online and not full");
}
_ => println!("queue is in an unknown state"),
}
if
-guards are really just a logical AND. In match statements we're in the
process of adding if let
-chaining to Rust by way of RFC 2497 (stable in Rust 1.88). With that we'll be able to write this same match
condition as follows:
if let QueueState::Online(count) = state && !is_full(count) {
// ^^^^^^^^^^^^^^^^^^
// && chaining
println!("queue is online and not full");
} else {
println!("queue is in an unknown state");
}
This is basically the same feature exposed two different ways. Instead I really
would like to be able to use &&
in both cases, bringing the two closer together:
match state {
QueueState::Online(count) && !is_full(count) => {
// ^^^^^^^^^^^^^^^^^^
// && chaining
println!("queue is online and not full");
}
_ => println!("queue is in an unknown state"),
}
I can guarantee that just about everyone who has had exposure to a language
which C-like notation (which rounds to all programmers) will have little trouble
understanding how this works. if
-guards, in comparison, tend to be harder to
intuit.
Logical OR
RFC 3637
introduces the "guard patterns" feature: an extension to Rust's pattern notation
which that moves if
guards out of match expressions and into all patterns. What it functionally provides us with is a way
to chain boolean AND expressions with logical OR expressions in all patterns. Say we took our previous example, and we wanted to print
a message if is_full
returned false
, regardless of the whether the queue was
online or offline. Using chained if..let
s we would write this as follows:
if let QueueState::Online(count) = state && !is_full(count) {
println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) {
println!("queue is full"); // 1. This statement...
} else if let QueueState::Offline(count) = state && is_full(count) {
println!("queue is full"); // 2. ...is duplicated here.
} else {
println!("queue is in an unknown state");
}
This is a little annoying because ideally we'd want both the second and third condition to be one big condition, separated by a logical OR
. Guard patterns are the feature which will allow us to do exactly that by bringing if
-guards and pattern OR-based pattern chaining to expressions. With that we would rewrite this as follows:
if let QueueState::Online(count) if !is_full(count) = state {
// ^^^^^^^^^^^^^^^^^^
// now using an if-guard, for fun
println!("queue is online and not full");
} else if let ((QueueState::Online(count) if is_full(count) = state) | // logical OR
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Either matches on pattern 1...
(QueueState::Offline(count) if is_full(count) = state)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// ...or on pattern 2.
{
println!("queue is full"); // No more duplication!
} else {
println!("queue is in an unknown state");
}
There's a lot going on here. For one, this makes if
-guards legal in if..let
expressions. And it also allows multiple patterns to be placed used in a single
conditional using OR semantics, as long as they're appropriately parenthesized.
Let's keep going on, and use this with a match
expression:
match state {
QueueState::Online(count) if !is_full(count) => {
println!("queue is online and not full");
}
((QueueState::Online(count) if is_full(count)) |
(QueueState::Offline(count) if is_full(count)) => {
println!("queue is full"); // No more duplication!
}
_ => println!("queue is in an unknown state"),
}
This is a lot better since the flow is clearer. By not having the explicit = state
repeated on every line we can now actually read all the statements
left-to-right. That's all good. We like that. Porting from the if..let
version to the match
version was also really easy. We didn't have to change
the if..let
conditions at all, aside from trimming their ends. That's good!
What's less good however is that we achieved this by making if..else
expressions more involved. The interaction of RFC 2497 if..let
chains and RFC
3637 guard patterns seems particularly like it might catch people off guard.
Consider for example this line:
if let Foo::Bar(x) if cond(x) = bar() && let Bin::Baz = baz() { .. }
The order in which expressions are evaluated on this line is here is
2-3-1-5-4
. That means it's probably not advisable to mix both features. The
reason why these interactions are this way is because both RFCs operate from
fundamentally opposing premises:
- RFC 2497 believes we should enable boolean operators like
&&
to work in more places. - RFC 3637 believes we should enable match-specific operators like
if
-guards work in more places.
In the previous section we already looked at replacing if
-guards with the &&
operator, inspired by if..let
-chaining. It's not a far reach to imagine if..let
-chaining using the ||
operator as well. That would look something like this:
if let QueueState::Online(count) = state && !is_full(count) {
// ^^^^^^^^^^^^^^^^^^
// if-let chain
println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) ||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Either matches on pattern 1...
let QueueState::Offline(count) = state && is_full(count)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// ...or on pattern 2.
{
println!("queue is full"); // No more duplication!
} else {
println!("queue is in an unknown state");
}
It's the same functionality as before, but provided as a natural extension to
&&
-based if..let
-chaining. Now once more, let's try rewriting this as a
match
expression:
match state {
QueueState::Online(count) && !is_full(count) => {
println!("queue is online and not full");
}
QueueState::Online(count) && is_full(count) || // <- logical OR!
QueueState::Offline(count) && is_full(count) => {
println!("queue is full");
}
_ => println!("queue is in an unknown state"),
}
What's neat here is that because logical AND (&&
) takes precedence over
logical OR (||
)
(ref),
we don't need to resort to wrapping our patterns in order to chain them. Oh and
I guess of course it's also nice that just about every programmer regardless of
familiarity with Rust should be able to parse what is going on here.
But even more than that: look at this! This is code that I want to be writing. It's not trying to be clever or inventing anything particular new. It's defining feature is just how ordinary this looks. And that really agrees with my sensibilities for what a general-purpose programming language should feel like.
if let
-evaluation ordering
I don't like reading if-let
statements because they flip the read order from
right-to-left. I double don't like reading if-let
statements when chained
because they need to be read center-left-right. I like the is
-operator because
it allows us to fix that. Let me show you how by taking our last if let..else
example, with all comments stripped:
if let QueueState::Online(count) = state && !is_full(count) {
println!("queue is online and not full");
} else if let QueueState::Online(count) = state && is_full(count) ||
let QueueState::Offline(count) = state && is_full(count)
{
println!("queue is full");
} else {
println!("queue is in an unknown state");
}
However much I like the boolean operators here, I don't like the order in which statements evaluate. The first line in our example above is evaluated as follows:
if let QueueState::Online(cond) = state && !is_full(count) { .. }
// ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^ ^^^^^^^^^^^^^^^
// | | |
// | this is evaluated first |
// | |
// this is evaluated second |
// |
// this is evaluated last
RFC 3573
proposes to add the is
-operator to the language, inspired by C#'s feature of
the same name. This would fix the evaluation order, making the same code instead
read from top-to-bottom, right-to-left:
if state is QueueState::Online(count) && !is_full(count) {
println!("queue is online and not full");
} else if state is QueueState::Online(count) && is_full(count) ||
state is QueueState::Offline(count) && is_full(count)
{
println!("queue is full");
} else {
println!("queue is in an unknown state");
}
To me this is Rust as it should be. The point has always been to make a language
that is as practical as possible to use, without compromising on correctness,
performance, and control. Making evaluation orders easier to follow seems like
it's right in line with that goal. Here is that same line annotated, but using the is
notation:
if state is QueueState::Online(cond) && !is_full(count) { .. }
// ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
// | | |
// this is evaluated first | |
// | |
// this is evaluated second |
// |
// this is evaluated last
It also comes with an added benefit: it makes it so refactoring code from
if..else
form to match
form produces an incredibly small diff. It's mostly
the same code, but with the start of each condition trimmed:
match state {
QueueState::Online(count) && !is_full(count) => {
println!("queue is online and not full");
}
QueueState::Online(count) && is_full(count) ||
QueueState::Offline(count) && is_full(count) => {
println!("queue is full");
}
_ => println!("queue is in an unknown state"),
}
This would make it possible to explain conditional control flow more incrementally. I see the potential for carving out a little didactic staircase into Rust's famously steep learning curve that goes a little something like this:
- Start by introducing
if..else
, which most programmers will already know. - Then introduce
is
as a more powerful version oftypeof
in other languages. - Finally introduce
match
as a more ergonomic version ofif..else
+is
. Likeswitch
in other languages, but exhaustively checking all cases.
We never take any concepts away or have big changes in notation. Each concept
neatly builds on the previous one, and we don't need to wait to introduce if
-let until much later like we do now.
Closing Words
Another thing I wasn't quite sure when to talk about when it comes to the guard
patterns RFC is its interaction with pattern types. Bringing if
-guards into
pattern feels like it changes the pattern notation from a fairly
tractable/limited subset that we can evaluate in reasonable time into a system
for arbitrary pre/post-conditions. Unless of course we disallow those kinds of
patterns in pattern types - which creates diverging pattern notations.
Overall I feel like we have a lot to gain in the language by making match
-expressions
feel more consistent with the rest of the conditional expressions. After writing
the bulk of this post, I remembered that what I'm sharing here is actually not a
unique insight. RFC
3796
cfg_logical_ops
by Jacob Pratt proposes the addition of the boolean &&
,
||
, and !
operators in cfg
attributes. Effectively replacing the existing
domain-specific all()
, any()
, and not()
operators.
// current
#[cfg(all(any(a, b), any(c, d), not(e)))]
struct MyStruct {}
// proposed
#[cfg((a || b) && (c || d) && !e)]
struct MyStruct {}
I like what that RFC proposes. I doubly like it when we consider it as part of a broader effort to reduce the amount of micro-syntaxes spread throughout the language. In turn making Rust an easier language, by virtue of it being smaller and more consistent.