How to re-throw any error with `?`

Hello,

To re-throw errors, ? is clear and powerful.

But , inside a function where a lot of different errors can happen (io, mutex, ...) how to deal with errors when using this magical ? ? Is it possible to return a generic type with automatic conversion ?

This question is certainly recurrent, but I don't find a fixed answer for this question.

I am 90% sure anyhow is what you are looking for.

as already pointed out, anyhow::Error is a type that can be converted from any type that implements std::error::Error.

just me being pedantic, I would like to remind that the term "generic type" has a specific connotation in programming languages. although understandable in this context, your wording of "return a generic type ..." might be better phrased as something like "return a common type", "return a single type", or something similar.


side note

your question reminds me of the compiler synthesized error sets in zig. I don't really know zig very well, but I really like how it tackles error handling.

there are already many discussions and comparisons on the error handling mechanism in zig and rust, and attempts to bring some of zig's features into rust.

Pre-Pre-RFC: Error Set types and Error Return Traces - language design - Rust Internals

error_set - Rust

some zig error handling features that I want for rust include:

  • automatic error set subtyping and coercion

    in other words, error types in zig are more of structural, not strictly nominal.

    for example, suppose type A is an error set const A = error { InvalidToken, Eof }, type B is an error set const B = error { Eof }, then an error value of type B can be casted into type A. no need to write code to convert between error values.

  • synthesize error set of a function's return type

    this means you don't need to define a nominal error type for every function, you just mark the function fallible, while (optionally) omitting the error set, the compiler will infer it based on the funciton body.

    it's still statically typed, and you get a compile time error if you use a wrong error, but you save a lot of boilerplates.

  • automatically generate a trace of error return path

    this is different from a captured stack trace. rust can generate a similar error trace by chaining the std::error::Error::source() method, but the error types at each level must implement it properly, it's more boilerplates, and the memory footprint is much larger. (the zig error trace is not free, but it's practically negligible, and by default it's only enabled for debug builds).

To add on that, if you are writing an library, you probably don't want to expose an anyhow error. See: thiserror - Rust (especially #[from] if you want auto conversion).

Ok. So for , for an executable, it's recommended to use anyhow, which is simple to use.
And for a library --> thiserror

Indeed it's for a library, so I will use thiserror

@nerditation : You are right to point that I misused the term "generic". Thanks for your explanations !
And thanks to @Fancyflame and @ymleung for your reply.

i wonder if it would be possible to use a lot of type system manipulation to implement anonymous enums even though the compiler doesn't support them, if it was that would also help with the issue (and produce 500-line long compiler errors).

I think ergonomic auto error conversion that is zero cost should be in STD, because it is a fundamental feature. So general users can already write non verbose code, without waiting the time they encounter a collection of error libraries

I felt a completely hopeless until I discovered that I can use Result<_, Box<dyn std::error::Error>> in my function header. It even works for functions returning Option. You can use something .ok_or("your func returned None"). Why it's better than anyhow? Because there is no anyhow if your outside of crates.io. Box<dyn std::error::Error>> is equal to Exception in Java.

In Java, there's a JIT that optimizes this at runtime. In Rust, there's no JIT, so Box<dyn> remains a vtable. This is slow because vtables cause cache misses, as the CPU can't predict them accurately

For example

use std::time::Instant;

