[Closed, with a new one] Checked exception simulation in Rust( 2nd version )


#12

Why is there a requirement for “handwritten boilerplate code”? Structured errors have a purpose, and that’s not something the compiler could/should figure out. And for getting rid of eventual boilerplate code, we can use macros; that’s one of their primary use cases. They work wonders. (There’s even a derive proc-macro in the failure crate that makes it possible to write custom structured errors with what is perhaps literally the minimal input information and typing possible.)

Accordingly, I don’t see why we have to simulate the whole “traditional” exception handling mechanism for making matters more convenient. There are several other feasible approaches, including:

  • ad-hoc sum types
  • adding generic error types (possibly in std, but before that, in a crate) that could be used as a fallback if one doesn’t want to write a specific custom error type (should be consumed responsibly; probably only outside libraries)

Introducing two (!) new operators would feel an overkill even if they actually added significant value; but, as others have already pointed out, they are not really necessary given that ? cooperates nicely with From. (Even if they technically don’t affect the core language, in case they become idiomatic, they would still need to be learnt, and if they don’t, they would lead to fragmentation. Neither option sounds desirable.)


#13

I am noticing some progress both in API and document, updated in the main thread, as the 2nd version.

Sorry for late responses:

In fact it works well with ?, just needs some annotation like ~/~~, due to lacking of language support( the chicken or the egg problem? )

It is a natural refining method of existing "wrapping errors" style in error-handling. Nothing to do with panic.

I believe that Ok-Wrapping could be implemented in 200~300 lines of code, but wonder if it is really an important feature.

Thanks for your sugguestion, now CeX uses ~/~~ instead of ??/???.:sunglasses:

I am not aware of the issues you mentioned about Option or async, would you plz explain them in details? Currently I don’t think CeX has limitations in these domains.

In fact, CeX looks like checked exception, but is in spirit of Rust’s error-handling. The proposal is not questioning on Result/?, but on the fat enum Error and the type alias pub type Result<T> = std::result::Result<T, Error>.


#14

Forgot to mention: the features described in the 2nd version have been implemented as a library, CeX


#15

Thanks for your response. I still think “exception handling” is itself not in the spirit of Rust at all. Error handling with Result is nice because there is nothing “exceptional” to it from the point of view of the language. It’s just a type implementing a trait for an operator, and its usage has got solid conventions around it. Consequently, it doesn’t need special handling, and that’s exactly where its beauty comes from.

For example, no “generics” (for the lack of a better word) around “throws/rethrows” are necessary when accepting functions as arguments in higher-order functions. That is a massive pain point in languages with checked exceptions (Swift comes to mind), and it either discourages people from writing higher-order functions at all (which is a sad regression from modern, functional programming practice), or the less experienced, forgetting to add “rethrows” annotations, write higher-order functions that are impossible to use with arguments throwing exceptions.

I don’t get what’s wrong with a type alias for Result; personally, I find it perfectly convenient to write and just as easy to read.

The readme of CeX reads:

The error types are not accurate

Again, since ? calls From::from(), if you don’t like an enum of all possible errors, this is almost trivially resolved by always returning the exactly accurate error type and implementing From (or just propagating the same type up the call chain). Even so, personally, I do like an enum of all possible errors. For one, it means that functions continue to be allowed to return any error, or, more precisely, to extend the set of errors they return, without a change in the signature. This saves users precious backward compatibility budget (and some complexity for the author of the code, although that’s not nearly as important).

Furthermore, errors from the same unit of code (say, from the same crate) tend to be somehow related, after all, and consumers can think of them as a group. In fact, I’ve seen and written lots of code that goes like this:

  1. Call high-level API of crate Foo
  2. High-level function in Foo’s API calls into lower-level functions, accumulates any errors through a single error type
  3. The caller switches on the result’s error type once, to find out what kind of error, if any, happened.

Although read_u32() does not do calculating at all, we need to deal with the Error::Calc branch and write unreachable!() code.

No, we don’t need to write an unreachable!(). In fact, I would consider that poor style. If there is an error that you don’t want to handle yourself, you should propagate it rather than optimistically asserting that it can’t happen. This also helps the “extend the set of errors to return after the fact” approach work nicely.


However, I still do appreciate the effort you put into the implementation of CeX. It does seem well-fleshed out based on your ideas. I think those who prefer exception-style error handling should definitely check it out. I just don’t believe it should be the standard, idiomatic way of error handling in Rust.


#16

