The awesomeness of pattern matching in Rust

themkat

Marie K. Ekeberg

Posted on October 13, 2022

The awesomeness of pattern matching in Rust

If you have followed me for a while, you know that I have really started enjoying Rust in the last year. Rust have many great features, and pattern matching is one of them. If you have used other languages like Haskell or Standard ML, you will notice some similarities. Same with the complete basic pattern matching with when in Kotlin (with some minor work). The pattern matching in Rust makes for expressive, readable and clear code. I admit that the Rust way of doing it is my personal favorite. In this article we will take a look at this topic, and maybe you will see why I think it's so great!

Basics of patterns

Refutable vs irrefutable

Patterns in Rust come in two types; refutable and irrefutable. Patterns that match conditionally are called refutable, while patterns that match any possible value are called irrefutable. Which one you can use will depend on the context. For example, a let-statement will need a irrefutable pattern, because what would happen if a variable in a let-statement doesn't get a value?

  // Irrefutable patterns
  let x = 2;
  let (x, y) = (2, 3);


  // WILL NOT COMPILE
  // let does not allow refutable patterns
  let Ok(x) = someString.parse::<i32>()
  // trying to parse a string will return a Result, and can not guarantee that a result and not an Err is returned
Enter fullscreen mode Exit fullscreen mode

if let-statements on the other hand can have refutable patterns, as the body is evaluated conditionally:

  if let Ok(x) = someString.parse::<i32>() {
      // ... do something if someString can be parsed as a 32 bit integer ...
  }

  // if let can have a refutable pattern, so we can also use a value for x:
  if let Ok(64) = someString.parse::<i32>() {
      // ... do something if someString can be parsed as a 32 bit integer ...
  }
Enter fullscreen mode Exit fullscreen mode

We will see more examples of these kinds of statements and patterns below. If you still think the topic of "refutable vs irrefutable" is hard, reread this section after reading the rest of the article. The Rust documentation also has some examples and explanations that can be worth it to check out.

Destructuring

Many patterns are patterns that destructure various types, and they can also be mixed and match together. Let's look at some of them.

Tuples

We already saw example of tuple destructuring in the last section, but let's take another look:

  // myTuple is of type (i32, i32, &str)
  let my_tuple = (1, 2, "hellothere");
  let (x, y, my_str) = my_tuple;
Enter fullscreen mode Exit fullscreen mode

Here we see in the last line that my_tuple is destructured into 3 new variables: x, y, and my_str. This can be done for all sorts of tuples, as long as the destructured types match.

You can also match elements with .. (possibly multiple) or _ (single), which are often used to skip elements:

  // ignore my_str
  let (x, y, _) = my_tuple;

  // ignore everything after x
  let (x, ..) = my_tuple;


  // bigger tuple
  let bigger_tuple = (1, 2, 3, 4, 5);

  // get first and last
  let (first, .., last) = bigger_tuple;

  // ambiguous! NOT ALLOWED
  // How would the compiler even know which element you wanted
  let (.., middle, ..) = bigger_tuple;
Enter fullscreen mode Exit fullscreen mode

(notice that the patterns have to be unambiguous)

You might try the first tuple example above with a tuple struct and get an error message. That is because tuple structs have more in common with struct destructuring, so they require special syntax:

    // Defining a tuple struct that looks like the tuple in the previous example:
    struct MyTuple(i32, i32, String);


    // Destructure it
    let my_tuple = TupleStr(1, 2, "hellothere".to_string());
    let TupleStr(x, y, my_str) = my_tuple;
Enter fullscreen mode Exit fullscreen mode