trait Shape {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

fn sum_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn sum_area_static<T: Shape>(shapes: &[T]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn benchmark_dynamic(n: usize, iterations: u32) -> std::time::Duration {
    let shapes: Vec<Box<dyn Shape>> = (0..n)
        .map(|i| -> Box<dyn Shape> {
            if i % 2 == 0 {
                Box::new(Circle { radius: i as f64 + 1.0 })
            } else {
                Box::new(Rectangle { width: i as f64 + 1.0, height: i as f64 + 2.0 })
            }
        })
        .collect();

    let start = Instant::now();
    let mut total = 0.0f64;
    for _ in 0..iterations {
        total += sum_area_dynamic(&shapes);
    }
    let elapsed = start.elapsed();
    std::hint::black_box(total);
    elapsed
}

fn benchmark_static_circle(n: usize, iterations: u32) -> std::time::Duration {
    let shapes: Vec<Circle> = (0..n)
        .map(|i| Circle { radius: i as f64 + 1.0 })
        .collect();

    let start = Instant::now();
    let mut total = 0.0f64;
    for _ in 0..iterations {
        total += sum_area_static(&shapes);
    }
    let elapsed = start.elapsed();
    std::hint::black_box(total);
    elapsed
}

fn benchmark_static_rectangle(n: usize, iterations: u32) -> std::time::Duration {
    let shapes: Vec<Rectangle> = (0..n)
        .map(|i| Rectangle { width: i as f64 + 1.0, height: i as f64 + 2.0 })
        .collect();

    let start = Instant::now();
    let mut total = 0.0f64;
    for _ in 0..iterations {
        total += sum_area_static(&shapes);
    }
    let elapsed = start.elapsed();
    std::hint::black_box(total);
    elapsed
}

fn print_result(label: &str, elapsed: std::time::Duration, iterations: u32, n: usize) {
    let total_ops = iterations as u64 * n as u64;
    let ns_per_op = elapsed.as_nanos() as f64 / total_ops as f64;
    println!(
        "{:<40} | {:>10.3} ms | {:>8.3} ns/op",
        label,
        elapsed.as_secs_f64() * 1000.0,
        ns_per_op
    );
}

fn main() {
    let n = 10_000;
    let iterations = 1_000;

    println!("Benchmark: vtable (Box<dyn>) vs non-vtable (generics/monomorphized)");
    println!("Elements: {n}, Iterations: {iterations}");
    println!("{}", "-".repeat(70));
    println!("{:<40} | {:>10} | {:>12}", "Method", "Total Time", "ns/op");
    println!("{}", "-".repeat(70));

    let d = benchmark_dynamic(n, iterations);
    print_result("Dynamic dispatch (Box<dyn Shape>)", d, iterations, n);

    let sc = benchmark_static_circle(n, iterations);
    print_result("Static dispatch (Circle only)", sc, iterations, n);

    let sr = benchmark_static_rectangle(n, iterations);
    print_result("Static dispatch (Rectangle only)", sr, iterations, n);

    println!("{}", "-".repeat(70));

    let dyn_ns = d.as_nanos() as f64;
    let static_avg_ns = (sc.as_nanos() + sr.as_nanos()) as f64 / 2.0;
    let overhead = ((dyn_ns - static_avg_ns) / static_avg_ns) * 100.0;

    println!(
        "\nVtable overhead vs static avg: {:.2}%",
        overhead
    );
}

The result :

Benchmark: vtable (Box<dyn>) vs non vtable (generics/monomorphized)
Elements: 10000, Iterations: 1000
----------------------------------------------------------------------
Method                                   | Total Time |        ns/op
----------------------------------------------------------------------
Dynamic dispatch (Box<dyn Shape>)        |     34.441 ms |    3.444 ns/op
Static dispatch (Circle only)            |      9.292 ms |    0.929 ns/op
Static dispatch (Rectangle only)         |      9.205 ms |    0.921 ns/op
----------------------------------------------------------------------

Vtable overhead vs static avg: 272.39%

For Box<dyn> as error type :

use std::fmt;
use std::time::Instant;

#[derive(Debug)]
enum AppError {
    NotFound(String),
    ParseError(String),
    IoError(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(msg) => write!(f, "NotFound: {}", msg),
            AppError::ParseError(msg) => write!(f, "ParseError: {}", msg),
            AppError::IoError(msg) => write!(f, "IoError: {}", msg),
        }
    }
}

impl std::error::Error for AppError {}

#[derive(Debug)]
struct NotFoundError(String);
#[derive(Debug)]
struct ParseError(String);
#[derive(Debug)]
struct IoError(String);

impl fmt::Display for NotFoundError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "NotFound: {}", self.0)
    }
}
impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "ParseError: {}", self.0)
    }
}
impl fmt::Display for IoError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "IoError: {}", self.0)
    }
}