In fact Error::Calc is not an error that I don’t want to handle, but an error that is impossible for fn read_u32( /**/ ) -> Result<u32> { /**/ } to return. Unfortunatelly this fact is unknown to the compiler due to the type alias Result<u32>. A propagated impossible error is still an impossible error. Even worse, if read_u32() was a public API, now client code dealing with its Err must dealt with Error::Calc, which is unnecessary, or propagates it, which does not solve the problem.

You may argue that we should change read_u32()'s signature, not to use Result<u32> to address this, as you had suggested:

"if you don’t like an enum of all possible errors, this is almost trivially resolved by always returning the exactly accurate error type

This is an either-or game. You can not get both advantages of them.

  • Pick up Result<T> and you get briefness, but losing accurateness, which confuses the compiler, and the programmer on debugging.

  • Define the exact accurate error type and its impls or propagating.

CeX is a balance between the two. It is more accurate/verbose than Result<T> but as accurate as/more brief than deliberate definition of the accurate error type.


In a certain crate, some errors are related, but some are not. Those related errors are usually concerned with the domain and may be grouped together. CeX does not stop users from doing so. It just provides a mechanism to gather unrelated errors.

fn foo<T>(/**/) -> T throws DomainErr(crate::Error),  IO(std::io::Error)
{/**/}

Thanks for all your positive and negative responses :grinning:.

The main pain point that CeX aims to address is throws in function signatures. Personally I regard it as a great tool on debugging. And I have noticed that a blog, Rust in 2022 posted by @nrc mentioned that:

I hope that you all did not get the impression that “@oooutlk wants to push CeX into Rust language cuz he thought he wrote a shining library” :sweat_smile:. I just got feelings that throws is valuable but largely ignored by Rust community, and it may be proved to be the standard, idiomatic way of error handling in the future.

At least it is worth a section in Rust by example:


#17

Sorry I do not get your point well. Would you please explain it in details, by demonstration code? Suppose we use CeX and want to do something related to higher-order functions.


#18

I do realize that in that case it’s impossible for that particular function to return that type of error. I argue, however, that this problem should be resolved by propagating that impossible error case anyway, instead of panicking on it, for reasons of robustness. It was more of a software engineering comment of mine, rather than a theoretical one.

I’m sorry but I disagree here. You can get most of the exactness with the From impls, which are, again, easily generated by a macro. I’m following this approach in some projects of mine, and error handling is the least painful aspect usually. It’s also an oft-needed pattern when I want to deal with (capture, wrap, and propagate) errors from external crates, so I would have to do it anyway.

Not at all – your motivation and your work is genuine. It’s just that I fundamentally disagree with the idea that this is the way to go, and I think the status quo of error handling in Rust is just fine (certainly a local, maybe a global optimum over the domain of all the languages I’ve encountered so far).

That could be the case; however, I largely see that Rust’s Result-based error handling turned out to be much more convenient, elegant, simpler, and just as useful in practice as exception handling in older languages. I think going back to exception handling would be considered a regression by many Rustaceans (in fact, one of the reasons I started using it was the exception-less error handling). I don’t, by any means, want to make it impossible to use a feature like this for those who do like it, though. It’s just that I wouldn’t like the language to change “under my feet”, so to speak, and downright reverse one of its current core idioms.


#19

Sure thing! The specific example in Swift that I was referring to looks like this (generics stripped and substituted by concrete types for simplicity):

func map(array: [Int], f: (Int) throws -> Int) rethrows -> [Int] { 
    var result: [Int] = [] 
    for item in array { 
        result.append(try f(item)) 
    } 
    return result 
} 

Here, the f functional parameter of the map higher-order function needs to have a throws annotation and map needs to be declared as rethrows in order to indicate that it will throw if and only if f throws.

This is a new generic dimension (basically a one-bit decision) in the type system, and it also makes it necessary to introduce a new special-case relation between types (namely, the throwing nature of the higher-order function and that of its functional argument(s)).

And now, in order to write good-quality generic code, this annotation should be added to basically any higher-order function, otherwise it will be impossible to call them with functions, that themselves throw, as arguments. At this point, this becomes noise and boilerplate that needs to be carried around and remembered without real benefits.


#20

This seems to be a non-issue for CeX.

#[derive( Debug, PartialEq, Eq )]
pub struct IsOdd(i32);

cex! {
    fn half( i: i32 ) -> i32
        throws #[derive( PartialEq,Eq )]
               Arg(IsOdd)
    {
        if i%2 == 0 { Ok(i/2) } else { throw!( IsOdd(i) ); }
    }
}

