The match Expression
Oxide has an extremely powerful control flow construct called match that allows
you to compare a value against a series of patterns and then execute code based
on which pattern matches. Patterns can be made up of literal values, variable
names, wildcards, and many other things. The power of match comes from the
expressiveness of the patterns and the fact that the compiler confirms that all
possible cases are handled.
Think of a match expression as being like a coin-sorting machine: coins slide
down a track with variously sized holes along it, and each coin falls through
the first hole it encounters that it fits into. In the same way, values go
through each pattern in a match, and at the first pattern the value "fits,"
the value falls into the associated code block to be used during execution.
Speaking of coins, let's use them as an example using match! We can write a
function that takes an unknown US coin and, in a similar way as the counting
machine, determines which coin it is and returns its value in cents:
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn valueInCents(coin: Coin): Int {
match coin {
Coin.Penny -> 1,
Coin.Nickel -> 5,
Coin.Dime -> 10,
Coin.Quarter -> 25,
}
}
Let's break down the match in the valueInCents function. First we list the
match keyword followed by an expression, which in this case is the value coin.
This seems very similar to a conditional expression used with if, but there's
a big difference: with if, the condition needs to evaluate to a Boolean value,
but here it can be any type. The type of coin in this example is the Coin
enum that we defined.
Next are the match arms. An arm has two parts: a pattern and some code. The
first arm here has a pattern that is the value Coin.Penny and then the -> that
separates the pattern and the code to run. The code in this case is just the
value 1. Each arm is separated from the next with a comma.
When the match expression executes, it compares the resultant value against the
pattern of each arm, in order. If a pattern matches the value, the code associated
with that pattern is executed. If that pattern doesn't match the value, execution
continues to the next arm, much as in a coin-sorting machine.
The code associated with each arm is an expression, and the resultant value of
the expression in the matching arm is the value that gets returned for the entire
match expression.
We don't typically use curly brackets if the match arm code is short, as it is in the previous example where each arm just returns a value. If you want to run multiple lines of code in a match arm, you must use curly brackets, and the comma following the arm is then optional:
fn valueInCents(coin: Coin): Int {
match coin {
Coin.Penny -> {
println!("Lucky penny!")
1
},
Coin.Nickel -> 5,
Coin.Dime -> 10,
Coin.Quarter -> 25,
}
}
Patterns That Bind to Values
Another useful feature of match arms is that they can bind to the parts of the values that match the pattern. This is how we can extract values out of enum variants.
As an example, let's change one of our enum variants to hold data inside it.
From 1999 through 2008, the United States minted quarters with different designs
for each of the 50 states on one side. No other coins got state designs, so only
quarters have this extra value. We can add this information to our enum by
changing the Quarter variant to include a UsState value stored inside it:
#[derive(Debug, Clone, Copy)]
enum UsState {
Alabama,
Alaska,
Arizona,
Arkansas,
California,
// ... etc
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
Let's imagine that a friend is trying to collect all 50 state quarters. While we sort our loose change by coin type, we'll also call out the name of the state associated with each quarter so that if it's one our friend doesn't have, they can add it to their collection.
In the match expression for this code, we add a variable called state to the
pattern that matches values of the variant Coin.Quarter. When a Coin.Quarter
matches, the state variable will bind to the value of that quarter's state.
Then we can use state in the code for that arm:
fn valueInCents(coin: Coin): Int {
match coin {
Coin.Penny -> 1,
Coin.Nickel -> 5,
Coin.Dime -> 10,
Coin.Quarter(state) -> {
println!("State quarter from \(state:?)")
25
},
}
}
If we were to call valueInCents(Coin.Quarter(UsState.Alaska)), coin would be
Coin.Quarter(UsState.Alaska). When we compare that value with each of the match
arms, none of them match until we reach Coin.Quarter(state). At that point, the
binding for state will be the value UsState.Alaska. We can then use that
binding in the println! expression, thus getting the inner state value out of
the Coin enum variant for Quarter.
Matching with Nullable Types
In the previous section, we wanted to get the inner T value out of the Some
case when using T?; we can also handle T? using match, as we did with the
Coin enum! Instead of comparing coins, we'll compare the variants of T?,
but the way the match expression works remains the same.
Let's say we want to write a function that takes a Int? and, if there's a
value inside, adds 1 to that value. If there isn't a value inside, the function
should return the null value and not attempt to perform any operations.
This function is very easy to write, thanks to match:
fn plusOne(x: Int?): Int? {
match x {
null -> null,
Some(i) -> Some(i + 1),
}
}
let five: Int? = Some(5)
let six = plusOne(five)
let none = plusOne(null)
Let's examine the first execution of plusOne in more detail. When we call
plusOne(five), the variable x in the body of plusOne will have the value
Some(5). We then compare that against each match arm:
null -> null,
The Some(5) value doesn't match the pattern null, so we continue to the next
arm:
Some(i) -> Some(i + 1),
Does Some(5) match Some(i)? It does! We have the same variant. The i binds
to the value contained in Some, so i takes the value 5. The code in the
match arm is then executed, so we add 1 to the value of i and create a new
Some value with our total 6 inside.
Now let's consider the second call of plusOne, where x is null. We enter
the match and compare to the first arm:
null -> null,
It matches! There's no value to add to, so the program stops and returns the
null value on the right side of ->. Because the first arm matched, no other
arms are compared.
Combining match and enums is useful in many situations. You'll see this pattern
a lot in Oxide code: match against an enum, bind a variable to the data inside,
and then execute code based on it. It's a bit tricky at first, but once you get
used to it, you'll wish you had it in all languages. It's consistently a user
favorite.
Matches Are Exhaustive
There's one other aspect of match we need to discuss: the arms' patterns must
cover all possibilities. Consider this version of our plusOne function, which
has a bug and won't compile:
fn plusOne(x: Int?): Int? {
match x {
Some(i) -> Some(i + 1),
}
}
We didn't handle the null case, so this code will cause a bug. Luckily, it's
a bug the compiler knows how to catch. If we try to compile this code, we'll get
an error indicating that we haven't handled all possible cases.
Matches in Oxide are exhaustive: we must exhaust every last possibility in
order for the code to be valid. Especially in the case of T?, when the
compiler ensures we explicitly handle the null case, it protects us from
assuming we have a value when we might have null, thus making the mistake
discussed earlier impossible.
Catch-all Patterns with else
Using enums, we can also take special actions for a few particular values, but for all other values take one default action. Let's look at an example where we want to implement game logic for a dice roll:
fn handleDiceRoll(diceRoll: Int) {
match diceRoll {
3 -> addFancyHat(),
7 -> removeFancyHat(),
else -> reroll(),
}
}
fn addFancyHat() {}
fn removeFancyHat() {}
fn reroll() {}
For the first two arms, the patterns are the literal values 3 and 7. For the
last arm that covers every other possible value, the pattern is the keyword
else. This is Oxide's catch-all pattern (equivalent to _ in Rust). The code
that runs for the else arm calls the reroll function.
This code compiles, even though we haven't listed all the possible values an Int
can have, because the else pattern will match all values not specifically listed.
The catch-all pattern meets the requirement that match must be exhaustive. Note
that we have to put the catch-all arm last because the patterns are evaluated in
order. If we put the catch-all arm earlier, the other arms would never run.
Catch-all with a Bound Variable
Sometimes you want to use the matched value in your catch-all arm. You can bind
the value to a variable by using a name other than else:
fn handleDiceRoll(diceRoll: Int) {
match diceRoll {
3 -> addFancyHat(),
7 -> removeFancyHat(),
other -> movePlayer(other),
}
}
fn movePlayer(spaces: Int) {
println!("Moving \(spaces) spaces")
}
Here, we're using the variable other to capture all values that don't match
3 or 7, and we use that value in the arm's code.
Ignoring Values with else
When you want a catch-all but don't need the value, use else:
fn handleDiceRoll(diceRoll: Int) {
match diceRoll {
3 -> addFancyHat(),
7 -> removeFancyHat(),
else -> {}, // Do nothing
}
}
Here, we're telling the compiler explicitly that we aren't going to use any other
value, by using else with an empty block.
Matching Multiple Patterns
You can match multiple patterns in a single arm using the | operator:
fn describeLetter(letter: char): String {
match letter {
'a' | 'e' | 'i' | 'o' | 'u' -> "vowel".toString(),
'a'..='z' -> "consonant".toString(),
else -> "not a lowercase letter".toString(),
}
}
Matching with Guards
Sometimes pattern matching alone isn't expressive enough. Match guards allow you to add an additional condition to a pattern:
fn checkNumber(x: Int?) {
match x {
Some(n) if n < 0 -> println!("Negative: \(n)"),
Some(n) if n > 0 -> println!("Positive: \(n)"),
Some(n) -> println!("Zero"),
null -> println!("No value"),
}
}
The if n < 0 part is called a match guard. It's an additional condition on a
match arm that must also be true for that arm to be chosen. Match guards are
useful for expressing more complex ideas than a pattern alone allows.
Destructuring Enums with Named Fields
When matching enums with named fields, you can destructure them using struct-like syntax:
enum Message {
Quit,
Move { x: Int, y: Int },
Write(String),
ChangeColor(Int, Int, Int),
}
fn processMessage(msg: Message) {
match msg {
Message.Quit -> {
println!("Quit received")
},
Message.Move { x, y } -> {
println!("Moving to x=\(x), y=\(y)")
},
Message.Write(text) -> {
println!("Text message: \(text)")
},
Message.ChangeColor(r, g, b) -> {
println!("Changing color to RGB(\(r), \(g), \(b))")
},
}
}
You can also rename the bound variables:
match msg {
Message.Move { x: horizontal, y: vertical } -> {
println!("Moving horizontally: \(horizontal), vertically: \(vertical)")
},
else -> {},
}
Nested Patterns
Patterns can be nested to match complex data structures:
enum Color {
Rgb(Int, Int, Int),
Hsv(Int, Int, Int),
}
enum Message {
Quit,
ChangeColor(Color),
}
fn processMessage(msg: Message) {
match msg {
Message.ChangeColor(Color.Rgb(r, g, b)) -> {
println!("RGB: \(r), \(g), \(b)")
},
Message.ChangeColor(Color.Hsv(h, s, v)) -> {
println!("HSV: \(h), \(s), \(v)")
},
Message.Quit -> println!("Quit"),
}
}
The match expression is one of Oxide's most powerful features. It's used
extensively throughout Oxide code for control flow, error handling, and working
with optional values. As you become more familiar with pattern matching, you'll
find yourself reaching for match whenever you need to handle multiple cases
based on the structure of your data.