impl std::error::Error for NotFoundError {}
impl std::error::Error for ParseError {}
impl std::error::Error for IoError {}

fn process_dynamic(input: i32) -> Result<i32, Box<dyn std::error::Error>> {
    match input % 3 {
        0 => Err(Box::new(NotFoundError(format!("item {input}")))),
        1 => Err(Box::new(ParseError(format!("value {input}")))),
        _ => Ok(input * 2),
    }
}

fn process_static(input: i32) -> Result<i32, AppError> {
    match input % 3 {
        0 => Err(AppError::NotFound(format!("item {input}"))),
        1 => Err(AppError::ParseError(format!("value {input}"))),
        _ => Ok(input * 2),
    }
}

fn bench_dynamic_mixed(n: usize, iterations: u32) -> std::time::Duration {
    let inputs: Vec<i32> = (0..n as i32).collect();
    let start = Instant::now();
    let mut ok_count = 0usize;
    let mut err_count = 0usize;
    for _ in 0..iterations {
        for &v in &inputs {
            match process_dynamic(v) {
                Ok(_) => ok_count += 1,
                Err(_) => err_count += 1,
            }
        }
    }
    let elapsed = start.elapsed();
    std::hint::black_box((ok_count, err_count));
    elapsed
}

fn bench_static_mixed(n: usize, iterations: u32) -> std::time::Duration {
    let inputs: Vec<i32> = (0..n as i32).collect();
    let start = Instant::now();
    let mut ok_count = 0usize;
    let mut err_count = 0usize;
    for _ in 0..iterations {
        for &v in &inputs {
            match process_static(v) {
                Ok(_) => ok_count += 1,
                Err(_) => err_count += 1,
            }
        }
    }
    let elapsed = start.elapsed();
    std::hint::black_box((ok_count, err_count));
    elapsed
}

fn bench_dynamic_all_err(n: usize, iterations: u32) -> std::time::Duration {
    let inputs: Vec<i32> = (0..n as i32).map(|i| i * 3).collect();
    let start = Instant::now();
    let mut err_count = 0usize;
    for _ in 0..iterations {
        for &v in &inputs {
            if process_dynamic(v).is_err() {
                err_count += 1;
            }
        }
    }
    let elapsed = start.elapsed();
    std::hint::black_box(err_count);
    elapsed
}

fn bench_static_all_err(n: usize, iterations: u32) -> std::time::Duration {
    let inputs: Vec<i32> = (0..n as i32).map(|i| i * 3).collect();
    let start = Instant::now();
    let mut err_count = 0usize;
    for _ in 0..iterations {
        for &v in &inputs {
            if process_static(v).is_err() {
                err_count += 1;
            }
        }
    }
    let elapsed = start.elapsed();
    std::hint::black_box(err_count);
    elapsed
}

fn bench_dynamic_no_err(n: usize, iterations: u32) -> std::time::Duration {
    let inputs: Vec<i32> = (0..n as i32).map(|i| i * 3 + 2).collect();
    let start = Instant::now();
    let mut ok_count = 0usize;
    for _ in 0..iterations {
        for &v in &inputs {
            if let Ok(x) = process_dynamic(v) {
                ok_count += 1;
                std::hint::black_box(x);
            }
        }
    }
    let elapsed = start.elapsed();
    std::hint::black_box(ok_count);
    elapsed
}

fn bench_static_no_err(n: usize, iterations: u32) -> std::time::Duration {
    let inputs: Vec<i32> = (0..n as i32).map(|i| i * 3 + 2).collect();
    let start = Instant::now();
    let mut ok_count = 0usize;
    for _ in 0..iterations {
        for &v in &inputs {
            if let Ok(x) = process_static(v) {
                ok_count += 1;
                std::hint::black_box(x);
            }
        }
    }
    let elapsed = start.elapsed();
    std::hint::black_box(ok_count);
    elapsed
}