let data = vec![ 0, 2, 3 ];
assert_eq!(
    data.into_iter()
        .map( half )
        .try_fold( 0, |acc,i| i.map( |i| acc+i ))
        .map_err( |cex| cex.error ),
    Err( half::Err::Arg( IsOdd( 3 )))
);

Note that half() can work with map() well, with the help of try_fold() to do early exit. The signature of map() does not need change.

(A bit off topic: you could ignore the throws syntax changes, I just update some code in my local repo to support attributes in throws)


#21

CeX could be regarded as abbreviations for “Combining Error eXtension” or “Concrete Error eXchange”( for cex 0.1 ), other than “Checked EXception”. It does nothing to do with stack unwinding, and you do not need to worry about “exception safety” in C++.

However, it introduces a different way of code organisation. I agree that it needs more software engineering practices in Rust to prove itself. I wish someone would pick up CeX to write or rewrite some projects and give responses, either positive or negative.


#22

Hello

I’ve seen „fake exceptions“ (see why I call it that below) to pop up repeatedly, often ending up in contentious discussions. However, I think the idea comes from reasoning like this:

  1. I find many situations in which Rust error handling is uncomfortable (I agree with that)
  2. I find error handling in language X that has exceptions much easier (I agree with that)
  3. Therefore, the solution is to add exceptions to Rust.
  4. Full exceptions are against the Rust nature and won’t make it, so let’s add syntax of exceptions (throw keyword, etc), which will solve the problem (I don’t agree with that)

I do find error handling in Rust could be improved, in several places, but none of that places gets solved by adding only the syntax (and semantics won’t just make it, because that has too many downsides):

  • Defining new error types or combining error types. Yes, you do solve that with CeX (nice), but it seems to be mostly a side effect of the syntax. Furthermore, I’d really like to see a general solution for that problem. This can be partly done by library support (eg. something like failure to easily create custom error types, but with the Error type), better support for dynamic typing (more comfortable downcasting) and maybe some kind of anonymous sum types (so I could write something like Result<T, impl Error (std::io::Error | MyCustomError)> ‒ but that would also allow me to write impl Display (String | &str) somewhere else and solve other problems too.
  • Inability to traverse several levels of call stack at once, eg returning from within vec.iter().map({…}). I don’t have a solution for that, but this is clearly not addressed by just adding a throws keyword.

I find all the ideas about adding exception-like terminology both misleading (if it isn’t exceptions, use different syntax so people know they are not exceptions) and alien to Rust. Adding such a big change to the language (and having to support both syntaxes) seems like pretty big change without that much of a benefit.

So while CeX seems like a very cool PoC and showcase what can be done, I would prefer to first list all the friction points and then try to find solutions that a) feel „Rusty“, b) are kind of minimal in the language-feeling impact, c) are general beyond just error handling (eg. ? also works on Option and will work on futures and you can add your own too). CeX seems valuable in showcasing possibilities, but also showing me that the exceptions without exception semantics is something I would really prefer not to have in the language ‒ so good work!


#24

Thanks for your reply which is extensive and helpful for me to go further. @vorner


Naming issue

I have no strong opinion about the syntax or naming. That’s why CeX supports both throws syntax and style of writing vanilla Rust as fallback. In perspective of EnumX, “throw” is into_enum() and “rethrow” is exchange_into(). But the latter names tend to be not related to error-handling. If someone got more proper names than throw, I will be glad to adopt them.

However, the terminology “exception” is not introduced in recent discussion. In RFC 0243-trait-based-exception-handling.md, “exception” appears 79 times, “throw” 22 times. This RFC regards “Rust exception” as "a lightweight(in syntax) but explict propagation of early return with Err":

And I considered CeX to be an immediate successor to RFC 0243:


EnumX related issue

I believe this topic is not related to error handling but a goal what EnumX aims to achieve. Some existing crates are ready on crates.io. And I have posted a Pre-RFC, however, “impl enum” not involved yet.


Early exit issue

As far as I know, Rust’s iterators are lazy, so it is not required to do early exit from several levels of call stack until consuming. As I mentioned in previous post, try_fold() is up to this job( correct me if I am wrong ).


Generalization issue

Both @kornel and you mentioned this. But I am still not very clear about the ? issue. CeX does not develop its own ? mechanism: ~?/~~? are not new operators but are a combination of ~/~~ and ?. However I am planing to add a dev branch in the github repo to do experiments and verifications of the idea. Would you plz show me some sample code which ought to be supported but not yet by CeX?


#25

That might be true, but while a big part of it got implemented, the current state AFAIK also deviated from that RFC a bit and the naming wasn’t widely adopted. For example, the Rust Book mentions „throw“ three times, two of which are „Throws a compile time error“. On the other hand, „error“ and „failure“ seem to be much more common there, so I think it makes sense to stick with something like that.

