Demystifying Rust's ? Operator

3 min read Original article ↗

When talking to folks new to the Rust language, a common misconception I encounter is that ? is some kind of special syntax that only works for Result. While it is true that the ? operator is a special bit of syntax, it is not limited to Result, and a lesser known fact is that it works for Option as well:

fn maybe_double(v: Option<u32>) -> Option<u32> {
    Some(v? * 2)
}

In fact, ? is a generic short-circuit operator that can be used for arbitrary types, as long as they implement the Try trait. To demonstrate, let us implement a new type that does so. Imagine we want to have a type that is both optional and fallible, some kind of OptionResult:Of course you wouldn’t go through the hassle of doing this, you’d just use a Result<Option<T>, E>.

enum OptionResult<T, E> {
    Some(T),
    None,
    Err(E),
}

This type can be some value of type T, nothing, or an error of type E.

To be able to use the Try trait, we need to use the nightly compiler at the moment, as it is not yet stabilized. We then also need to activate the feature for the trait, and import a few dependencies:

#![feature(try_trait_v2)]

use std::ops::{ControlFlow, FromResidual, Try};

Try requires implementing types to also implement FromResidual. A residual is the type returned on the short-circuit path, the error for Result, or nothing for Option. The implementation for FromResidual is quite simple, all we have to do is wrap the value:

impl<T, E> FromResidual for OptionResult<T, E> {
    fn from_residual(residual: <Self as Try>::Residual) -> Self {
        Self::Err(residual)
    }
}

Next we already get to implementing Try, which is the last piece of the puzzle. Here we have a decision to make. Try has a method branch, which returns a ControlFlow. ControlFlow is an enum that decides whether we should continue execution with an unwrapped output, or short-circuit and return a residual. For the sake of argument, we will implement OptionResult such that its Some and None arms continue execution and unwrap into a regular Option, and Err will short-circuit just like Result does. In practice this will mean:

fn some_function() -> OptionResult<u32, &'static str> {
        let output: Option<u32> = OptionResult::Some(42)?;
    
        let output: Option<u32> = OptionResult::None?;
    
        let output: Option<u32> = OptionResult::Err("oh no")?;
}

Here is the corresponding implementation for Try:

impl<T, E> Try for OptionResult<T, E> {
    type Output = Option<T>;
    type Residual = E;

    fn from_output(output: Self::Output) -> Self {
        match output {
            Some(t) => Self::Some(t),
            None => Self::None,
        }
    }

    fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
        match self {
            Self::Some(t) => ControlFlow::Continue(Some(t)),
            Self::None => ControlFlow::Continue(None),
            Self::Err(e) => ControlFlow::Break(e),
        }
    }
}

We can see the definition of two associated types, Output is what we unwrap to on the continuation path, and Residual is the aforementioned short-circuit type. We also have to implement from_output, which similarly to from_residual is simply wrapping the output in the correct variant of our type. Lastly we have the branch method, maps values of our type to the ControlFlow enum to decide whether to continue execution or short circuit.

And that is already it. Like so many things in Rust, what seems like magic at first is not actually all that complicated once you look into how it actually works. I should note that there are additional nuances to implementing this trait, which are outlined in the documentation. Specifically, it is recommended to use a newtype as the residual to prevent accidental cross-conversion between different kinds of residuals. And of course this being an unstable API, it is still subject to change.