> Any error system that handles all errors the same way will fail because there are some errors we can ignore, and some errors we must not ignore.
Rust has separate categories for these two things. Panics cannot be handled, which pushes the author to use them sparingly. Results must be handled (or explicitly elevated to panics, in a way that's easy to track down later).
The main weakness of this system, imo, is that the thrower, not the caller, decides whether or not a given error must be handled, and sometimes the answer is "it depends on what the consumer is doing".
That said, the powerful (mainly, no-implicit-null...) type systems that are starting to become prevalent prevent a lot of cases that would have been errors 20 years ago from existing in the first place. In modern typed languages an error is almost always a genuine IO/environment anomaly, which means there are a lot fewer of them to handle in the first place.
Edit: The other big weakness of Rust's system, as mentioned in the article, is the jumble of all the different disjoint Error types. I think the core issue here is that Rust doesn't have ad-hoc union types. I.e. if you want to say "this value is of type X or Y or Z", you have to declare a new enum somewhere, and embed your possible types in each branch, and that's a lot of ceremony at each point where you bubble up a different possible type of error. I understand why Rust's enums are static, though, I wonder if there's a place for some sort of heap-allocated ("dyn?") solution that allows any combination of possible types.
Edit 2: Actually this probably wouldn't be possible because there would be no way to distinguish the union, because Rust carries no type info at runtime. Maybe there could be syntax sugar for anonymous enum types declared inline?
So... exactly the same problem that Java's checked exceptions have?
Well yes, but also no because Java's checked exceptions have issues which go way beyond that. Hell I'd say this is not an issue because of the other issues.
In Rust the thrower decides whether the error must be handled, but the default is "yes", and it's the overwhelmingly common decision. Panic is the exception (or multiple APIs are provided). Rust also provides easy way to convert errors to panics, and syntactic sugar to convert between error types.
In Java, the classifications were much less stark, and thus more arbitrary, some exceptions were checked, others were not, but there was little rhythm or reason about it.
Furthermore, there was little to no ability to abstract over checked exception, and the statement-oriented nature of the language made both converting checked to unchecked or converting between different checked exception types a chore.
The verbosity and horrible ergonomics of java's checked exceptions is where the problem always lied, really, with the seemingly arbitrary nature of the classification coming in at third.
The correct thing here is to correctly model your Error domain and map errors you don't control to Errors that you do. In the Java Checked Exception case the Throws clause only grows because everyone is trying to ignore the errors and make somebody else handle it. I would argue that in Rust the explosion of different generic error handling mechanisms is also laziness and a desire to not handle the other half of the API's you are handling.
It seems programmers want errors to handle themselves. It's like they want errors to automagically hook themselves into the entire code base and do "the right thing". Or maybe in other words: programmers mistake errors-as-additional-data, with errors-as-automatic-runtime-behaviour.
Am I crazy?
There's the option of surfacing the panic-ability of a function in the same way the constness is surfaced, which would allow some subsets of the code to ensure they won't call a possibly-panicing thing, even at the cost of convenience.
Sure. It is also by far the largest risk you face, so would solve a good 4 or 5 nines if the issue.
> And really, noreturn ought to be enough to infer that on its own.
noreturn only works for functions which never return. Most panicking functions do return, just not all the time.
Sorry, I meant that any function that was noreturn could be inferred to take up to forever to return (since it always takes forever to return, since it never returns). Then, any function that calls such a function (in a non-DCEed position) can be inferred to also take up to forever to return (even though it might take less than that), because some possible code path through it (it's up to X amount of time to return) takes forever due calling a function that takes forever.
* Rust doesn't infer function types (on purpose)
* like const (or Copy), the removal of the flag is a breaking change, so it should not happen implicitly
So functions would necessarily have to be explicitly marked as e.g. `convergent` or whatever. And while callers of `!`-functions would be easy to check for, I don't think rustc is currently smart enough to infer the totality loops in general.
> I don't think rustc is currently smart enough to infer the totality loops in general.
Well, yes; we're necessarily talking about changes to the language (or, equivalently, possible different languages).
> while callers of `!`-functions would be easy to check for, [other problems wouldn't]
That was my point, thanks: "when trying to save your crucial data to disk, in real-time code where panicking would maybe kill someone in the real world, etc", checking for the easy-to-check-for problems doesn't actually suffice to prevent disasters.
echo 1 > proc/sys/vm/panic_on_oom
100% this. The very concept of "error" is philosophically unsound. There are no errors; only conditions that you dislike. It is unfortunate that programming languages allow to express your emotional detachment to one of both cases of a branch. Nothing good can come from that.
I yearn for a language with no error handling nor exceptions. Just plain language constructs to deal with the state of the world around the program, without unnecessary emotional attachment to certain flow paths.
Anyway, you can't just define the problem away, or else you end up with Go style error handling - that is exactly what a language with no built-in support for errors looks like.
Languages need to offer control flow mechanisms that allow you to separate common cases in the code from the uncommon cases. Especially since for most code, the uncommon cases appear deep in the bowels of an application, and the only way to handle them is to ask an actual human to handle them, usually on the opposite side of the application stack.
For a more reified implementation of this pattern, the Common Lisp Condition system is very interesting. It is similar to most Exception systems (code can raise a Condition, at which point a handler for that Condition is searched for up the call stack), but with one crucial difference: when raising a Condition, you can also specify Restarts - alternatives for how to proceed. Condition handlers can choose whether to cancel the execution flow and handle the condition with their own logic, OR they can choose to invoke one of the Restarts offered along the condition, based on their own logic. An example would be something like: Raise ConfigFileNotFound; Restarts: ContinueWithDefaults, Retry, ContinueWithOtherFile(newFileName). Then, a Condition handler (possibly a User) can choose to stop the application, or it can choose to continue with the defaults, or it can create the file with its own defaults and Retry, or it can try a different config file path.
This offers a lot of flexibility and has uses outside the idea of handling errors.
You can still readily express the concept of conditions in Rust, but it’s no longer baked into the standard library.
Strong disagree with this sentence. It represents the exact opposite of what I deem a good programming language. The difference between "common" and "uncommon" execution paths must be of no bearing. Moreover, this "likeliness" depends on the (unknown to the programmer) usage that the program will be put through. For all you know, the uncommon path may be the only one that will be ever traversed in all instances of your program. A correctly specified program must deal with all possible input conditions, and that includes missing files, inconsistent parameters, and lack of resources. A filename existing or not is the same boolean value as an integer being even or odd.
Even outside of programming, human thought often works exactly in terms of general cases and exceptions. It's just what comes naturally, and we shouldn't be fighting it in code.
> The difference between "common" and "uncommon" execution paths must be of no bearing. Moreover, this "likeliness" depends on the (unknown to the programmer) usage that the program will be put through. For all you know, the uncommon path may be the only one that will be ever traversed in all instances of your program.
As the designer of that program, I obviously know up to a very good degree of confidence what the usage of the program will be: I am designing my program for a particular use, by definition. Sure, I may not know exactly how flaky your network may be, but I know that this program only really works on networks that deliver more packets than they drop, that can read all of the bytes I wrote to disk back, at least most of the time, and so on.
Or, perhaps I'm writing a program that is meant to run on very flaky networks (say, a device for wireless thunderstorm sensors): I expect that program to look very different from the Netflix app's networking code.
No; this is bad engineering. You write a program to conform to a specification. In the specification, it says what must happen when a file does not exist, what must happen when there's not enough memory, etc. Then you write the specified behavior into code.
I've never, ever seen a specification which mentions file-not-found or out-of-memory errors. The "specifications" I get from my clients are more like (paraphrased):
> Listen, adwn, I have all those files in a shitty, undocumented file format designed by another group in another building, could you please add an import button for them? Oh, and can I have it by Friday? Thanks, you're the best!
I'm exaggerating only slightly. And then I go and try to make sense of what they need, and I implement error handling on top of it.
Your specifications tell you exactly how to deal with file-not-found situations? You lucky bastard ;-)
And if the spec says "try downloading the file 3 times at 5 seconds interval; if that fails, give up the update", I am free to implement it however I want (?) unless I missing your point.
I've never seen an end-user application accompanied by a written a specification about what happens if there is a run-time error due to a mistake in the program.
Your original thesis is something like that "there are not errors, only conditions we care about"; but then this subsequent argument is disappointingly about environmental conditions only.
If I have a program that takes two run-time inputs and divides them, I may or may not specify what happens if the denominator is zero. I could explicitly specify that the behavior is not defined; my program can do anything. (In practice, any decent processor will catch it via some numeric exception.)
If my program generates a division by zero due to a programming bug, where no such division is implied by the specification of what the program does on the inputs which it is given, that's something we don't bother specifying. Not usually.
Anything can happen if the program has a mistake. A document listing all possible mistakes and how the program will react will not only be intractably long, but in the course of writing it, you would just inspect that the program doesn't have those mistakes. In the end, you'd be left with a document describing mistakes that the program doesn't actually contain, while it remains vulnerable to unknown mistakes.
We might be required to have a strategy for the software to deal with its own faults in a general way, if we are working on something safety-critical. Such as that no matter how the program might fail, the system as a whole will revert to a safe state.
Most software in business is written to implement a process. And business processes are exactly defined as a common workflow with edge cases and exception handling. Not all software is like this, but a hell of a lot of it is. And those business processes are not always complete, and they are typically changing over time as well. Any idea of a global specification outside of the process that the code is actually implementing becomes useless pretty fast.
For some domains, you can write a specification that has value in itself, it will be shorter and easier to review.
For other domains, the program itself is the best specification for the desired behavior. There is nothing special about a specification that makes it inherently more correct than a program. The specification may be large enough and detailed enough that it is essentially just as hard to review as the program output. The specification can have subtle bugs, just like a program. Even worse, the specification itself is harder to test, especially for corner cases.
It can be very easy to produce an excellent specification that solves a different problem than expected, especially for complex problems with many moving parts and many possible use-cases.
When people say "errors are values", I say: yes, but they are a special kind of values. The same way floats are special (signaling NaNs), or that bools are special (short-circuit operators). It is great to have special support for values which need to be used in a certain way (here: don't lose any error, bubble up by default).
It is great to have the opportunity (not the obligation) to use features for separation of concerns. This makes it a bit like an aspect-oriented approach where normal code emits errors and error handling code elsewhere knows how to handle/restart them. If you language allows it, it makes also sense also to decouple logging (tracing functions) or data-access permissions (postgresql row level security).
In the C code I maintain at work, everything is here in plain view, logging, error handling, etc., and I am not complaining, but more recently we tend to use other languages or code generation a lot (think of something like protobuf) because it makes code a lot easier to maintain.
Why? I think that a good programming language should help readers focus on the "essence" of an algorithm. Usually, handling stuff like memory allocation errors is just noise in that effort.
Besides, most errors will usually be propagated to the caller, so I think the programming language should make that convenient, like Rust does with its ? syntax, and most languages do (perhaps too implicitly) with some concept of exceptions.
The general problem seems to be something weird happening far down in the system, for which the correct way forward is dependent on how we got there. This situation can't be handled where the issue happened since that would break encapsulation, but unrolling the stack all the way up blows away the context that is necessary to understand how to move forward. I don't see how a system that either unwinds the stacks (throwing errors) or tries to encapsulate the state through the system (Either) would truly solve the issue.
It doesn't solve the issue of forcing programmers to handle errors..but then, CL doesn't care a lot about hand holding.
like "file not found", "disk full". Something in between like "out of memory".
Misusing an object as the wrong type, or division by zero, or accessing missing memory are errors. These usually point to defects in the program, or in some cases defects in the program's defense against bad inputs.
There are related errors are the hardware level, like numeric exceptions and bus errors. Without these, machine-language programs just lock up or produce garbage results.
Situations like "file not found" or "host not reachable" are usually environmental conditions, and not internal problems. There are ways in which they can be internal problems; two modules in a program might be related in such a way that one prepares a file that the other expects to exist, and there can be some bug in that.
Then there are situations like "out of memory" or "disk full" are somewhere in between. All the operands to the calculation exist and are well-defined, the operation is correct, but just there is a resource issue. In pure computing theory, these situations are the heart of the distinction between simulating a Turing machine by means of a finite tape, and the Turing machine abstraction, as such, which has unlimited tape.
In summary, there are basically three categories: errors (programming mistakes), environmental situations (issues in presentation of the inputs to the program, like asking it to operate on a file that doesn't exist, or connect to a host that is down) and resource exhaustions (out of some type of storage).
There is no emotional attachment in this classification whatsoever; and there is value in their separation.
Then there is another category of errors: faults in the hardware. The foregoing assumes that there are no malfunctions in the hardware; no cosmic rays that collide with silicon, flipping the values of bits and such. In some situations, you need a demonstrated strategy for these. Like what if an error happens in a DRAM chip that is not detected and corrected.
The library designers had a choice between making a generic this-or-that type or a value-or-error type and they chose the latter because they thought that that would be the common this-or-that use-case.
Even Haskell’s more generic-sounding “Either” type is made for the same purpose: the “right” (as in correct) variant is the value while the left side is the error, by convention.
So you can also build an enum that doesn't have any explicit mention of which is the right path
Something is a failure when it fails to do what it says it does.
openfile();
If openfile() does not open a file, then it failed.
This terminology makes it clear we are not just bubbling up errors. It’s the function itself which failed.
This is true in mathematical sense, but unhelpful in UX sense.
- "Null pointer dereference"
- "Out of memory"
- "Disk is full"
- "File does not exist"
- "File does not exist in cache"
- "File exists but is corrupt"
- "Access denied"
- "Connection reset by peer"
It's pretty obvious that all of the above is generally unwanted most of the time.
However, putting them all in the same bag labeled "error", and forcing them to be treated the same way might be counterproductive. Sometimes you might want to panic. Sometimes you might want to retry. Sometimes you might want to ignore!
Now, if your program isn't interactive (such as a compiler), halting on any error might be a choice. But you still have to provide contextualized and accurate error messages, which is easy for the case "File does not exist", and a lot less easy for the case "Out of range index".
Forth?
Worse, it's COME FROM!
enriquto when you talk about "a language with no error handling nor exceptions" it reminds me of a crazy idea i was toying with: what if you made all "exceptions" require handlers, e.g. what if every divide had to be accompanied by code to deal with divide-by-zero? In other words, DIV(X, Y, Foo) would be (X/Y if Y != 0 else Foo()) And so on...
On the contrary, the Common Lisp condition/restart system solves it, and it's maddening that this hasn't been adopted anywhere else.
An exceptional state signals a condition, and without unwinding the stack, looks for a handler for that condition, which can, among other things, restart the computation from a lower stack frame. In development mode, it defaults to dropping into the debugger/repl; in release mode, an unhandled condition panics.
The way you described it seems not so much as hard problem. More like an impossible one.
That said. Perhaps error handling isn't one problem at all, but several problems masquerading as one. In which case Rust approach makes much more sense.
And I'm no Rust developer, but it looks to me like it basically demonstrates how Rust is an abject failure in that regard. The developer has to jump through lots of nonobvious hoops and choose between competing libraries to get anything useful.
Contrast that with Java, where any uncaught or logged exception prints a detailed stack trace and (usually) one or more useful error messages, by default, since day one.
This is a problem with very good known solutions, yet Rust seems to fail hard.
I think you're misunderstanding what the OP is trying to show here. If all you want is a backtrace, then Rust supports that out of the box. Here's an approximation of the full error message that you would see from the first example in the post:
thread 'main' panicked at 'dumping failed', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The OP has omitted the next line, which tells you how to receive a backtrace. Backtraces are off by default in Rust because, unlike Java, Rust has a lightweight, C-style runtime that tries not to impose any runtime cost that the programmer did not ask for.Even in the absence of stack frames, the mere contents of the stack with lookups where possible is really useful, and usually more than enough.
The cost is a little code to do lookups at runtime, and making the mapping data available (typically compressed and embedded in the executable).
(Note that this isn't stack unwinding or exceptions. This is backtraces. Rust, like most languages which embed errors in return values, makes the developer do the stack unwinding manually.)
As a rust developer, there are other ways to skin a cat, or an Error return as it were. For the std::net library (I've been using it when dealing with tcp/udp sockets), when a socket returns an Error, it uses std::io::Error [1]. This is basically a struct that implements the display and Error traits, it stores an enum inside of the types of errors that you may want / need to ignore/ handle. You don't technically need to use libs to do anything error related.
This code has been round since Rust 1.0. Imho, this is kinda how Rust should tell people to make/handle errors. I like it. But I also have found rust enums to be extremely powerful in a lot of use cases.
I'm only posting this because I find a lot of people forgetting that Java has had a very long history at this point. And that many of the things we take for granted in the language today did not always exist. I still remember when generics were released. Get off my lawn.
try {
someCall();
} catch (ErrorICanHandle err) {
//handle
}
perfectly equivalent to this: err = someCall();
if canHandle(err) {
//do something
} else {
return err
}
I don't see why so many people think exceptions make code harder to analyze. In my opinion, it is errors themselves that make code hard to analyze, regardless of implementation strategy.The only difference between exceptions and error returns/error codes is in human readability: one makes the bubbling behavior explicit, which clutters the code but makes it obvious; the other makes the bubbling behavior implicit, which keeps the code cleaner, but also less obvious.
After writing professionally in both languages with Exceptions (Java/C#/Python) and in Go, I much prefer Exceptions to error values/codes, but I can appreciate that it may be different in other domains or for other people.
Yes, this is exactly what I meant.
> it is at least explicit in what functions can fail and which don't
So is Java with exceptions (at least as long as developers are even slightly disciplined).
try {
someCall();
someOtherCall();
} catch (ErrorType err) {
//handle
}
Which method throws? If they both throw ErrorType but one of them cannot be handled, then you have to put additional logic into the catch and rethrow.This is a common source of faulty error handling.
try {
someCall()
} catch (ErrorType err) {
//handle one way
}
try {
someOtherCall()
} catch (ErrorType err) {
//handle another way
}
It's no less verbose than the error values way, though again, it is easier to make the mistake in the first place.I'm not trying to claim that exceptions are ultimately better than error values, just that the difference isn't so much "non-local control flow" as it is explicitness vs implicitness. Exceptions are implicit, error codes are explicit. Both have benefits and drawbacks.
someCall()
is equivalent to code in a language without exceptions that looks like this: err = someCall()
if err {
return err
}
(ignoring for a moment non-error returns, which don't change the point significantly)Having functions that are guaranteed not to throw / return errors doesn't significantly change this shape.
So basically it's no more non-local than the code you'll end up writing with error codes, in most cases, any way. The only difference is implicit vs explicit.
> `ErrorICanHandle` may be a kind of exception that the `someCall` function has no idea about whatsoever -- it could be an exception propagated from much farther down the call stack -- while your if statement assumes the error was explicitly returned from the function.
someCall doesn't need to know about the meaning of the error in either case. It only needs to propagate any error it receives from any functions it calls (that it can't handle itself). Think about the function I wrote myself: did it need to know the precise type of err? Nope - it just needed to know that it implements some kind of Error interface (which may be as simple as being a negative number, like a POSIX error code).
Edit to give a more complete example:
extern void bar();
void foo() {
bar()
doOtherStuff()
}
void someCall() {
foo()
doMoreStuff()
}
void main() {
try {
someCall()
doFinalStuff()
} catch (BarSpecificException e) {
doErrorStuff()
}
}
Is equivalent to this code: extern err bar()
err foo() {
err = bar()
if err {
return err
}
err = doOtherStuff()
if err {
return err
}
}
void someCall() {
err = foo()
if err {
return err
}
err = doMoreStuff()
if err {
return err
}
}
err main() {
err = someCall()
if err is BarSpecificException {
err = doErrorStuff()
if err {
return err
}
} else if err {
return err
}
err = doFinalStuff()
if err is BarSpecificException {
err = doErrorStuff()
if err {
return err
}
} else if err {
return err
}
}
If I ask you in the second piece of code what will be executed after the call to foo(), is it any easier to reply than in the first version? Personally, I don't believe so.The bigger difference is knowing whether you should expect errors at all - with exceptions, you can forget to handle an error and you may screw up an important assumption, like not unlocking a Mutex if an Exception is raised. With error codes, you have the opposite problem: you may forget to check for an error, and continue to execute in a bad state.
Nitpick: I'd say if you need the compiler to optimize exception handling you are using them wrong.
Exceptions are for exceptional circumstances.
(if you mean that exceptions mess up optimization of surrounding code that could be a bigger deal but I won't accept that without pointers to benchmarks$
> Any error system that relies upon developer discipline will fail because errors will be missed.
You must handle all errors on egress to either C abi or a function that does not have an error signature.
> Any error system that handles all errors the same way will fail because there are some errors we can ignore, and some errors we must not ignore. And what's ignorable/retriable to one project is not ignorable/retriable to another.
trys, which are the "lazy" way of error handling (not counting "catch unreachable" - which promotes errors to panics and shouldn't be used except in dev) automatically append the error return code to the trying function's error return call.
> Attempting to get the complete set of error types that any given call may raise is a fool's errand because of the halting problem it eventually invokes. Forcing people to provide such a list results in the Java problem for the same reason.
If every function has a well-defined tree of possible internal "call dependencies" that has finite set of type signatures, you don't have this problem.
> Any error system that relies upon developer discipline will fail because errors will be missed.
> Any error system that handles all errors the same way will fail because there are some errors we can ignore, and some errors we must not ignore.
Not sure how that's true for Rust. Functions that can fail, return a Result, that contains either a value or an error. If you want to use the value, you have to either explicitly check which one is it (you can handle or ignore the error at this point), or quickly access it by unwrapping the Result, which will crash (panic) the program on error.
Maybe it's not error-handling that is the problem then, but undisciplined software developers writing bad code?
Out of all the engineering disciplines, us software engineers have to be the worst bunch by far.
1. Continue trying to create error-handling systems for devs 2. Try to solve this with more generic static (or dynamic) analysis tools.
I mean, maybe what we need to do is make simulator-esque testing more popular, or something.
They can also harm readability, as just about every statement can now cause an early return from a block. They also greatly complicate static analysis.
No discussion of the shortcomings of conventional exceptions would be complete without these two excellent blog posts by the great Raymond Chen:
Cleaner, more elegant, and harder to recognize https://devblogs.microsoft.com/oldnewthing/20050114-00/?p=36...
Cleaner, more elegant, and wrong https://devblogs.microsoft.com/oldnewthing/20040422-00/?p=39...
> Attempting to get the complete set of error types that any given call may raise is a fool's errand because of the halting problem it eventually invokes. Forcing people to provide such a list results in the Java problem for the same reason.
The Java problem is one of ergonomics -- unwieldy lists of exception types -- not a computability problem. What do you mean here?
First of all, because a religious subculture will run to pitchforks and torches at the slight mention of the word exceptions in the room.
Then the overall machinery in place to make them work alongside code that was most likely written as C originally.
And naturally every ms and byte lost to exceptions infrastructure is a capital felony, so even more fine grained semantics to make it happen.
C++ is still the language I reach for when not doing any JVM/.NET language, but things like this is what takes the fun out of using it with another ones on a team.
I'm not sure what you mean here, neither article is about the low-level issues that arise in C++. The second article only discusses C#. Chen's point is more about the way every call can surprise you by causing your code to return early.
At least with exceptions you get a "I told you so" if something does happen, and a defined way to force application termination if the error isn't properly handled.
Given the remarks on the C++ post, I assume he belongs to the /EH- side of pitch forks and torches, which actually goes against the C++ best practices at Microsoft, https://docs.microsoft.com/en-us/cpp/cpp/errors-and-exceptio..., which leaves a feeling of a kind of biased article in some way.
Because in this case, the error handling is done in a different process altogether (supervisor).
That’s not true.
while True: pass
raise ImpossibleError
But normally we are perfectly content to put ImpossibleError in the signature for code like this.There is a practical example of “panic!” in Rust to go around that as well.
I'd argue that only the 3rd kind is actually 'exception', it's completely out of program's control.
Code contracts are wonderful way of dealing with 1st and 2nd kind, sadly they didn't catch up and remains mostly unknown. They are vastly superior to tests and also serve as much better way to document intent of the code, how the author expected the code to work.
() -[DatabaseReadError]-> ()
then they would be part of a function's 'contract'.
With this, consumers of your function are making an active decision about whether to handle or bubble an exception without examining your implementation, and the type of the main function tells you which errors will end up being fatal.
Disclaimer: this is not a new idea. There are proposals for OCaml to add 'algebraic effects', which have similar annotations on arrows, and I believe there have been discussions around using the syntax to track exceptions.
To fix that, you need to start supporting error polymorphism. For example, `map` should have a signature like
map :: List a -> (a -> b -[err]) -> List b -[err]
So that map [1 2 3] +1 //no errors
map [1 2 3] sendOnNetwork //returns NetworkError
At least, this is one of the major limitation of Java's Checked Exceptions idea, one which often forces you to use Unchecked Exceptions.Another common problem is handling exceptions which occur while handling another exception. D has the best default I've seen in that area (it will automatically collect all of the errors, instead of panic()-ing like C++ or discarding the old exception like Java and C#).
interface SomeProcessor<E> {
void process(Thing thing) throws E;
}
<E> void processThingsSomehow(
SomeProcessor<E> processor,
Container<Thing> things,
Parameter how) throws E {
// ... {
processor.process(extractedThing);
// } ...
}
and then later: void processOrFail(Thing thing)
throws SomeCheckedException {
// ...
}
void processOurThings() {
try {
someObject.processThingsSomehow(
this::processOrFail,
getThings(), HOW);
} catch (SomeCheckedException e) {
// ...
}
}
and it definitely worked the way I expected—if the correct exceptions weren't caught in processOurThings, it wouldn't compile, and processThingsSomehow did not have to catch them. It even worked in at least some cases with multiple throws on the concrete SomeProcessor, though I think the different exceptions involved had an upper type bound within the package; I don't know how well that's handled in the general case. > map :: List a -> (a -> b -[err]) -> List b -[err]
FWIW, Haskell has this already; it's called[0] Traversable: ghci> :t mapA
mapA :: (Traversable t,Applicative f) => (a -> f b) -> t a -> f (t b)
ghci> :t mapA @(Error ||) @[]
mapA :: (a -> Error || b) -> [a] -> Error || [b]
0: The default prelude calls it `traverse` instead of `mapA` (which is terrible for reasons that should be obvious[1]) and `Either Error a` instead of `Error || a` (which is a matter of taste).1: Especially if you rename the method of Functor from `fmap` to:
map :: Functor t => (a -> b) -> t a -> t b
-- for comparison:
mapA :: ... => (a -> f b) -> t a -> f (t b)The problem is that it makes the most common case (letting the exception bubble up) very inconvenient and clutter-y. Signatures with 5 different declared exceptions are worse than useless.
The Java team adopted the ideas of CLU, Modula-3 and C++, yet it keeps getting the blame while being the fourth language implementing the idea.
While it would be cool to get an error payload, I would hope that if that was added to the language, that it doesn't affect the current ergonomics of error handling.
Where I've really needed to get some data back out, I've passed in a pointer to a struct that gets populated.
Haven't there been some languages that force functions to return some kind of tuple like:
result,error
And forces the programmer to at least do:
if(error) { }
It does not force any kind of correct handling, but simply oversights should be caught. I might be imagining things though.
Rust, meanwhile, is way stricter than that, and has gone way further on the "not relying on developer discipline" path: a failing Rust function will return a `Result<Value, Error>`. You can't even access the value without explicitly checking whether it's a value or an error one way or the other, which you can very much do so in e.g. Go. Rust also has an attribute called `must_use`, which can be set on types and functions. That attribute causes the compiler to emit a warning if the marked object is not used at all (either the type or the function's result), so while go will not say anything if you write
Foo()
and that returns an error (or an error and a result you happened not to care for), Rust will absolutely complain by default in the same case, you will need to write at the very least let _ = Foo();
Go has a second issue, which is that in result, error := Foo()
that you "have to" handle the error is a consequence of the unused variable check (can't define a variable and never read from it). However because it's that instead of something dedicated, this: id, error := Foo()
result, error := Bar(id)
if error != nil {}
will work fine, with no complaint. Despite possibly passing complete nonsense to Bar if Foo is in error. Also works with id, error := Foo()
if error != nil {}
result, error := Bar(id)
Funnily enough Rust will also warn in both those cases, because it tries to track individual writes.Of course that leaves function that can fail but don't return any value, but since Result is tagged "must_use" you get a compiler warning if you don't explicitly discard the result with something like `let _ = foo()`.
If the function returns a (non-error) value that is directly accessible from the function call, the programmer is not forced to do anything with the error.
This is exactly the same problem with null: forget a null check, and you're hosed. Forget an error check, and you're hosed.
If you make errors a part of the actual single return type (as Rust does), then you have to explicitly deal with the possibility of an error before you are even allowed to touch the successful case.
Even the name is wrong: an "exception" should signal truly exceptional behavior, things that you would not expect to happen unless something is really wrong. And yet exceptions are thrown for mundane things like "file not found", something you could very easily expect to happen routinely.
Exceptions should be like Rust's `panic!()`: only for things that the programmer can't reasonably do anything about, which will cause the program to terminate.
Also, things like "user canceled this operation" are great for exceptions IMO... exactly how would you stop (say) a sort() function otherwise? You'd need to write a custom cancelable sort, which isn't a great idea? You can't reinvent the wheel for everything.
They're strings. They’re chain-able. They’re visible. They’re hard to ignore. They don’t propagate or crash unless you want them to.
First, we had error codes. Except these were wrong because people forget all the time to check them.
Then we had exceptions, which solved the problem of people forgetting to check by crashing the app.
Then the Java team got the bright idea to have checked exceptions, which at first helped to mitigate crashes from uncaught exception, but caused an explosion in thrown exception signatures, culminating in "catch Throwable". Back to square one.
Then we got multi-return error objects, maybes, panics, and all sorts of bright ideas that fail to understand the basic premises of errors:
Any error system that relies upon developer discipline will fail because errors will be missed.
Any error system that handles all errors the same way will fail because there are some errors we can ignore, and some errors we must not ignore. And what's ignorable/retriable to one project is not ignorable/retriable to another.
Attempting to get the complete set of error types that any given call may raise is a fool's errand because of the halting problem it eventually invokes. Forcing people to provide such a list results in the Java problem for the same reason.
It's a hard problem, which is why no one has solved it yet.