A little idea of throwing exception for rust

In my heart, the best idea of error handling is throw a exception rather than return a ADT such as result, but neither checked exception or dynamic checking exception, is the compile-time auto deduction exception. I don't know is there a similar theory about it, but I think it is cool.

how it works?

Let us look a piece of code:

arr.map(|item| do_something(item));

This is a general functional operation of array.

But in real world, error will happen any time, although during iterating, so do_something may not success, maybe result a error.

But, it's sad that the map function only accept a closure which return a (), we can't return a Result!

So, how can we do?

  1. Turn map to for, we can return a Result solved this problem, but it's not functional. In some case, it isn't a iterator, we has no idea.

  2. Use unwrap, expect or panic, and then we use catch_unwind to handle the panic, it's work well, but not suit the rust's philosophy.

And let we jump out of rust, try checked exception and dynamic checking exception?

  1. checked exception: not works, because it also needs function meta declaration.
  2. dynamic checking exception: it works, just like panic, but it's runtime exception handling, we can't know the real type of exception during compile-time, must downcast the type of exception to detect what exception we met, so not suit the rust's philosophy.

Now we explain about the compile-time auto deduction exception, I will wite some pseudo-code.

The defined of do_something:

fn do_something() {
    throw MyException::new("A little error");
}

Look just like dynamic checking exception, but when compile, it will expand to:

enum __AnonymousExceptionBucket_A__ {
    MyException { message: String },
}

fn do_something() -> Result<(), __AnonymousExceptionBucket_A__> {
    return Err(MyException::new("A little error"));
}

So throw is just a syntax sugar of Result.

And when calling the map:

arr.map(|item| do_something(item));

It will expand to:

arr.map(|item| -> Result<(), __AnonymousExceptionBucket_A__> do_something(item));

Not just the function meta declaration of do_something will change, but the caller map will change too.

What about multi Exception type throws in a function? I think dealing Result type cast is so boring in rust, let we see how compile-time auto deduction exception works.

fn do_something() {
    do_something_first();
    throw MyException::new("A little error");
}

fn do_something_first() {
    throw MyAnotherException::new("Another little error");
}

Expand to:

enum __AnonymousExceptionBucket_A__ {
    MyException { message: String },
    MyAnotherException { message: String },
}

fn do_something() -> Result<(), __AnonymousExceptionBucket_A__> {
    do_something_first()?;
    return Err(MyException::new("A little error"));
}

enum __AnonymousExceptionBucket_B__ {
    MyAnotherException { message: String },
}

fn do_something_first() -> Result<(), __AnonymousExceptionBucket_B__> {
    return Err(MyAnotherException::new("Another little error"));
}

// Omit some auto generated trait implementation...

How to handle these exception? it's also easy:

try { // try will return a `Result`, because it's a sugar.
    arr.map(|item| do_something(item));
}.catch { // it's the syntax same as `.await`, behave like `.match`.
    MyException(message, ...) => { ... },
    MyAnotherException(message, ...) => { ... },
    // Not need to exhaust the enum.
}

But if we want to catch a Exception not appeared, we will receive a compile error:

try {
    arr.map(|item| do_something(item));
}.catch {
    MyException { message, ... } => { ... },
    MyAnotherException { message, ...} => { ... },
    NotKnownException { message, } => { ... }, // Not compiled! because is not an element of enum.
}

I don't understand which map function you mean. The Rust stdlib's Iterator::map() method is generic over the type of its functional argument which is allowed to return any type at all, not only (), and in particular, you can absolutely return a Result.

YMMV – perhaps Rust is not the ideal language for you. It has been discussed widely why Rust chose this approach and what benefits it has over traditional exception handling when it comes to playing nicely with the goals of the language and other features thereof.

If you could provide some more information as to what you're missing from Result-based error handling, perhaps we could suggest another, idiomatic way of overcoming said actual or perceived limitation. I'd suggest trying to resolve problems using whatever the language offers first, instead of immediately reaching for an RFC.

