All-In on Pattern Matching
Tags: rust, patternmatching, cli
Published: 2022-01-25 01:29:31

---
title: All-In on Pattern Matching
published: true
date: 2021-01-05 00:00:00 UTC
tags: rust, patternmatching, cli
canonical_url: https://ngriffith.com/writing/all-in-on-pattern-matching/
---

I'd like to share a snippet of code I've been honing over the past few days.

For context, I'm working on a command-line interface for taking photos from a webcam. I'm aiming to make it compatible as a drop-in replacement for the mac-based [imagesnap](https://github.com/rharder/imagesnap) tool, and I'm writing it in Rust in the hopes of enabling cross-platform support. But for now, I'm focusing on a mac implementation and am (with a bit of magic) interfacing with one of macOS's Objective-C libraries (the [AVFoundation](https://developer.apple.com/av-foundation/) framework).

**But none of that matters to this post.** What matters is that I'm building a tool that will accept inputs from humans and translate those to a series of nontrivial behaviors under the hood. For anyone familiar with this kind of shell-command work, this means accepting and parsing a series of arguments that might be supplied in any number of combinations:

```bash
$ imagesnap -q -w 2 -d "Microsoft LifeCam HD-3000" 2020-01-05-test-snapshot-1.jpg
Capturing image from device "Microsoft LifeCam HD-3000"..................2020-01-05-test-snapshot-1.jpg
```

This also means accepting zero arguments:

```bash
$ imagesnap
Capturing image from device "FaceTime HD Camera (Built-in)"..................snapshot.jpg
```

And, of course, it means gracefully handling invalid inputs as well:

```bash
$ imagesnap -xzfv
Error: Unrecognized option: 'x'
```

The fact that this kind of human-machine interface pattern allows for practically limitless input permutations is what makes it so powerful and enduring. But it also presents an age-old challenge: we must design a `main` method that can make sense of these inputs and conditionally adjust its behavior.

And if there's one thing that I can say about conditionals, it's that the more of them you have, the more subtle the bugs are, and the harder it becomes to reason about edge cases.

Let's take a look at what this commonly entails.

### Conditional Argument-Handling In Practice

In Rust, as in most languages, getting the list of user-supplied inputs is super easy:

```rust
let args: Vec<String> = std::env::args().collect();
```

For the sake of conciseness, we can then use the `getopts` library to define our program's options (and ensure that any unrecognized arguments result in an error state):

```rust
let mut opts = getopts::Options::new();
opts.optopt("w", "warmup", "Warm up camera for x seconds [0-10]", "x.x");
opts.optflag("l", "list", "List available capture devices");
opts.optopt("d", "device", "Use specific capture device", "NAME");
opts.optflag("h", "help", "This help message");

let inputs = opts.parse(&args[1..]).unwrap(); // might panic!
```

This allows us to quickly iterate on the argument-handling logic. If you're like me, your brain will naturally want to start introducing a series of procedural steps that walk through the inputs:

```rust
let help = inputs.opt_present("h");
let list_devices = inputs.opt_present("l");

let filename = if !inputs.free.is_empty? {
    inputs.free[0].clone()
} else {
    "snapshot.jpg".to_string()
}

let warmup_secs = if inputs.opt_present("w") {
    inputs.opt_str("w").parse().unwrap()
} else {
    0.5 // default based on rough manual testing
}

let device = if inputs.opt_present("d") {
    inputs.opt_str("d")
} else {
    get_default_device()
}
```

This is a perfectly sensible approach and will look very similar across a lot of C-style languages, regardless of our use of `getopts` (which simply helps us avoid explicitly looping through every input).

You can start to picture where this might go—a bunch of variable assignments via a series of `if`/`else`conditionals, all culminating in a final series of possible behaviors:

```rust
if help {
    print_usage();
} else if list_devices {
    list_devices();
} else {
    snap_image(filename, device, warmup_secs);
}
```

Following so far? Good.

As of, say, Saturday, this is what my code looked like (more or less) and it worked well for prototyping. Of course, I could clean it up by handling return types (instead of blindly calling `unwrap()` and hoping the runtime doesn't panic), but for simplicity's sake, I've excluded all of the error-handling logic from the above examples.

And sure, there are bunch of ways I could've implemented this, but you'll probably agree that most of the above is relatively unobjectionable and, in all likelihood, uninteresting. So let's move onto the more interesting parts.

### Where It Breaks Down

What the above approach doesn't help me with at all are the many corner cases that hide among the conditionals.

The `getopts` library will already return an `Err` variant if any unrecognized arguments are passed, but say, for example, that I'd like to react to incompatible or nonsensical _combinations_ of otherwise _valid_arguments:

```rust
$ imagesnap -l -w 1
Error: Invalid combination of arguments supplied!
```

To make that work as a conditional, I'd need to first check if both were supplied:

```rust
if inputs.opt_present("l") && inputs.opt_present("w") {
    eprintln!("Error: Invalid combination of arguments supplied!");
    return;
}
```

But doing this across all of the inputs would be quite cumbersome! I might start chaining `opt_present` calls together with `&&`/`||` operators, or break out a series of conditionals to check for interesting permutations, but this expansion of conditional behavior is prone to error, and commonly results in `main` methods becoming littered with guard clauses (often in response to user-reported bugs).

Alternatively, I could choose to focus on stricter conditions around each behavior:

```rust
if inputs.opt_present("l") {
    if no_incompatible_options_present {
        list_devices();
    } else {
        eprintln!("Error: Invalid combination of arguments supplied!");
    }
}
```

Yet, once again, solving for the "no incompatible options" condition will be annoying to reason about, and may require an update every time I add a new program argument.

A common resolution to the above conundrum is, simply, to not solve it, and rely on the user to supply valid inputs, lest they encounter undefined behavior. Such tendencies are a result the high cost of getting it right, relative to the low cost of getting it wrong. _It's a minor irk, easily worked around, so why bother?_ I wouldn't fault anyone for making such a trade-off.

But what if we could make it easier? What if there were a low-cost way of _visualizing_ the whole matrix of possible inputs, and expressing that visualization in code?

### Patterns and Matching

Yep, this is where pattern matching comes in.

As a quick example of how Rust's match syntax works, let's look at that filename conditional from before:

```rust
let filename = if !inputs.free.is_empty? {
    inputs.free[0].clone()
} else {
    "snapshot.jpg".to_string()
}
```

This could instead be expressed as a pattern match:

```rust
let filename = match inputs.free.get(0) {
    Some(name) => name.clone(),
    None => "snapshot.jpg".to_string(),
}
```

Fans of functional languages will recognize this pattern. (My first experience with pattern matching was with Haskell.) But in this example, we're matching on just a single value, and even with the fancy `get(0)` call (returning an `Option`type), the overall approach is functionally equivalent to the `if`/`else` statement.

Where pattern matching truly shines is when we can compose multiple values and types. Case in point: that "no incompatible options" problem from before. What if we solved that by matching for patterns of _multiple inputs_?

```rust
match (
    inputs.opt_present("l"),
    inputs.opt_str("w"),
    inputs.opt_str("d"),
) {
    (true, None, None) => list_devices(),
    (true, _, _) => eprintln!("--list/-l is incompatible with other args!"),
    (_, _, _) => continue_execution(),
}
```

Now we're talking! Similar to before, we're relying on `opt_str` to return an `Option` type. In other languages, this might be a null-check, but in Rust, we can pattern-match on type variants and guarantee that we've covered all possible permutations of these three inputs.

**Yes, that's a guarantee.** Rust won't compile our code unless we've accounted for all possible `match` branches. This encourages us to rely on wildcard/catch-all arms like that `continue_execution()` arm above.

### Going All-In

So why not go all the way, and include _all_ of our inputs in a single `match`? Some might call it crazy, but I've found incredible utility in pattern-matching on inputs, early and often.

Sure, it won't prevent logical bugs in the way we parse and handle args, but it will at least ensure that we express_all possible permutations_ of user input. This provide a much better starting place than the previous pile of loosely-related conditionals.

Convinced? Or, at least intrigued? Great. Here's our initial structure, accounting for every user input:

```rust
match (
    inputs.free.get(0), // optional filename
    inputs.opt_present("l"),
    inputs.opt_present("h"),
    inputs.opt_str("d"),
    inputs.opt_str("w"),
) {
    (file, false, false, device, warmup) => {
        snap_image(file, device, warmup) // todo: handle Option types
    }
    (None, true, false, None, None) => list_devices(),
    (None, false, true, None, None) => print_usage(),
    (_, _, _, _, _) => eprintln!("Invalid combination of arguments."),
}
```

By matching on `None` in the `list_devices()` and `print_usage()` cases, we ensure that those arguments can only be used in isolation, otherwise the match will fail and we'll fall through to the "invalid combination" arm! Of course, in the `snap_image()` case we're missing some `Option`-handling, but let's assume that method is capable of accepting `None` types and applying default values.

What we're left with is an incredibly concise representation of the way the logic flows from inputs to actions/outcomes. Each arm summarizes a set of compatible inputs that lead to a specific behavior. We should avoid letting two arms lead to the same behavior, and rely on our type system/variants to abstract some of those details away.

### Adding More Arms

We may be missing a few corner cases, but accounting for them is simply a matter of adding a new pattern arm!

One thing that immediately jumps out at me is that I've forgotten to handle extraneous arguments. If someone supplies two or more non-option arguments instead of one, I'd like to return an error. This can be accomplished by adding an additional match on the 2nd argument, and expecting it to be `None` in all but the catch-all case:

```rust
match (
    inputs.free.get(0), // optional filename
    inputs.free.get(1), // 2nd free arg should always be `None`
    // etc...
) {
    (file, None, ...) => snap_image(...),
    // etc...
    (_, _, ...) => eprintln!("Invalid combination of arguments."),
}
```

Another improvement I'd like to make is to parse and validate the "warmup" input, which must be cast to a float value. Once again, I can slot in a couple extra match arms, and I can even add an `if` condition to the pattern match!

```rust
match (
    // Transpose lets us match on the `Result` of the parse,
    // but preserve the `Option` type. `Ok(None)` is a valid input!
    inputs.opt_str("w").map(|s| s.parse()).transpose(),
    // etc...
) {
    (..., Ok(Some(warmup))) if w < 0.0 || w > 10.0 => {
        eprintln!("Warmup must be between 0 and 10 seconds")
    }
    (..., Ok(warmup)) => snap_image(...),
    (..., Err(_)) => eprintln!("Failed to parse warmup!"),
    // etc...
}
```

Nice.

### Wrapping Up

The joy of pattern matching makes it easy to get a little carried-away, so of course I'll want to be mindful of the complexity of this particular `match`. Things like numerical validations can always be tucked-away into one of the arms. But for a simple program with only a few arguments, being able to express the full range of valid inputs up-front is extremely satisfying, and comes with compiler-enforced guarantees!

And this isn't to say that the c-style `if`/`else` approach is inherently bad! I imagine that plenty of coders will see the above `match` structure and start dry-heaving, and that's okay! Declarative programming takes some getting used to, and not everyone will prefer its aesthetics. But for those who do, hopefully this post has sparked some inspiration!