fn print_result(label: &str, elapsed: std::time::Duration, iterations: u32, n: usize) {
    let total_ops = iterations as u64 * n as u64;
    let ns_per_op = elapsed.as_nanos() as f64 / total_ops as f64;
    println!(
        "{:<50} | {:>10.3} ms | {:>8.3} ns/op",
        label,
        elapsed.as_secs_f64() * 1000.0,
        ns_per_op
    );
}

fn print_overhead(label: &str, dyn_d: std::time::Duration, stat_d: std::time::Duration) {
    let overhead = ((dyn_d.as_nanos() as f64 - stat_d.as_nanos() as f64)
        / stat_d.as_nanos() as f64)
        * 100.0;
    println!("  {label}: {overhead:+.2}%");
}

fn main() {
    let n = 10_000;
    let iterations = 1_000;

    println!("Benchmark: Error Type โ€” Box<dyn Error> (vtable) vs Enum (non-vtable)");
    println!("Elements : {n}, Iterations: {iterations}");
    println!("{}", "=".repeat(80));

    println!("\n[Skenario 1] Mixed Ok/Err (~33% Ok, ~67% Err)");
    println!("{}", "-".repeat(80));
    let d_mix = bench_dynamic_mixed(n, iterations);
    print_result("Box<dyn Error>  โ€” mixed ok/err", d_mix, iterations, n);
    let s_mix = bench_static_mixed(n, iterations);
    print_result("Enum AppError   โ€” mixed ok/err", s_mix, iterations, n);

    println!("\n[Skenario 2] All Errors (100% Err path โ€” heap alloc setiap iterasi)");
    println!("{}", "-".repeat(80));
    let d_err = bench_dynamic_all_err(n, iterations);
    print_result("Box<dyn Error>  โ€” all errors", d_err, iterations, n);
    let s_err = bench_static_all_err(n, iterations);
    print_result("Enum AppError   โ€” all errors", s_err, iterations, n);

    println!("\n[Skenario 3] All Ok (100% happy path โ€” error type tidak pernah dibuat)");
    println!("{}", "-".repeat(80));
    let d_ok = bench_dynamic_no_err(n, iterations);
    print_result("Box<dyn Error>  โ€” all ok", d_ok, iterations, n);
    let s_ok = bench_static_no_err(n, iterations);
    print_result("Enum AppError   โ€” all ok", s_ok, iterations, n);

    println!("\n{}", "=".repeat(80));
    println!("Vtable overhead Box<dyn Error> vs Enum:");
    print_overhead("Mixed ok/err  ", d_mix, s_mix);
    print_overhead("All errors    ", d_err, s_err);
    print_overhead("All ok        ", d_ok, s_ok);
    println!("{}", "=".repeat(80));
}

The result :

Benchmark: Error Type โ€” Box<dyn Error> (vtable) vs Enum (non vtable)
Elements : 10000, Iterations: 1000
================================================================================

[Skenario 1] Mixed Ok/Err (~33% Ok, ~67% Err)
--------------------------------------------------------------------------------
Box<dyn Error>  โ€” mixed ok/err                     |    562.580 ms |   56.258 ns/op
Enum AppError   โ€” mixed ok/err                     |    363.631 ms |   36.363 ns/op

[Skenario 2] All Errors (100% Err path โ€” heap alloc in each iteration)
--------------------------------------------------------------------------------
Box<dyn Error>  โ€” all errors                       |    782.548 ms |   78.255 ns/op
Enum AppError   โ€” all errors                       |    519.097 ms |   51.910 ns/op

[Skenario 3] All Ok (100% happy path โ€” error type is never created)
--------------------------------------------------------------------------------
Box<dyn Error>  โ€” all ok                           |     35.413 ms |    3.541 ns/op
Enum AppError   โ€” all ok                           |     14.964 ms |    1.496 ns/op

