GitHub

purplesyringa

You might want to use panics for error handling

Rust’s approach to error handling is neat, but it comes at a cost. Fallible functions return this type:

// A sum type. Defined in the standard library.
enum Result<T, E> {
    Ok(T),
    Err(E),
}

So the Result type is almost always larger than the actual returned value:

                                     Discriminant
                                          vv
                                     +-----------+--------------------------+
                       Ok variant:   | 0x00...00 |       actual data        |
                                     +-----------+--------------------------+

                                     +-----------+--------------------------+
                       Err variant:  | 0x00...01 |       actual error       |
                                     +-----------+--------------------------+

Oftentimes it doesn’t fit in CPU registers, so it has to be spilled to stack.

Callers of fallible functions have to check whether the returned value is Ok or Err:

// What the programmer writes:
f()?
// What the compiler sees:
match f() {
    Ok(value) => value, // Handle the Ok output
    Err(err) => return Err(err), // Forward the error
}

That’s a comparison, a branch, and a lot of error handling code intertwined with the hot path that just shouldn’t be here. And I don’t mean that lightly: large code size inhibits inlining, the most important optimization of all.

AlternativesChecked exceptions – the closest thing there is to Results – have different priorities. They simplify the success path at the expense of the failure path, so it’s easy to forget about the occasional error. This is an explicit anti-goal of Rust.

Rust has panics that use the same mechanism, but guides against using them for fallible functions, because they are almost unusable for that purpose:

//                     vvv  Does not specify the error type.
fn produces(n: i32) -> i32 {
    if n > 0 {
        n
    } else {
        panic!("oopsie")
    }
}
// Compare with Result:       vvvvvvvvvvvvvvvvvvvvvvvvv
fn produces_result(n: i32) -> Result<i32, &'static str> {
    if n > 0 {
        Ok(n)
    } else {
        Err("oopsie")
    }
}

fn forwards(n: i32) -> i32 {
    //                 v  Implicitly forwards the error.
    let a = produces(n);
    let b = produces(n + 1);
    a + b
}
// Compare with Result:
fn forwards_result(n: i32) -> Result<i32, &'static str> {
    //                        v  Requires a simple but noticeable sigil.
    let a = produces_result(n)?;
    let b = produces_result(n + 1)?;
    Ok(a + b)
}

fn catches(n: i32) -> i32 {
    //   vvvvvvvvvvvvvvvvvvv  What?
    std::panic::catch_unwind(|| forwards(n)).unwrap_or(0)
}
// Compare with Result:
fn catches_result(n: i32) -> i32 {
    forwards_result(n).unwrap_or(0)
}

Forbidden fruitHowever, panics don’t suffer from inefficiency! Throwing an exception unwinds the stack automatically, without any cooperation from the functions except the one that throws the exception and the one that catches it.

Wouldn’t it be neat if a mechanism with the performance of panic! and the ergonomics of Result existed?

#[iex]I’m quite familiar with the Rust macro ecosystem, so I devised a way to fix that with a crate. Here’s how it works, roughly:

//        vvv  Import a macro from the iex crate.
use iex::{iex, Outcome};

#[iex]
//                     vvvvvvvvvvvvvvvvvvvvvvvvv  The signature includes the error...
fn produces(n: i32) -> Result<i32, &'static str> {
    if n > 0 {
        Ok(n)
    } else {
        Err("oopsie")
    }
}
// ...but this code is actually compiled to:
// fn produces(n: i32) -> i32 {
//     if n > 0 {
//         n
//     } else {
//         // vvvvvvvv  ✨ Magic ✨. Don't worry about it. Actually throws a panic.
//         throw_error("oopsie")
//     }
// }

#[iex]
fn forwards(n: i32) -> Result<i32, &'static str> {
    //                 v  The code is rewritten to rely on unwinding instead of matching.
    let a = produces(n)?;
    let b = produces(n + 1)?;
    Ok(a + b)
}

fn catches(n: i32) -> i32 {
    //         vvvvvvvvvvvvvv  Switch back to Result.
    forwards(n).into_result().unwrap_or(0)
}

This was just a joke experiment at first. It should work quite efficiently. Microbenchmarks are bound to show that.

But the design allows Result-based code to work with #[iex] with minimal changes. So I can slap #[iex] on a real project and benchmark it on realistic data.

BenchmarksOne simple commonly used project is serde. After fixing some glaring bugs, I got these benchmark results on JSON deserialization tests:

Speed (MB/s, higher is better)canadacitm_catalogtwitter
DOMstructDOMstructDOMstruct
Result282.4404.2363.8907.8301.2612.4
#[iex] Result282.4565.0439.41025.4317.6657.8
Performance increase0%+40%+21%+13%+5%+7%

This might not sound like a lot, but that’s a great performance increase just from error handling. And this is a universal fix to a global problem.

That includes youTo be clear, this benchmark only measures the success path. In realistic programs, the error path may be reached more often than the success path in some cases, so this is not a generic optimization.

However, it is applicable in almost every project to some degree: for example, querying a database is almost always successful. Optimizing such paths is trivial with #[iex]:

Afterword#[iex] is a very young project. It might not be the best solution for production code, and it would certainly be great if rustc supported something like a #[cold_err] attribute to propagate errors by unwinding without external crates.

But I think it’s a move in the right direction.

The crate documentation includes instructions on how to use #[iex] in your project. If you find this library useful, please tell me on the issue tracker.