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 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 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:

$ 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:

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

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

$ 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:

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):

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:

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:

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:

$ 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:

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:

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:

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:

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?

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:

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:

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!

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!