Exception type upcasting

Wouldn’t that happen to be the first and only true „type upcasting“ in Rust? (I don’t know if type → dyn Trait is considered upcasting, might be). But again, bringing terminology and somewhat emulating object oriented languages that have inheritance feels non-Rusty.

I believe this topic is not related to error handling but a goal what EnumX aims to achieve. Some existing crates are ready on crates.io. And I have posted a Pre-RFC, however, “impl enum” not involved yet.

That’s actually what I’m trying to say. While impl Enum (or whatever else, subject for bikeshedding) would help in more places than just error handling, it would also improve the comfort of creating new error types. Such solutions seem to be strictly better, because they generalize.

As far as I know, Rust’s iterators are lazy, so it is not required to do early exit from several levels of call stack until consuming. As I mentioned in previous post, try_fold() is up to this job( correct me if I am wrong ).

Iterators are just an example. To return from my own function from a closure I pass into some other higher-lever function would make my code simpler in many occasions. I see try_fold as kind of a patch for the problem, its existence actually showing that there’s the problem. It is up to the job in the same sense as return Err(e) is up to the job of error handling ‒ it gets it done, but one desires for something less cumbersome.

Both @kornel and you mentioned this. But I am still not very clear about the ? issue.

Again, ? is just one example, but what I was trying to say is, even the ? that was motivated for error handling is generalized. But Result is just an enum, Box is just a type (AFAIK there was a time when it was a sigil or keyword), etc. The throws thing that seems to create the error type behind the scenes look a lot like it is very much just to build a Result and nothing else. It puts error handling into a special position and pushes other „control“ types and such into background, which feels like some kind of violation of how Rust feels now ‒ instead of special casing some (yet very common) thing by giving it keywords and baking it into the language, a lot of things are handled by giving the library good enough tools to build them comfortably and more besides.

I agree there are ways to improve error handling in Rust, I just don’t see that giving a new syntax to things we already have is an improvement. In other words, the sum enums idea looks like good direction to me ‒ it allows to build error handling and more. I’d also like to see how far things like failure 1.0 gets us before proposals for extending the language (as opposed to eg. adding types to the std) start ‒ I see changes to the syntax as the last resort thing, if everything else fails.

Is there any other problem than just being able to create the sum error type comfortably CeX tries to solve? I might have invested too little time into studying what it does, but it feels like just giving you that.

(I know the CeX itself just uses macros, but if I understand it correctly, this is more like to experiment with things ‒ the syntax as a whole feels unsatisfactory somewhat)


#26

I furthermore wonder if nicer syntax could be something like this (should be possible with proc macros too) ‒ instead of wrapping:

#[sum_errors]
fn stuff() -> Result<i32, io::Error | num::IntParseError | *OtherSumError> {
  let file = File::open("xyz")?;
  ...
}

I haven’t experimented, but I believe:

  • This looks nicer than with the wrapping in cex! { } (the wrapping disrupts the flow of code, making it more of experimental-looking, this feels more like „production“ syntax).
  • The syntax is closer to today’s Rust, less surprising than throws etc.
  • This would still put some weird type into documentation (the one created by the macro), but maybe the type could be something like IoError_or_IntParseError_or_Other_Sum_Error.
  • It should be possible to implement all From<IoError> or From<Other_Sum_Error> traits for the resulting type (notice the annotation in the signature, that * would „unpack“ the variants), so ordinary ? works.
  • If I wanted to do something like or -> Option<Result<i32, io::Error | num::IntParseError>> (maybe because I want the function inside filter_map, want to get rid of some things and pass errors on into .collect<Result<Vec<_>, _>>()) or -> Option<Either<io::Error | num::IntParseError, OtherSumError>>, it would still be possible to support.

Just throwing it in as an idea ‒ I know preferred syntax is very much subjective thing. But this feels much less controversial syntax to me.


#27

Naming issue

I agree that the using of “type-upcasting” might be an inappropriate metaphor in some context. Just to clarify: CeX is not emulating “inheritance” but “sum types”, which is not commonly adopted( if ever ) in traditional OO languages.


EnumX related issue

With impl Enum being implemented in EnumX project, it will of course benefit CeX too. However they are orthogonal features.

Rust’s Result and ? treats Ok and Err as different stuffs. If you are satisfied with this, Ok-wrapping and throws are up to do conversions of the two respectively, both utilizing “sum types”/“enum exchange”. That is the way CeX is exploring.