(notice that we had to change our &str type to String, as the compiler can't infer the size of any &str we might want to use in our tuple struct. Strings are saved on the heap, so that solves that problem).

Tuple structs are technically structs, which brings us to our next type of destructuring...

Structs

Structs are not that much different, and an example might show it clearly:

  // define a simple struct
  struct Point {
      x: f32,
      y: f32,
      z: f32
  }

  // create a variable to use
  let myPoint = Point {
      x: 1.0,
      y: 0.5,
      z: -1.0
  };

  // destructure it!
  let Point { x, y, z} = my_point;

  // Maybe we just want x and y?
  let Point { x, y, .. } = my_point;

  // or maybe just z
  let Point { z, .. } = my_point;
Enter fullscreen mode Exit fullscreen mode

One thing you should notice when destructuring structs is that the name have to match the one found in the struct, and that .. have to come last. .. in this case simply means match the rest and ignore the result.

Enums

The simplest case for an enum is simply to match one with no data:

  // define a simple enum
  enum Color {
      Red,
      Blue,
      Green
  }

  // match if our color is green
  if let Color::Green = my_color {
      // .. do something is color is green ..
  }
Enter fullscreen mode Exit fullscreen mode

This is not that interesting, as Rust enums are way more powerful. If you are not familiar with them, they can contain data! Let us see an example:

  // More advanced enum
  enum HttpRequest {
      Get,
      Post(String)
  }

  // match the post request
  if let HttpRequest::Post(data) = my_request {
      // .. do something with the post request data ...
  }

  // can also ignore data
  if let HttpRequest::Post(_) = my_request {
      // .. do something when post request ...
  }
Enter fullscreen mode Exit fullscreen mode

If an enum has several arguments, you can do most of what you are used to from tuples above. You can use ranges (e.g, 1..=2), or skip some elements with .. by itself (e.g, MyEnum(firstElem, .., lastElem)).

Combined

You can also combine all of the above into your own patterns! Structs inside enums, enums inside tuples etc. The list goes on!

  // Define some nested structure
  enum Color {
      Red,
      Blue,
      Green
  }

  // imagine old OpenGL from the early 2000s where colors of points were interpolated across the shape
  struct Point {
      x: f32,
      y: f32,
      z: f32,
      color: Color
  }

  struct Triangle(Point, Point, Point);


  // A destructuring based upon the data we want
  // gvet only x for the first point when the first points color is blue
  if let Triangle(
      Point {
          x,
          color: Color::Blue, ..
      },
      ..,
  ) = my_triangle {
      // .. do something with the data we wanted for some reason ..
  }
Enter fullscreen mode Exit fullscreen mode

Other patterns

There are other types of patterns as well, and these are mostly used with match which you will see more of below. The first one is the or-matcher:

  // matches 1, 2 or 3
  1 | 2 | 3

  // Matches one of the strings
  "first" | "second"
Enter fullscreen mode Exit fullscreen mode

We saw ranges briefly above:

  // matches 1 to 10 (inclusive)
  1..=10

  // matches 1 to 10 (non-inclusive)
  1..10
Enter fullscreen mode Exit fullscreen mode

(ranges can also be used as indexes for arrays to fetch multiple elements)

The last pattern I want to show is one used for testing and capturing values. Yes, you can have both! This functionality is often used for capturing values somewhere inside a pattern that satisfy a given condition, and can be used deep inside the pattern. Let us see an example with ranges:

  // Integer version of point
  struct MyData {
      x: i32,
      y: i32,
      z: i32,
  }

  // Match data x value when it's between 1 and 20 (inclusive)
  if let MyData {
      x: my_x @ 1..=20, ..
  } = my_data
  {
      // .. do something with the my_x that is 1<=20 ..
  }
Enter fullscreen mode Exit fullscreen mode

(notice that the variable we use is now called my_x)

These can be used with if-let and other places that allow refutable patterns, but is mostly used together with other patterns in match. I have rarely, if ever used any of these with if-let (maybe with the exception of ranges).

That should be the most important topics, but there might be things I've missed. Rust is an evolving language after all, and new features may have been added. There might also be features I don't use as much, and therefore forget about. You can see the Rust documentation on this topic for more.

Caveats

One minor caveat I have found is that Rust is finicky about floating points in patterns. Not about the capturing part, but if you try to use them in ranges and so on. It warns you that it is not supported. We all know how floating points behave, and that they are very sensitive to different scales and that they are rarely (if ever) exact. That probably explains why they are not recommended to use with more advanced Rust patterns.

Where to use pattern matching? (example time!)

We have already seen some basic examples above, but let's dive a bit deeper and see how and where to use pattern matching. We have already seen lots of let and if-let statements, so let's look at match and patterns in function signatures!

match

This is in my view the switch/case statement you are used to in other languages, but on steroids! match lets you write crazy powerful matchers. We have already looked at all the different patterns above, so let us have some quick examples:

  // A more extensive version of httprequest
  enum HttpRequest {
      Get,
      Post(String),
      Put(String),
      Custom,
      Unknown
  }

  // a match expression
  match my_request {
      Get => {
          // .. do something with get ..
      }
      Post(data) | Put(data) => {
          // .. do something with the data ..
      }
      _ => {}
  }
Enter fullscreen mode Exit fullscreen mode

(matches have to be exhaustive, and cover all cases. You see we handle this with the wildcard _ above. This is also an advantage, as the compiler helps you remember all cases!)

match also have another clever thingy called a match guard! It is basically an extra if-sentence inside a case. Let's add it to the second case above:

   match my_request {
       HttpRequest::Get => {
           // .. do something with get ..
       }
       HttpRequest::Post(data) | HttpRequest::Put(data) if !data.is_empty() => {
           // .. do something with the data ..
       }
       _ => {}
   };
Enter fullscreen mode Exit fullscreen mode

Here we have added a check to see if the string data sent in is not empty! Pretty nifty!

functions

You can use patterns in function definitions! That is really cool, right? You do have to remember that the patterns have to be irrefutable like for let. Don't be sad yet! It still means you can use a lot of destructuring operations we have seen above! Let's look at some examples:

  // test data
  struct Vector2d {
      x: f32,
      y: f32
  }

  // destructured Vector2d
  fn length(Vector2d { x, y }: Vector2d) -> f32 {
      (x.powi(2) + y.powi(2)).sqrt()
  }

  fn x_val(Vector2d { x, .. }: Vector2d) -> f32 {
      x
  }
Enter fullscreen mode Exit fullscreen mode

They can be deep as well if you want to, but remember to not f**k up your code's readability.

Feel free to share any clever pattern matchings you have done in your function signatures in the comments! And yes, I know you have them in JavaScript as well.


Now you have hopefully seen a little bit of what patterns in Rust can do. If you are new to Rust, maybe it has inspired you to learn the language? :)

💖 💪 🙅 🚩
themkat
Marie K. Ekeberg

Posted on October 13, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related