Learning Cairo - Match Control Flow (12)

Learning Cairo - Match Control Flow (12)

Prerequisite

Install cairo-run.

Match Control Flow

Cairo provides us with a powerful control flow operator called match, which allows us to compare a value against a series of patterns and execute corresponding code based on the matching pattern. Patterns can consist of literals, variables, wildcards, and many other elements.

Let's start with an example using match:

num Coin {
    Penny: (),
    Nickel: (),
    Dime: (),
    Quarter: (),
}

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny(_) => 1,
        Coin::Nickel(_) => 5,
        Coin::Dime(_) => 10,
        Coin::Quarter(_) => 25,
    }
}

Let's examine the value_in_cents function. First, the match keyword is followed by an expression, in this case, the value of coin. This looks similar to the conditional expressions used with if, but there is a significant difference here: with if, the expression must return a boolean value, while here it can be of any type. In this example, the type of coin is an enumeration Coin, defined in the first line.

Next are the match branches. Each branch consists of a pattern and some code. The first branch here has a pattern, which is the value Coin::Penny(_), followed by the => operator to separate the pattern from the code to be executed. In this example, the code simply returns the value 1. Each branch is separated from the next branch by a comma.

When the match expression is executed, it compares the result value with each branch's pattern in sequence. If a pattern matches the value, it executes the code associated with that pattern. If the pattern doesn't match the value, it proceeds to the next branch.

In Cairo, the order of branches must follow the same order as the enumeration. The code associated with each match is an expression, and the result value of the expression in the match branch is the value returned by the entire match expression.

If the code in a match branch is short, we typically don't use curly braces, as in our example, where each branch simply returns a value. If you want to run multiple lines of code within a match branch, you must use curly braces and add a comma after the match branch. For example, the following code prints 'Lucky penny!' every time the Coin::Penny(()) method is called but still returns the last value of the block, which is 1:

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny(_) => {
            ('Lucky penny!').print();
            1
        },
        Coin::Nickel(_) => 5,
        Coin::Dime(_) => 10,
        Coin::Quarter(_) => 25,
    }
}

Patterns with Binding

Another useful feature of match branches is that they can bind to parts of the value that match the pattern. This is how you extract values from enum members.

As an example, let's modify one member of the enum to hold data. Between 1999 and 2008, the United States minted quarters with different designs on one side for each of the 50 states. Other coins did not have this state-specific design, so only these quarters had special value. We can add this information to our enum by changing the Quarter variant to include a UsState value.

#[derive(Drop)]
enum UsState {
    Alabama: (),
    Alaska: (),
}

#[derive(Drop)]
enum Coin {
    Penny: (),
    Nickel: (),
    Dime: (),
    Quarter: (UsState, ),
}

Imagine a friend trying to collect quarters from all 50 states. While categorizing change by coin type, you also want to report the state name associated with each 25-cent coin so your friend can add it to their collection if they don't have it.

In the match expression in this code, we added a variable called state to the pattern to match the value of the Coin::Quarter variant. When Coin::Quarter matches, the state variable is bound to the state value of that quarter. Then we can use state in the code of that branch like this:

fn value_in_cents(coin: Coin) -> felt252 {
    match coin {
        Coin::Penny(_) => 1,
        Coin::Nickel(_) => 5,
        Coin::Dime(_) => 10,
        Coin::Quarter(state) => {
            state.print();
            25
        },
    }
}

To print a variable's value in Cairo, we need to implement a print function for debug::PrintTrait:

impl UsStatePrintImpl of PrintTrait<UsState> {
    fn print(self: UsState) {
        match self {
            UsState::Alabama(_) => ('Alabama').print(),
            UsState::Alaska(_) => ('Alaska').print(),
        }
    }
}

If we call value_in_cents(Coin::quarter(UsState::Alaska(()))), coin will be Coin::quarter(UsState::Alaska()). When we compare this value with each matching arm, none match until we reach Coin::Quarter(state). At this point, the state binding will have the value UsState::Alaska(). Then we can use this binding in the PrintTrait to obtain the inner state value from the Coin enum variable for Quarter.

Matching Option

