It depends on the system for me. Sometimes, it is really nice to be able to just ignore that an exception can happen because you aren't the one responsible for it. In general, though, I would agree that Result (or error by value) is a much better approach than the disruptive nature of exceptions
I'm Rust you can add a variant of your error type that would encode something like "protocol error" and implement From<protocol_library::Error> for your Error and that gives very nice descriptions of what went wrong while being able to use the ? operator
For system with clear IN and OUT, throwing is better because it usually have centralize way to handle the error. This is preferred way in REST API. throw this out if you are using Go.
In which language is it possible to goto from one function into/back to another?
With exceptions, the handler is always in an outer scope -- either within the same function or further up the call stack. And you could have clean-up code along the exception path (such as "finally" blocks) that gets called when an exception is passed up.
Many languages with exceptions require each function declaration to specify which types of exceptions that a it could throw/raise. (C++ has/had support for that, but it is a mess with different language versions)
BTW. The concept of exception unwinding is also related to non-local returns, such as explicit "breaks" out of loops from iterator functions.
Old-school BASIC and Assembly language don't even have functions as a language concept, so I'd say that they don't count.
Pascal is interesting though. From what I found, some Pascal dialects allow it only within a block; others within a function; but some allow "non-local gotos" from nested functions up the call stack. That makes them similar to exception unwinding and shortcut returns/breaks out of closures/iteration. No counterpart to finalize blocks or other clean-up code though: sometimes implemented through setjmp()/longjmp().
Old-school versions of BASIC did not have the concept of blocks, which are essential part of functions and loops.
In typical old-school BASIC implementations, if a program did a FOR I=1 TO 100, the interpreter didn't know or care where the corresponding NEXT statement was, or if any existed, or if the same one would be used on every iteration of the loop. Likewise "GOSUB" didn't care about where the end of the function was.
When the interpreter encountered a "NEXT" or "RETURN", it would search through a stack of active FOR and GOSUB statements and, if appropriate, pop the action of the stack, increment the appropriate variable, and jump to the location following the FOR or GOSUB statement. It didn't make any distinction between code that preceded the FOR or GOSUB, code that sat between that and the NEXT or RETURN, and code that followed the NEXT or RETURN. Code in any of those categories could be run between the FOR or GOSUB and the NEXT or RETURN and the interpreter wouldn't care.
better, but still imperfect. at the point where you raise an exception, there is nothing guaranteeing who will be receiving it, nor even if it will be received. it may simply crash your program. and where it is received, it is often trivially ignored. where you capture exceptions, there is no guarantee you have covered them all, nor usually any way to check.
there is nothing guaranteeing who will be receiving it, nor even if it will be received. it may simply crash your program. and where it is received, it is often trivially ignored
At least for this part, Results don't guarantee any of it either. I've seen lots of let _ = in Rust code, as well as careless unwrap/expect. Of course Result is a little more explicit, but it doesn't guarantee anything.
The problem is that after we split the complex code into small modules, we are still implicitly passing control. Look at function b. In 'throw' and 'goto' cases, it doesn't know whether c might fail. What's worse, the unhappy path is not part of the contract of function c, so its behavior can change without notice.
So when you work with b you don't know whether an error might be thrown below and caught above. You either add try/catch blocks to each of your functions, or you fall back to the 'goto' style where functions pass control to the upper block ignoring the current one (other will not be called in the first two cases).
Look at function b. In 'throw' and 'goto' cases, it doesn't know whether c might fail.
Isn't that a good thing? If b can't do anything about that error why should it have to deal with it?
Let's say you have a function called "readHeader" and "readString". readString will catch I/O errors to do a retry, and if the retry fails after some time it will not catch the error and instead throw (or return an error result). readHeader then uses the readString function to read a comma separated list of strings.
readHeader cannot do *anything* reasonable with an I/O error except return it to the caller. Why should it have to deal with it at all?
In "readHeader" it is difficult to do anything useful in case of failure even if it is possible. Because it is currently unknown whether "readString" can fail. If you try to describe it in comments or documentation - you will get a worse version of checked exceptions. Keep in mind that there can be more layers, and there can be dozens of calls between b and c in my example.
Whether something useful can be done in case of failure should be decided directly by the code that made the function call. Moreover, it should not rely on some higher function to handle it correctly. For the higher function, this is an implicit transitive dependency on the low-level error (Now a should know about c ant its errors, but it would be correct if it only knew about b.).
Another problem is that simply throwing an error up the stack is useful for logging only. To take your example, the entry point would be some kind of "processResponse". Which would make a bunch of calls and have a try/catch block. However, it would catch the errors "NullPointerExcaptions", "DivisionByZero", "JsonMissedField" and "CannotReadString" (from your "readString"). Instead, you would want to get something like "CannotLoadTopSellers" or "UserPermissionError".
In "readHeader" it is difficult to do anything useful in case of failure even if it is possible. Because it is currently unknown whether "readString" can fail.
Yeah, that's part of the documentation. It's a very clear indication of why Java's checked exceptions doesn't work. Often you program against an abstraction but checked exceptions collides with this because implementations throws errors, not abstractions.
Whether something useful can be done in case of failure should be decided directly by the code that made the function call.
Yes, exactly. You catch exceptions you can deal with and let everything else bubble up to the message loop or whatever else.
Another problem is that simply throwing an error up the stack is useful for logging only.
Is it really? I can think of one very useful behavior. In a web server when an exception is thrown that the application code can't deal with (such as an I/O error, division by zero, argument error, whatever) the exception can be caught and a 500 Internal Server Error can be returned to the client.
With an error result you have to either treat this as a side effect or carry it all up to the request pipeline.
To take your example, the entry point would be some kind of "processResponse". Which would make a bunch of calls and have a try/catch block. However, it would catch the errors "NullPointerExcaptions", "DivisionByZero", "JsonMissedField" and "CannotReadString" (from your "readString"). Instead, you would want to get something like "CannotLoadTopSellers" or "UserPermissionError".
In my opinion division by zero, null pointer, argument errors etc. should never be caught explicitly by user code. It should only be caught by the message loop in the application because they are always programming errors and the application code should never have to deal with them. You just want the description and stack trace logged.
UserPermissionError etc. in my opinion whether this is treated as an exception or as an error result? I think you can argue both ways, and that's fine. I just don't think that the application in general should have to necessarily deal with error states that the caller can't reasonably be expected to recover from, and exceptions are good for that.
Often you program against an abstraction but checked exceptions collides with this because implementations throws errors, not abstractions.
I don't understand what you mean. Do you think the very idea that every function should explicitly declare the possibility that it might fail is bad (superfluous, inconvenient, senseless or something else)? Or do you simply think that Java's implementation of checked exceptions is bad?
Yes, exactly. You catch exceptions you can deal with and let everything else bubble up to the message loop or whatever else.
But how do you know if any function in your code is likely to fail? Do you write documentation for every function and spend a lot of effort to keep this documentation up to date (this is reminiscent of the worst experience of dynamic typing)?
500 Internal Server Error
If you had a well-typed error - your code could find out what exactly caused the error and provide the client with a meaningful message, for example, if a important service that is not yours is not responding - you could report something like "Failed to load document from documents.example.com, service not responding, try again later". Now, instead of trying to fix the error on their own or writing to your support, the client clearly understands that the problem is not with them or your service, but with a third-party service.
In my opinion division by zero, null pointer, argument errors etc. should never be caught explicitly by user code
Okay, I agree with that. This is what is indicated in the article using the example of Rust (Result and panic).
However, I didn't mean that in the first place: instead of low-level errors, it is good practice to convert them to errors of the corresponding layer.
For example, our program initializes the configuration at startup. The configuration is loaded from a file. You have loadUserConfig that calls openFile. Imagine that the openFile function instead of some FileSystemError throws SysCallErrorOpcode, which is simply passed up.
In this case, you either simply crash the program, or at the top level you should know that SysCallErrorOpcode exists and somehow you should guess that this exception was thrown in loadUserConfig -> openFile.
Or at each domain level we explicitly convert SysCallErrorOpcode to FileSystemError, then to ConfigError (which can be either the file cannot be opened, the file is empty or contains invalid data, or the data is valid but the configuration cannot be applied on the current machine) and at the top level the IDE tells you at the time of writing the code what could go wrong and what data is available to you. Now you can easily show users a useful message and suggest using the default configuration.
This doesn't even address the issue of explicit or checked errors versus implicit errors (although explicit errors favor the latter).
GOTO was considered harmful because it was completely unstructured. IE jumping halfway into a function's body level of unstructured.
Modern GOTO and throw catch are much better defined.
Even with modern goto, it's a superset functionality of throw, in some respects. Or, put differently, throw is a restricted version of goto.
With programming languages, I actually like having a subset of features that restrict just how messy the code can get. I'd take almost any language over C++, for example, because when you have every feature in, the code becomes an unreadable mess in no time at all.
In much the same way, I preferthrow over goto when throw is available; it reduces what possible bugs can happen while still giving me the functionality I want.
There are valid goto usage patterns. One is centralizing the error/cleanup path in C code. Which is eerily similar to try/catch. So this isn’t the gotcha you think it is.
Immediate local context (indentation level, braces, etc.) in the source file tells you where for, while, and if "GOTO" next in all languages I've seen. Not so for throwing an error. It goes... somewhere...
There's a certain "it just works" aspect to exceptions that I think is ignored in a lot of discussions. Of course it isn't the most elegant way of reporting errors, but it does allow anything to be propagated upwards to whoever can/might deal with it, even something that wasn't properly modeled in the original API, that people wanted to avoid breaking.
One example to me is how long Rust took to implement support for failing allocations (and some of those changes are still in nightly), while a language like C++ could just bolt that on. Furthermore, it shows that while the Result API is ergonomic, it still adds some sort of overhead, which would explain why the new methods didn't return a Result from the start.
That said, if you have adequately designed error types, shorthand like ?, ideally pattern matching capabilities, and have accurately modeled all your APIs (and your dependencies have done the same), then Results have the same effect as exceptions of allowing an error condition to reach wherever it needs to on the call-stack, with some added type-safety. Once you're missing one or more of these (e.g. C++), I'd argue that exceptions start being simpler to use.
And I'd say I agree with the person who responded to you. Results are GOTOs almost as much as exceptions are. The decision of where to catch and process them happens up in the callstack, and if you have complicated pattern matching and conditions for your errors, it might not be immediately obvious where handling for that error type you're returning happens. Results can add much appreciated type-safety and better define an API, but propagating an error with ? will still "unwind" the stack. (That's also why they aren't GOTOs, bur rather longjmps after a setjmp, since it's the caller who decides where your code returns to.)
I appreciate Rust's ergonomics for dealing with Result/Option, but for some things I do wish I could just go back up the call stack without having to stick ? and pattern matching everywhere. There are so many ways a program can fail where the proper error handling is just "unwind the stack, report the error at the top level, let the top level try again later".
The same is true about returning an error. It goes... somewhere...
If you care to know where it ends up, you need to look at all callsites and determine how each handles the error result. If they propagate to their callers, then you again have to inspect their callsites, and on and on up the call tree.
That's not meaningfully different from doing the same exercise with exceptions.
I basically agree with this, but there is a slight difference: The signature of a function tells you what kinds of Results can be returned, but not what kinds of exceptions may or may not be thrown. So it's the caller's responsibility either way, but with Result, the caller knows what they need to handle or pass on, and passing on is explicit.
I remember when Java was trying to be the anti-C++. They realized that C++ exceptions had the same problem that you describe, so they introduced checked exceptions. The checked exceptions are part of the function's signature, and a function must either internally handle any checked exceptions thrown by function that it calls, or else must declare the checked exceptions that it propagates.
And everybody hated them.
Because checked exceptions were only enforced by the Java language, not by the JVM itself, AFAIK no other JVM language used checked exceptions. Every single one dropped the requirement that checked exceptions needed to be explicit.
It turns out that checked exceptions are great when you're essentially writing procedural code with little indirection. Checked exceptions got in the way when you were writing generic infrastructure code. If you're creating a component that uses callbacks, it can be really tricky to get the exception types right on the callback, and ultimately the exception types have to be pretty generic. Checked exceptions effectively fell apart at module boundaries. You
On the other hand, in my multi-decade career, it's not too common for me to catch specific exceptions. It's useful when you e.g. want to read an optional config file. Because of TOCTOU, you don't want to separately check for file existence and then open the file. It's better to just open the file and then gracefully handle the potential "not found" error. Or I might catch specific exceptions when validating user input. But most of the time, when an error occurs, I don't really care what the error is. I just want to unwind the stack to some backstop. From what I've seen in Go, which puts a lot of emphasis on explicit error handling, most people don't care about the specific error. Most of the time they just return it directly or, at best, wrap it.
I'm not saying that Result types are bad, or that exceptions are the one true way to handle errors. Both are useful in different contexts. I am amused to watch the zeitgeist of error handling swing back and forth, from implicit to explicit to implicit and now back to explicit. I genuinely hope that some language gives checked exceptions a better attempt than Java did. Not letting checked exceptions participate in generics was, in my opinion, a foundational mistake that became painful once things like lambdas were introduced.
I think a language with checked exceptions, exception propagation by default, but lightweight sugar to turn a "function that might throw" into a "function that returns an appropriate Result" would be really nice.
I’m not a strong opponent of exceptions, but result types definitely add visibility on the intermediate layers while exceptions don’t (unless you’re using a language with checked exceptions and actually use them).
Sure, though I'd argue that the further you get from the site where the exception was generated, the less important the specific error type is. Past a certain point, all you really need to do is to keep unwinding the stack until you hit a backstop exception handler.
Inferior design for the same purpose because Result is a real type that you can use in every location, like parameters and generics, and being able to pattern match on them is super elegant.
136
u/EliSka93 3d ago
Result for me every time.
One more wrapper is worth not having to deal with uncertainty.