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

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.

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!

5 Likes

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?

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)

4 Likes

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.

5 Likes

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.

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?

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().

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.

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.

2 Likes