3 Likes

The map function does work for any return type. This would normally be written in Rust using Result as something like:

fn do_something(item: Input) -> Result<Output, Error> {
    ...
}

fn do_all_things(arr: Vec<Input>) -> Result<Vec<Output>, Error> {
    arr.into_iter().map(|item| do_something(item)).collect()
}

This might interest you:

1 Like

Something like auto_enums + impl Error should roughly cover this use case. Not sure if this will work directly, as it probably can't handle Result<(), impl Error>, with the impl trait not being the main return type.

I think this is definitely worth exploring. fehler, anyhow and co. rely on boxed trait objects, while this would use impl trait, which would allow for something like those crates, but without allocating.

I suspect the "pain point" here is that you can't trigger divergent flow from inside a map

If you return a Result, then every item of that iterator must also be a Result.

This means for example, if you had a situation where map traversal could return some result state in the containing function without continuing to iterate, the only way to handle that presently is to de-structure the map into a for loop.

I'm sure we can all agree that's not fun, but I don't think general purpose exceptions are the right approach to solve this pain.

1 Like

You can use try_fold/try_for_each/collect to early return from an iterator.

2 Likes

Thanks everybody's reply, maybe my example of map is not suitable, I want to express that the situation about to return a error type the function declaration not covered.

One of the reasons many Rust developers like the Result approach is precisely that a function can't return an error type the function declaration doesn't cover; you can see from the function declaration how it might produce an error.

3 Likes

…furthermore, even when a language has checked / typed exceptions (e.g. Swift), which provide the same or similar guarantees (no throwing of an exception unless a function is marked as throwing, and exceptions must be handled somehow), returning Result is still superior from a usability and API design point of view. Here's why.

Result is a regular type, defined in the standard library. It isn't baked in the language. It can be manipulated, constructed, and returned just like any other type, and in particular it can be substituted for a generic type parameter with no additional work or forethought required.

If, however, checked exceptions are a special case, they add a whole new dimension to the type system. In particular, higher-order functions that take other functions as their arguments now need to decide whether to allow the functional argument to throw an exception. In 99% of the time, this question has no good answer:

  • If the API designer (= the author of such a higher-order function) wants to allow throwing functions, s/he will need to handle (propagate, assert away, …) all the potential exceptions in the implementation of the higher-order function, cluttering its body with irrelevant error handling code, even when there is no reason for it at all, i.e. in cases when it is in fact invoked with a non-throwing functional argument.

  • If, however, the API designer doesn't want to allow exceptions, then it will suddenly be impossible to call said higher-order function in many contexts where it would be necessary. Even worse, this situation can emerge when the API designer merely forgets to annotate their function as re-throwing. This is a major source of annoyance in Swift, and even the standard library suffers from it (although to a lesser extent than 3rd-party libraries).

    This also means that the usual advice / common "wisdom" will eventually be to just mark every higher-order function as re-throwing and every functional argument as throwing. At that point, this completely defeats the purpose of checked exceptions, because if everything is marked as potentially throwing/re-throwing, then the distinction is gone, the compile-time/reading-time guarantees are gone, and the fact that exceptions are checked or typed is rendered completely useless.

10 Likes

Obviously the map function does not expect the closure to fail, and is not coded to handle failure. Forcing a failure down its code creates more hazards because the function is not written to expect failure.

If you just wait to early-out, return Result<...>'s all the way...

something.iter()
    .map(|x| do_something_that_can_fail(x))
    .map(|r| r.map(|x| process_result(x)))
             :
    .try_for_each(|r| r.map(|x| process_result_n(x)))?;

If you just want to ignore the errors (usually a bad thing to do!), then just filter it:

something.iter()
    .map(|x| do_something_that_can_fail(x))
    .filter(|r| r.is_ok())
    .map(|x| process_result(x))
    .map(|x| process_result2(x))
                :
    .collect();  

Usually ignoring an error is a BAD thing and against Rust's style.

1 Like