13 ms·
Ok, so the original idea of Result<T, Error> was that you have to consider and handle the error at each place. But then people realised that 99% of the time yo
by ekimekim 2y ago
Ok, so the original idea of Result<T, Error> was that you have to consider and handle the error at each place.
But then people realised that 99% of the time you just want to handle the error by passing it upwards, and so ? was invented.
But then people realised that this loses context of where the error occured, so now we're inventing call stacks.
So it seems that what people actually want is errors that by default get transferred to their caller and by default show the call stack where they occured. And we have a name for that...exceptions.
It seems that what we're converging towards is really not all that different from checked exceptions, just where the error type is an enum of possible errors (which can be non-exhaustive) instead of a list of possible exception types (which IIUC was the main problem with java's checked exceptions).
- tux3 2y agoIt does seem to be converging somewhere, but a major difference that I really like is pushing humans a little more to care about errors, instead of just letting whatever bubble up from wherever until a catch(...) somewhere. With checked exceptions, it's very common for the user to end up with only a cryptic message from a leaf function deep inside something, and that's very hard to interpret. Having a manual stack of meaningful messages that add context is so nice as a user. Even if I do get the stacktrace in a program that threw a deep exception, you typically won't understand anything as a user without access to the code, the stack trace for exceptions is just not meant for human consumption.
- shepmaster 2y ago> pushing humans a little more to care about errors This is 100% a reason that I like using SNAFU. The term I use for this is a "semantic stack trace" — a lot of the time, the person experiencing the error doesn't care that it occurred in "foo.rs" or "fn bar()" or "line 123". Instead, they care what the program is trying to do ("open the configuration file", "download the update file"). When I'm putting effort into my errors, I basically never use `snafu::Location` or `snafu::Backtrace`. My error stacks should always be unique — any stack can exactly point to a trace through my program.
- jcelerier 2y agoBut... It's not the user that is seeing this, it's the developer. You catch at the top of your event loop and you log the stack trace to some place that can be reached by the dev team, be it Jira, some crash reporting tool, etc.
- jandrese 2y agoYeah, but lots of diagnostic work is done by end users in the real world. Users rarely have good access to the developer team, if the team even still exists. Usually there are layers of insulation that mean your problem might be looked at in a few weeks or months only if the company thinks it might be interesting. Meanwhile you have your problem to fix and it is off to stack traces and access logs to try to figure out what went wrong. Maybe some library updated. Maybe there was a permissions change. Maybe some policy change at the OS level. Maybe some external resource went away or changed syntax. It is up to you as the end user to figure it out and fix it, or at least figure out a unique enough error message that you can Google to find someone else with the same problem. There is nothing more frustrating than a dialog box that says "An error occurred" and then the program shuts down. Frankly I'd rather it crashed hard, at least then I might have some evidence to sift through in the blast zone.
- Groxx 2y ago>Yeah, but lots of diagnostic work is done by end users in the real world. Users rarely have good access to the developer team, if the team even still exists. And hiding details prevents them from being able to know if error X is different from error Y, yes. It's an unhandled error at that point. You do not know what is relevant, essentially by definition, because otherwise you would have handled it. Display messages are almost completely unrelated to error handling, and have almost completely unrelated needs. If you decide to combine them, I'm pretty convinced that it's ALWAYS better to show ALL context somewhere, because otherwise troubleshooting frequently becomes impossible. It doesn't have to be a megabyte of stack trace info in a dialog box shown all the time, save it to a file and link to it or something.
- 2y ago
- kibwen 2y ago> show the call stack where they occured. And we have a name for that...exceptions. Getting a stack trace isn't a distinguishing feature of exceptions; stack traces predate the notion of exceptions. The distinguishing feature of exceptions is that they're a parallel return path all the way back up to `main` that you can ignore if you don't care to handle the error, or intercept at any level if you do. For some contexts I think this is fine (scripting languages), and for other contexts I think that being forced to acknowledge errors in the main return path is preferable.
- danenania 2y agoI think a lot of it is psychological. Being forced to ask yourself "what do I want to happen if there's an error here?" every single time seems to go a very long way. If the answer is "ignore it" or "bubble it up" then fine, but at least you considered and explicitly answered that question rather than totally forgetting that an unhappy path exists. Default consider vs. default ignore.
- ekimekim 2y agoThat's interesting. To me stack traces + default pass up the stack are the distinguishing features of exceptions. Suppose we had a version of the ? operator that automatically appended a call stack to the error value returned. Are you saying that that's not "an exception" because I still need to write ? after each falliable function? Or because it's still part of the return type? Or is it specifically only an exception if it works via stack unwinding?
- kibwen 2y agoIf we're making a distinction between "exceptions" and "errors as return values", then that implies that exceptions are not return values. And so the question to ask to identify each one is: is the error treated the same as a returned value would be? IOW, if it shows up in the usual return type location in a function signature, and if calling this function plops the value into my lap the same as it would for any other value, then it's errors-as-values. Whether or not stack unwinding is used and whether or not a stack trace is provided is an implementation detail. Note that C++ certainly has exceptions, and yet getting a stack trace from them is nontrivial.
- jgilias 2y agoYes and no. When a language has exceptions the code is perpetually wrapped in a fallible computational context. When the Result is reified as a type, you have the option (ha!) to write code that the type system guarantees won’t fail. This is nice. Let’s not talk about panics, shall we?
- zokier 2y agoThat's not particularly novel observation; people have been pointing out the equivalence between checked exceptions and Result types for pretty much forever. See for example this thread from decade ago: https://news.ycombinator.com/item?id=9545647 https://news.ycombinator.com/item?id=9545647
- anon-3988 2y agoI have a theory that what people actually want is something ala named exceptions + forced try catch with pattern matching + automaitally derived return Type.
- deleted 2y ago[deleted]
- PittleyDunkin 2y ago> But then people realised that 99% of the time you just want to handle the error by passing it upwards This seems like a gross exaggeration > So it seems that what people actually want is errors that by default get transferred to their caller Hell no
- IshKebab 2y ago> So it seems that what people actually want is errors that by default get transferred to their caller and by default show the call stack where they occured. And we have a name for that...exceptions. You've drawn the wrong conclusion - we don't want that by default. We want to chose. In most cases we'll just return the error to the caller, but we don't want it to be the default so we can miss critical points where we didn't want to do that.
- ragnese 2y agoYou're not far off. This is one of my favorite topics in programming language design discussions, and I have opinions that some may even say are "controversial". For what it's worth, I've been writing Rust in production since 2016 (not 100% of my time since then, but I've had a good amount of experience with some decently long-lived projects of varying complexity). First, I assert that Java's checked exceptions are a solidly good feature. Of course it has flaws. The whole rest of the language is also full of flaws, so that's not surprising. Second, I assert that there are two things that have caused the vast majority of hate toward Java's checked exceptions: programmers not being taught/shown how and when they're intended to be used, and that oft-circulated interview transcript from 2003 where Anders Hejlsberg asserts that checked exceptions are language design "dead end". I don't think he was right in 2003, and I especially don't think the opinion is correct today in light of how much strong static typing has really gained favor with the programming community. But, that opinion really took off and we spent years and years seeing that assessment repeated as a truism, which I think is why it took so long to finally start experimenting with statically typed failure modes again (e.g., Rust and Swift). Now, here's where I'll get controversial about Rust error handling. I'll try really hard to keep this from turning into an entire dissertation, but I'll elaborate if anyone asks. It is often a mistake to implement the `From` trait for error types and use the `?` operator everywhere. Error types in an API need to be aware of the context in which they occur, so just converting by type only often doesn't make sense. You may encounter a `FooError` type while your app is doing totally different things, so it's likely that not every `FooError` occurrence means the same thing to whoever is calling into your code. Also, sometimes you can actually handle an error, and getting into the muscle memory habit of just tacking `?` on to everything can lead to mistakenly propagating errors that you might have better handled by doing something else (including perhaps panicking). There does seem to be a trend toward automatically adding stack traces in Rust errors. This is completely misguided, IMO. And this may be my MOST controversial opinion: stack traces almost *never* belong in a `Result<>` error type. Result types should be relevant to your "domain" (borrowing the term from "Domain Driven Design" even though I do NOT advocate for DDD in general). Think about it this way: designing an API is about abstraction. So if you write a integer division function that takes two arguments and divides them, it might return `Result<i64, DivideByZero>`. If the caller passes in a 0 divisor, then what business is it of theirs to see what your private functions are called, how many of them are called, and what line of your file they were defined on? That's the leakiest of leaky abstractions. You might be thinking: "But, if I see an result/error value that I didn't expect while running my program, the stack trace will help me track down the issue!" Yeah, no kidding. So, let's also start adding stack traces to our successful values, too! After, all, if I call my division function and get back a `Result::Ok` with a weird number that I didn't expect, I might want to trace that back, too, right? (This suggestion is sarcastic to prove a point. It should, hopefully, sound ridiculous to add stack traces to every return value from every function.) The issue is that Rust's Result (and Java's checked exceptions) require a different paradigm. A Result is in the type signature because it's part of your domain's API design. It's just values. It's not *for* debugging. You use a debugger for that or programmatically panic when something is truly unexpected and get the stack trace from that. Which leads to the corollary to the previous controversial opinion: Rust has unchecked exceptions; they're called panics and they are 100% *okay to use* in the vast majority of applications that the vast majority of day-job programmers work on. Obviously, context matters, and there are some places where panicking is unacceptable. But, Result is for expected domain failures. Panics are for programmer errors and unrecoverable constraint violations. And I'm not advocating for panics to be "lazy". Rust code that refuses to ever panic (as far as they know, but I hope they aren't indexing any vecs/arrays just in case!) usually leads to overly polluted error types where it ends up being difficult to understand what errors are actually meaningful and what errors are never actually going to happen. Instead of inspecting errors and figuring out which to handle and how, I've seen things just snowball into a giant mess of nested enums with sometimes redundant error "branches" and missed opportunities to actually handle some cases. If you, as the programmer, know for sure that you just added something to a HashMap earlier in your function and you know you didn't remove it, then for the love of all things sacred, just write `map.get("my-key").unwrap()` (or `.expect("message")`--whatever) instead of making the caller have to consider an error that will never happen, is not their fault, and that they can't do anything about! And, if you do have a situation where panicking is unacceptable (you must be using `#![no_std]`, right??), then don't make a bunch of different error types for all of the possible programmer bugs. Just make a single umbrella `FatalError` type and use that. For further reading, I really like this piece from the book Real World OCaml, which also has a Result type and exceptions: https://dev.realworldocaml.org/error-handling.html https://dev.realworldocaml.org/error-handling.html. Specifically, the very last section at the bottom of the page, titled: "Choosing an Error-Handling Strategy". (The old version of that page used to be more plain HTML and the sections had anchors so I could link directly to that section...) And for further reading about error handling strategy in a no-panic context, I really like the approach described here: https://sled.rs/errors https://sled.rs/errors
- packetlost 2y agoChecked exceptions that don't automatically propagate up the call stack to be specific. There's a subtle but incredibly important difference between just "exceptions" and what you're describing.
- tonyhart7 2y agoYou can use 1 type of error enum for your app for example me, Yes my code can fail and only have 1 type eg: AppError but I can supplement that with db error,cache error,serde error etc
- maxk42 2y agoThere's a critical difference between exceptions and what's happening in this article: exceptions create de facto nondeterministic behavior in programs. They cause every line in a function to potentially result in a return from the function with an unexpected type. Rust's error handling requires explicit return statements and explicit return types. This critical difference results in code that is far easier to document, reason about, and slightly better performance as well.
- kelnos 2y agoGP specifically said checked exceptions, which don't create the problems you describe. (They do create other problems, of course.) And exceptions don't have to be slower than putting errors in return values. (Having said that, I am still not a proponent of exceptions for error handling.)
- branko_d 2y ago> They cause every line in a function to potentially result in a return from the function with an unexpected type. That’s not non-deterministic. It’s just not statistically typed.
- maxk42 2y agoThis is an example of the non-determinism (in pseudocode): try { calculation1 = num1 / x; calculation2 = num2 / y; calculation3 = num3 / z; } catch (DivideByZeroError) { error("Which line failed?"); } If calculation2 was previously initialized to a default value, then how would we know if the calculation was completed before the exception was thrown without adding another 8 lines of boilerplate? This is compounded by other functions being able to throw their own exceptions. Consider: func error(msg) { display(msg); log("Error encountered: ", msg); shutdown_program(); } If both the display() and log() functions might throw IO exceptions, then how would we know whether or not the error was logged, even if the exceptions were checked, unless we create custom exception types for every possible error? In conclusion, we don't know with certainty which path was taken through the code's execution, and this is tantamount to non-deterministic behavior.
- ljm 2y agoGo's approach has been to treat errors as a linked list, and thus one would explicitly create a chain of errors by wrapping each one as it passes up the stack. The end result would be an error like 'Error Z: Error Y: Error X', as each error in the list is 'unwrapped'. The lack of any kind of caller information when creating an error makes it quite important to write decent error messages, which I think is actually quite hard to do. At the same time I think it depends on what you're building: a library should have good errors (ideally well-typed ones too), but in an application you'd benefit from adding logging at each point in the stack (which can then contain caller information like file and line number) rather than just doing the logging at a system boundary; maybe set it at debug level. Then use tracing for the rest of it (for extra visibility in stuff like Sentry). At least, I feel like that's how you'd be encouraged to do it in Go considering the opinions of Go's creators.
- Groxx 2y agoJava's main issue is that its `throws` isn't generic. It forces middleware-like code to choose between `throws Exception` and runtime-only plus boxing... both of which lose ALL details and ruin your compile-time safety. IMO it just poisoned the well, and now everyone* thinks they don't like checked exceptions, when really they just don't like Java's badly crippled version.
- ragnese 2y agoYou can have generic `throws` markers; e.g., interface Frobinicator<E extends Exception> { void frobinicate() throws E; }
- Groxx 2y agoWhich gives you a single exception type, not a list. Squashing the list of possibilities rather uselessly. You can work around this with N `T extends Exception`s, but now you have to pick the correct one all the time. And e.g. using it in a `map`-style stream with a final collected throw means picking whether you're adding type N or not. Or possibly multiple new types. It rapidly grows to be unusable. You also can't make a `class MyException<T>`. Or do a `catch (T e)`. There are a lot of blockages in practice to trying to do any of this - exceptions are very special in the type system, which is the problem.
- ragnese 2y agoYou definitely won't find me defending Java too often. And I certainly agree that there are frustrating limitations. Like you said, it's annoying that Java does have ad-hoc union types, but only for the throws list in function signatures and for the type specification in catch blocks. So, it's definitely painful that you can't use a similar syntax when implementing something like the generic interface example I wrote. > You also can't make a `class MyException<T>`. Or do a `catch (T e)`. There are a lot of blockages in practice to trying to do any of this - exceptions are very special in the type system, which is the problem. Agreed. But, my entire contention with the discussion around checked exceptions is that everyone found some sharp edges and limitations with Java's checked exceptions and instead of deciding that Java suck{ed,s}, everyone seemed to decide that checked exceptions suck. That was the wrong conclusion, IMO, and I truly believe it has slowed progress in programming language design. It's only recently that statically typed failure modes are becoming mainstream again (e.g., Rust, Swift, and many third-party libraries for languages like TypeScript and Kotlin among others). Speaking of streams and combinators like map, Swift has the `rethrows` keyword which is absolutely awesome, IMO. It's this kind of progress that I think we've missed out on from everyone rejecting checked exceptions as a concept for the last decade or so. We threw the baby out with the bathwater.
- kelnos 2y agoI get what you're saying, but this is still very different from (checked) exceptions, both in syntax and ergonomics. Java's checked exceptions are the worst. Having to declare every exception thrown as a part of your API/ABI makes for brittle, difficult-to-evolve interfaces. Rust's Result and '?' syntax sidesteps a few of these issues. You can "add" underlying errors to the error return of your function without changing its API/ABI. You don't need to add a bunch of try/catch blocks, cluttering and confusing the code, in order to make sense of this and convert exceptions into whatever your API/ABI specifies. Rust's 'From<>' trait is damn-near magical when it comes to error conversion and propagation. I get that not everyone is a functional programming enthusiast, but you can't do FP with exceptions. (Well, you can, via a sort of Try monad like Scala has, but it's error-prone and ugly to deal with.) With Result, you can, and it works seamlessly with the rest of the language and syntax. I don't think Rust's error model is perfect, but it's miles ahead of what I've worked with in most other languages.
- ragnese 2y agoI generally disagree with you. I think that Result/Try types are essentially isomorphic to checked exceptions. > Java's checked exceptions are the worst. Having to declare every exception thrown as a part of your API/ABI makes for brittle, difficult-to-evolve interfaces. How is this different, in practice, from how it's done in Rust? You have to evolve your Result error type as well. The exact same concerns exist for both. The difference is that you actually have more choice/freedom with Java: you can choose to wrap all of your API's checked exceptions under one base type (analogous to defining a single error type for Result in Rust) so your function throws a single exception type, or you can have your function signature use an ad-hoc union type of several exception types without the boilerplate of wrapping them in a new type. In fact, many people have requested ad-hoc union types in Rust for a long time, because it's so painful to choose between all of your functions returning the same umbrella error type even though it only truly needs a subset of it vs. defining new mostly-redundant error types for each function in your API. > Rust's Result and '?' syntax sidesteps a few of these issues. You can "add" underlying errors to the error return of your function without changing its API/ABI. You don't need to add a bunch of try/catch blocks, cluttering and confusing the code, in order to make sense of this and convert exceptions into whatever your API/ABI specifies. Rust's 'From<>' trait is damn-near magical when it comes to error conversion and propagation. As I mentioned above, you can certainly define a base exception type (and you probably should in many cases) in Java, too. Yes, Java's syntax is fairly verbose, but Java's syntax is verbose for almost all of the language. So, is it the checked exception mechanism that is "bad", or is it just that all of Java is verbose? My take is that checked exceptions are, overall, good, and the syntax to work with them in Java is similarly tedious as the rest of the language. Also, as a tangent, I kind of hate `From<>` in Rust. I think people lean on it way too much. It certainly makes the code shorter and "cleaner", but it also makes it harder to understand because of how implicit it is. And it causes people to miss opportunities where they actually could or should handle an error, just because the types happen to line up so that you can use `?`, instead of thinking about the actual local logic. > I get that not everyone is a functional programming enthusiast, but you can't do FP with exceptions. (Well, you can, via a sort of Try monad like Scala has, but it's error-prone and ugly to deal with.) With Result, you can, and it works seamlessly with the rest of the language and syntax. Can you elaborate on this? I feel like Scala's Try and Either are almost exactly the same as Rust's Result.
- LelouBil 2y agoThere's also a usability problem. Handling results with map, map_err and .ok is way easier to follow that the minimum 4 lines you have to add in Java to do anything about a checked exception (try {} catch {}). Explicit error handling/ignoring/passing is way better than implicit, so the direction of checked exception is good. The debate is not really checked exceptions vs Result, it's try/catch vs map_err (and friends). And will always chose the latter.
- jen20 2y agoA simple usability improvement for try..catch in Java would be to make it an expression, so initializing a variable with a fallible operation no longer requires declaring it outside, which is ugly.
- ragnese 2y agoBut, this isn't you complaining about checked exceptions vs Result. This is you complaining about Java's overall syntax style vs Rust's. Phrased another way, Java's syntax is fairly verbose for everything, not just for try-catching to handle exceptions.
- LelouBil 2y agoI don't know any language that has exceptions and also has no try/catch type syntax. > But, this isn't you complaining about checked exceptions vs Result Yes, I said so exactly > The debate is not really checked exceptions vs Result, it's try/catch vs map_err (and friends) The fundamentals are the same, you are forced to handle/discard/Buble up any error, but in my mind (and I assume a lot of other developers), the word "exception" means try/catch, even though like I said the fundamentals are the same.
- pjmlp 2y agoAddendum, CLU, Modula-3 and C++ checked exceptions, before Java got the blame.
- 3836293648 2y agoThat's just implementation details. You can absolutely do Result types with unwinding (and som auto inserted catches) and you can absolutely do exceptions with chained early returns. The relevant improvement new languages (Rust, Zig, Swift?) bring over old is making it explicit at the callsite what actions throw and how they're composed