We can use match to handle Option<T> by importing the option::OptionTrait trait to work with Option.

Suppose we want to write a function that takes an Option<u8> and, if it contains a value, adds 1 to that value. If it doesn't contain a value, the function should return None and not attempt any operation.

use option::OptionTrait;
use debug::PrintTrait;

fn plus_one(x: Option<u8>) -> Option<u8> {
    match x {
        Option::Some(val) => Option::Some(val + 1),
        Option::None(_) => Option::None(()),
    }
}

fn main() {
    let five: Option<u8> = Option::Some(5);
    let six: Option<u8> = plus_one(five);
    six.unwrap().print();
    let none = plus_one(Option::None(()));
    none.unwrap().print();
}

Note that the branch order must match the order defined in the core Cairo library for OptionTrait.

enum Option<T> {
    Some: T,
    None: (),
}

Let's take a closer look at the first execution of plus_one. When we call plus_one(five), the value of the variable x in the plus_one function body is Some(5). We then compare it with each matching branch:

Option::Some(val) => Option::Some(val + 1),

Option::Some(5) matches with Option::Some(val). val is bound to the value contained in Option::Some, so val is set to 5. The code in the matching branch is then executed, so we add 1 to the value of val and create a new Option::Some value with our result, which is 6. Because the first branch matches, the other branches are not evaluated.

Now consider the second call to plus_one in our main function, where x is Option::None(()). We enter the matching and compare it with the first branch:

Option::Some(val) => Option::Some(val + 1),

Option::Some(val) does not match Option::None, so we proceed to the next branch:

Option::None(_) => Option::None(()),

It's a match! There's no value to add, so the program stops and returns the Option::None(()) value on the right side of =>.

Matching with match combined with enums is useful in many scenarios. You will see many such patterns in Cairo code: match an enum, bind the value to a variable, and then execute code based on its value.

Exhaustive Matching

There's another aspect to discuss: these branches must cover all possibilities. Consider this version of the plus_one function, which has a bug and won't compile:

fn plus_one(x: Option<u8>) -> Option<u8> {
    match x {
        Option::Some(val) => Option::Some(val + 1),
    }
}

$ cairo-run main.cairo
    error: Unsupported match. Currently, matches require one arm per variant,
    in the order of variant definition.
    --> main.cairo:34:5
        match x {
        ^*******^
    Error: failed to compile: ./main.cairo

Rust knows that we haven't covered all possibilities and even knows which patterns are missing! Cairo's matching is exhaustive: you must exhaustively cover all possibilities to make the code valid. In this particular Option<T> example, Cairo prevents us from forgetting to handle the None case explicitly, protecting us from assuming we have a value when it's actually empty, thus making the potentially catastrophic error mentioned earlier impossible.

Matching with 0 and _ Placeholders

With enums, we can also perform special operations on some specific values and default to a generic operation for all other values. Currently, only 0 and _ operators are supported.

Imagine implementing a game where you get a random number between 0 and 7. If you get 0, you win; for any other value, you lose. Here's a match implementation for this logic, with the numbers hardcoded instead of being random:

fn did_i_win(nb: felt252) {
    match nb {
        0 => ('You won!').print(),
        _ => ('You lost...').print(),
    }
}

The first branch has a pattern, which is the literal 0. The last branch, covering all other possible values, has the pattern _. Even though we haven't listed all possible values that felt252 could have, this code still compiles because the last pattern matches all values not explicitly listed. This wildcard pattern satisfies the requirement that match must be exhaustive. Note that we must place the wildcard branch last because patterns are evaluated in order. If we place the wildcard branch first, Cairo will warn us if we add branches after it!

Summary

match is a highly useful tool in Cairo, significantly improving code readability and logic, so we can optimize our code as much as possible by using it.

Low latency and free Starknet node awaits!

For a limited time, Reddio is offering unrestricted access to its high-speed StarkNet Node, completely free of charge. This is an unparalleled opportunity to experience the fastest connection with the lowest delay. All you need to do is register an account on Reddio at https://dashboard.reddio.com/ and start exploring the limitless possibilities.

You can discover why Reddio claims the fastest connection by reading more here.