If you want to treat Ok and Err equally and go further than CeX, the function signatures might become fn foo(/**/) -> Enum!( T0, T1 /**/ ); and must provide another mechanism different from?/map_err() etc. Will you? :smile:


Early exit issue

If it is the case, I would say CeX is not making things any worse or better. If Rustaceans find a general solution for early exit from nested function calls, I cannot imagine the reason why CeX is not able to follow.


#28

Syntax issue

The syntax you proposed has multiple issues to clarify or address:

  1. #[sum_errors] is not applicable for invalid Rust syntax. Unfortunately io::Error | num::IntParseError | *OtherSumError is not a valid Type. We are forced to use sum_errors!{}. The best chance we got is writing it as #[sum_errors] fn stuff() -> Result<i32, SumErrors!(io::Error | num::IntParseError | *OtherSumError)>;.

  2. File::open("xyz")?; has no extra annotation, such as ~ or its full form .may_throw(). I believe implementing a sum type in standard From is asking for language support – might change Rust’s type system, which is going further than EnumX/CeX.

I noticed your assumption:

Generally I do not believe this is feasible. While collecting variants into an enum seems O(1), supporting conversions between enums is quite a different thing. The latter would be required if a cex fn calls another cex fn and handles errors. Please check my read_u32()/a_mul_b_eq_c() example and image what is the Err conversions between fn stuff() and another fn another_stuff(). The best effort would be corporations by injecing significant syntax noise into client code, I guess.

Once again, I would like to emphasize that CeX is not an O(1) syntactic sugar, although it might look like, due to the brief throws syntax.

  1. What is the form of pattern matching on the returned Err?

#29

FAQ

  1. What does CeX aim to change?

To put error types in function signatures, avoiding to write type alias pub type Result<T> = Result<T,Error>;, mentioned as “wrapping errors” in Rust by example.

  1. What does CeX NOT change?

CeX requires neither stack unwinding nor “exception safety”, in other words, not to import “true exceptions” from Java/C++.

  1. What is the difference between “throw” and “rethrow”?

CeX is based on EnumX. Technically speaking, “throw” is into_enum() and “rethrow” is exchange_into(). In other words, “throw” does a one-to-many conversion from a plain error to compound error, while “rethrow” does a many-to-many conversion from a compound error to another one.

  1. Why use the terminologies “exception”/“throws”, since CeX is essentially not “exception” as the one in C++?

The ? is mentioned as an exception mechanism of Rust in RFC-0243.

And I regard Rust’s exception as an explicit propagation of Err.

However, I have no strong opinion about naming. Unfortunately into_enumx()/exchange_into() are not related to error-handling. If someone got proper names, they will be adopted.

It seems that some people are quite satisfied with “throws” but the others are not. For those who dislike it, a style of writing vanilla Rust is ready for them, looking like fn foo() -> Result<T, Cex<Enum3<E0,E1,E2>>>;, without using cex!{}.

  1. Is it an O(1) syntactic sugar which could be easily done by using macros?

No.

Currently CeX is O(n*n) in compiling while n is the maximum variants it supports. Maybe it can be improved in O(n), but I don’t think it can be O(1). Generally speaking, it is nontrivial to mimic “sum type” without language support.

On the other hand, if users are satisfied with explicit linkages between compound error types, it is something like “public vs friend” in C++’s terminology. You could not say that “public” is a sugar of “friend”, since they are quite different in user experience and serve for different purposes.

CeX does not forbid the using of “friend”, it just provides the “public” mechanism which is missing and can’t be easily done via macros.

  1. What if Rust does support this?
  • Using standard From instead of “(re)throw”. (may require type system changes)
  • More friendly compile error messages
  • Less leaking implementation details
  • Potentially compile time improvements
  1. What NOT if Rust does support this?
  • throws syntax will NOT propagate to fundamental infrastructures such as map().

#30

I’m sure he wrote it just because there are people who want to see exception-style errors handling. I don’t understand why you don’t use other languages where you can use your so non-exceptional exceptions.


#31

I decided to close this discussion.

  • Good news for fan of orthogonality: Logging has been separated from the error conversion APIs, and supports user-defined log agent/item. Finally I have got your points. @kornel

  • Good news for haters of throws syntax: Any syntax or traits, macros, have been removed. @H2CO3 @vorner

  • Good news for fans of generalization: enum exchange for Ok is possible, and has been implemented. Finally I have got your points. @kornel @vorner

Now we can focus on things that is more interesting:

Pre-RFC: the error handling approach in manner of static dispatch using enum exchange.

Maybe you( @Ixrec @CAD97 ) will be interested in.


closed #32