Closed divyekapoor closed 6 months ago
I apologize for the long issue above. 5 line summary:
Continuing down the current path will mean that the community will eventually have to create the "anyhow" crate which everyone will have to agree on as the error type.
See the myriad of reasons why typed errors are bad: https://www.google.com/search?q=checked+exceptions+harmful
(the combinatorial expansion of the error space means that it's futile to assume that people are interested in writing error handling code for all the variety of fine grained ways in which a piece of code can fail - all failures should be handled relatively uniformly: a structured error object serializable into a string or a JSON list is the most uniform way to achieve this goal).
Consequence: please rework error handling to be more similar to Java. They got it right. Rust got it wrong.
it's only for the compiler's type benefit (no human cares whether it's Error or fail and whether it should be ? or ??).
Incorrect - ? is only for the reader's benefit. As you note, the compiler can figure it out just fine on its own. ? visualizes scope exits.
I understand what you mean about exceptions, but ... I really don't want to add exception handling. It's a mess from an implementation perspective. I have some plans to improve this, but I don't see it as urgent right now. Fwiw, I think I can fix this easily later because code that uses ?
will always stay compatible with code that doesn't, so if this turns out to be an insurmountable issue I can always add a per-package flag for "automatic error propagation" later on. Currently written code will keep working as it currently does; that doesn't commit me to not adding exceptions later. I am aware of the problems you raise though.
To clarify, the point of the language design right now is to do simple and straightforward things. The package system should make it viable to upgrade the language incrementally in the future.
Thanks for the response! Very much appreciated.
Fwiw, I think I can fix this easily later because code that uses ? will always stay compatible with code that doesn't, so if this turns out to be an insurmountable issue I can always add a per-package flag for "automatic error propagation" later on. Currently written code will keep working as it currently does; that doesn't commit me to not adding exceptions later.
The main thing is that ? is harmful. Actively so. It encourages poor code and poor libraries. It's a degraded version of a re-throw-without-context and once the ecosystem adopts Result<T, Error>, there's not a lot of going back. I see that you understand this. I will rest my case.
If ? is to be part of the language, then at-least force a context capture and serialization of all the function parameters and the line-number of the return point as part of the exception path. This too is non-trivial work, though it's better than the status quo. The outcome is going to be "beautiful error messages and stacktraces" of a depth that even Java can't match - all by default.
Thanks for considering the above. Please feel free to close out this ticket.
Cheers!
My plan (as required) is at some point to change ?
to inject local site information into the error. Or just have Error in general accumulate local information on return.
Thanks for the feedback!
My plan (as required) is at some point to change ? to inject local site information into the error. Or just have Error in general accumulate local information on return.
Sounds good. One point I’ll make about this proposed approach - it will require every single object in the codebase to be serializable to String. Please make sure this is a language feature by default and not an add on like the Rust serde crate. For structs and classes, it will require an implicit codegen similar to #derive[Debug] on every struct. Rust got it wrong by making this optional (making all structs noisy and of variable quality wrt debugging and printing).
Thank you once again for bearing with me. I wish you all the best with Neat. It shows promise.
I'm sorry that I'm touching on such deep aspects of language design (and especially one where you've recently made progress). However, the traps that Neat is falling into Re: error management are completely avoidable and are those that are critical for ergonomic operation of the language. (Rust fell into the trap that Neat is falling into and now they are too far along to change this design decision).
For starters, I'll point out to this blog post: https://www.divye.in/2020/06/checked-exceptions-break-composition.html
The main thesis is that checked exceptions break composition. In a single line, the issue is that when someone is writing code, they have control over their dependencies as they exist at that point in time - that is, they rely on the type signature at the time of writing the code: let's say it's <int, FileNotFoundError> and they happily handled the case. As their underlying library's dependencies evolve (note - might be 2 or 3 levels deeper), a change in a transitive dependency will implicitly change the type signature of the function. Let's say it changes implicitly to <int, FileNotFoundError, OutOfDiskError>. By the very nature of the code written (especially with the ? operator a la Rust), the type signature is actually something like <int, Error, FileNotFoundError, OutOfDiskError, ...and others>) will cause the error to propagate. Problem: local changes can propagate global changes to the type graph in a cascading way: Ugly.
In practice, every function has a bivalent implementation: f: T -> U f: Error -> Error
The first one is the happy path. The second one is the happy path for error handling (which is equally important!). After sufficient complexity and transitive dependencies, it's impossible for f to be cogently written with an error type more complex than simply std::Error - it's a maintenance nightmare with every dependency upgrade (eg. what would you do if an RPC library introduced a new error type RPCFailedDueToOutOfMemoryOnRemoteNode that is only mildly different from RPCFailedDueToSocketExhaustionOnRemoteNode and that's bubbling up to you through some code paths over which you have no control).
The introduction of a checked or typed exception system breaks the "programming in the small" vs the "programming in the large" symmetry. Every such language will force users to write code that "homogenizes" the Error types at some level of abstraction otherwise that's a loss of ergonomicity (especially with dep upgrades!). Library authors also split on Error type management (see the mess with Rust's Error type being extended with anyhow and thiserror - anyhow is essentially RuntimeException and it composes cleanly, thiserror is checked Exception and it is a mess - see the crate documentation to judge for yourself) RuntimeExceptions restore composition of functional types. So,
f: T -> T f: Error -> Error
cleanly composes with g: T -> U g: Error -> Error
(and so on... all the way down the function composition chain).
We need to look at our goals with Error handling:
How can we fix this?
The rare case of g: T -> Error -> U where the catch block actually manages a successful recovery instead of a re-throw is composed on the f.g.h: T->U path with an if-condition and interestingly, this does not break composition! (it adds an if-check similar to the existing code)
The major advantage is that, error handling becomes a joy. It's not an accident that Rust has such poor error management and that Java errors are beautiful stacktraces with lots of contextual information. The languages have made specific choices that have produced these outcomes.
In summary, the asks are:
The important part is that (3) is not expensive because there's no string concat till the final dump. It's just an alternate execution path that keeps a bunch of references around which are compatible and can be optimized. Any error handling code can pattern match against the JSON list. If you'd like to retain the type information, make it part of the error: eg. throw MyFancyError(".....", ...) transforms to throw std::Error("....", ..., context=MyFancyError) and catch blocks can be written against MyFancyError and internally, it's still always std::Error on all the types (essentially it's composition and not inheritance and certain catch blocks may use RTTI to trigger).
The structured JSON list [err1, err2, ...] homogenizes the combinatorially expansive space of errors into a uniform space that is amenable to pattern matching and syntactic sugar.