================================================================================
Vtable overhead Box<dyn Error> vs Enum:
  Mixed ok/err  : +54.71%
  All errors    : +50.75%
  All ok        : +136.66%

anyhow is more or less the same thing, with various useful implementations etc.

Anyhow is Box<dyn> plus methods. I was talking about static dispatch (enum) and dynamic dispatch vtable (Box<dyn>)

It isn't my concern since an exception happens quite rarely. But if you heavily use Err variant, then probably your discovery is valuable.

If you read the results, they are much slower even when there are no errors. I don't know why that is :<

Vtable overhead Box vs Enum:
Mixed ok/err : +54.71%
All errors : +50.75%
All ok : +136.66%

I thought they use a kind of a lazy approach and build vtable on demand. If they build it regardless, then yes, it makes such approach inefficient. I didn't know that, thank you to figure out.

How would you build vtable on demand? My understanding is that compiler creates and puts into executable vtable for each type it observes being coerced into dyn.

Also as far as I see vtables have absolutely nothing to do with slowdown, Box has. Here are benchmarks:

Original 1: +8% in debug build, +271% in release - basically same as in this comment.

Original 2:

Release:

Vtable overhead Box<dyn Error> vs Enum:
  Mixed ok/err  : +70.13%
  All errors    : +49.32%
  All ok        : +111.75%

Debug:

Vtable overhead Box<dyn Error> vs Enum:
  Mixed ok/err  : +5.95%
  All errors    : +25.28%
  All ok        : -0.63%

My take: do not use Box<dyn Error>, use Box<AppError>. Results:

Release:

Vtable overhead Box<AppError> vs Enum:
  Mixed ok/err  : +8.00%
  All errors    : +5.01%
  All ok        : +67.94%

Release, less luck with runner:

Vtable overhead Box<AppError> vs Enum:
  Mixed ok/err  : +32.29%
  All errors    : +17.21%
  All ok        : +115.99%

Debug:

Vtable overhead Box<AppError> vs Enum:
  Mixed ok/err  : +40.67%
  All errors    : +4.77%
  All ok        : -0.39%

The "?" is a wonderful thing. I'm not happy with the "re-throw errors" terminology though. We are not talking about throwing exceptions that need catching in some unknown place here, only tweaking with return values.

AFAIK Box<dyn Error> and Box<ConcreteType> where ConcreteType: Sized have exactly two differences:

  1. Trait methods will go through vtable. Does not matter if you do not call them.
  2. Box is two pointers wide instead of one. This may cause some performance degradation, but not in such simple benchmarks.

Executable is also slighly larger with correspoding possible performance degradation: there will be vtable and there might be functions that cannot be thrown out as they are referenced by vtable and that might push something out of cache which would otherwise be in cache, but whether this has any impact at all depends on where in executable extra data is put and where OS then decided to place it (are vtables in the same section as code?).

Indeed, I got curious and did a little test for myself:

if some error happens, then ->

no err 49989494206760
49989494206760 Time elapsed: 99.375014ms
no err 49989494206760
49989494206760 Time elapsed: 22.321754ms

dyn has 5 times performance degradation. If no errors, then ->

no err 49999995000000
49999995000000 Time elapsed: 331.328ยตs
no err 49999995000000
49999995000000 Time elapsed: 102.957ยตs

only 3 times worse.

My explanation is that vtable built at runtime (actually I did it when implemented C++ compiler) and Rust does it statically (perhaps because llvm). So, yes, it's a good finding and I will avoid using dyn in heavy loops.

Yes i know :slight_smile: I just didn't find a better "one-word" term for the concept of ?.

Thanks all for this really interesting discussion !
So if I understand as the newbie-I-am-that-needs-simple-rules : anyhow has some performance issue (and not only in error cases) => the preferred solution is to use thiserror.