5 ms·
> Their idiomatic error passing is via try/catch, which is the best error handling pattern we've figured out so far, ... I find monadic error handling (`Result
by maxbond 2y ago
> Their idiomatic error passing is via try/catch, which is the best error handling pattern we've figured out so far, ...
I find monadic error handling (`Result<T, E>` and `Option<T>`, or `Either<T, U>` and `Maybe<T>` if you prefer Haskell) to be much better at keeping error handling logic local to where errors originate, and making it explicit which operations are fallible and what errors they produce. Exceptions tend to be nonlocal and difficult to determine what exceptions can occur from where.
That sugar for getting an optional/nullable value with `try?` definitely looks nice, I wish Python had something like that, but it seems to me that the reason it works better is that it's closer to monadic error handling (you get an `Option<T>`).
Has your experience been different?
- happytoexplain 2y ago>making it explicit which operations are fallible and what errors they produce I should specify that throwability needs to be part of the function signature to be useful in the language. As for what types of errors: At minimum they should provide something you can show a user, maybe after adding context, and the machine-readable codes should be documented, both of which are points of failure, but the ability is there. Interestingly, Swift is getting typed exceptions, which seems too good to be true. I'm not sure how it works when passing exceptions up the call chain. As for Optional/Maybe, as I replied to a sibling, that's not really comparable. It's not for error-handling because it doesn't provide an error. It's useful for when something might not exist, but not so much for when some input is invalid, etc, since it's more verbose to explicitly fork on null and return early. As for Result<T,E>, it has a subset of the same problems: Can't pass it up without writing a fork/switch unless it's just a wrapper function that returns Result<T,E>. And it makes things less composable or require extra forks when something is expecting T. But, like Optional, it definitely has uses: Like if you're using a callback system for an async function, where throwing is impossible.
- maxbond 2y agoI agree that exceptions are much more tolerable when they're part of the method signature. Otherwise you have to dig through the code to figure them out, and that's a mess. You can think of `Optional<T>` as being `Result<T, null>` - it's a fallible operation where there is either one obvious way it can fail or where we don't care to distinguish between them. If you look a key up in a map, that's a fallible operation, but there's only one way for it to fail - the key wasn't present in the dictionary. It's definitely error handling. `Result` really comes into it's own when combined with pattern matching (`switch` with destructuring and exhaustiveness, so compilation will fail if you've neglected to handle a certain error case (!!!)). Rust adds syntactic sugar and other features that make this very ergonomic. Eg, there's a `?` operator that will return early if there is an error, and traits (interfaces) that make it seamless to convert one error into another, add error messages, etc. > it makes things less composable Not so! They're very composable, eg: let x = my_fallible_operation().map(|v| do_something_on_success(v)).or(other_fallible_operation_to_compose_with()) If `my_fallible_operation()` success, `x` will be `Ok(do_something_on_success(v))`; otherwise, it will be the return value of `other_fallible_operation_to_compose_with()` (itself a result).
- vlovich123 2y ago> As for Result<T,E>, it has a subset of the same problems: Can't pass it up without writing a fork/switch unless it's just a wrapper function that returns Result<T,E>. And it makes things less composable or require extra forks when something is expecting T. But, like Optional, it definitely has uses: Like if you're using a callback system for an async function, where throwing is impossible. Are you saying that Result<T, E1> doesn't convert to Result<T, E2> automatically? Cause I've had great success implementing `From<E1> for E2` and forwarding the error via `?`. There's also map and unwrap helpers to convert. If you're saying that Result<T, E> is incompatible with a return type of T, that's often seen as a benefit because they are incompatible and you have to choose how to bridge that gap by for example unwrapping because you don't expect an error (equivalent to not specifying a catch and letting the thread terminate) or somehow else doing something with that error (specifying a catch handler). What it doesn't let you do is ignore the failure case silently and implicitly.
- lolinder 2y ago> Exceptions tend to be nonlocal and difficult to determine what exceptions can occur from where. This is true statically (mitigated by checked exceptions in Java, but that's a whole can of worms on its own), but the opposite is true when you actually have an error at runtime—exceptions come bundled with stack traces which make it very easy to see the stack of function calls that get us into the failure state, where with result types you at best have a (hopefully descriptive) error message and that's it. If the code has already been well polished then that error message is probably enough, but if you're still ironing out rough edges the error message itself may be in error and the actual problem may be somewhere else entirely. With a stack trace you can easily see the function calls that got into that unexpected state, but without them you're on your own and have to try to recreate the error while stepping through in the debugger.
- maxbond 2y agoBut with a `Result` type, I'm forced to deal with each error case at compile time, so I avoid finding out about an unexpected edge case at runtime in the first place! (My perspective is Rust centric, and if you eg unwrap an error because you mistakenly thought it shouldn't be reachable, you end up with a panic which is just an exception.)
- kaba0 2y agoThe exact same way as in case of a checked exception.
- lolinder 2y ago> so I avoid finding out about an unexpected edge case at runtime in the first place! This has not been my experience with Rust. In my experience I'll handle all the errors just fine—nothing is unexpectedly crashing—but then every once in a while an error object will propagate up and I'll have no clue why that error got triggered by that input. If I'm lucky I'm working on something that makes dropping into the debugger easy, but other times this is the beginning of a very long troubleshooting session that would have been much shorter if I had a stack trace. Mind you, this always does turn out to be a mistake in my code that caused an error to get returned incorrectly, but Rust doesn't help me find and fix these problems the way that Java does. (My personal opinion is that Java's checked exceptions were on the right track and just need some UX improvements and syntactic sugar for handling them. They provide all the safety of a result type with all the debugability of an exception.)
- ninkendo 2y agoSwift’s `try` isn’t “real” exceptions. It’s basically syntax sugar around a Result type. You can convert them trivially: func someThrowingFunc() throws -> T let someResult = Result(catching: { try someThrowingFunc() }) // someResult: Result<T, any Error> Or the other way: let someResultFunc() -> Result<T, any Error> let t = try someResultFunc().get() // t: T The reason I say they’re not “real” exceptions is that it compiles down to a branch that checks if a particular error return register is non-nil, same as you would with a result type. (As opposed to “real” exceptions like in C++ where execution is forcefully interrupted by an expensive exception handler.) I started out in swift treating it like rust and using Result everywhere, and manually chaining results back up when I didn’t want to handle them, but after a while I realized that the `throws` and `try` statements were just way more readable syntax to accomplish the same thing I was already doing: Want to “not care” about the thrown error? Use `try?`. Want to propagate it up? Use `try` and just mark your function as throws rather than explicitly returning a Result. Want to crash on an error? Use `try!`. Want to explicitly deal with the error? Use `catch`, or if you don’t like that syntax and you’d prefer matching on a success/error enum you can just convert it to a Result as I outlined above.
- Aeolun 2y agoNonlocality of exceptions is a feature to me. I don’t want error handling logic all throughout my applications, I want it only in the contexts I care about failure.
- maxbond 2y agoWell, we want the same thing, I guess we have opposite ideas about how to achieve it.
- slaymaker1907 2y agoYou're assuming that error handling logic should be local, but I don't think that's ideal. Most of the time, I really do just want things to bail out if something goes really wrong and log what happened. This is because what often happens is that the real error occurred a while back and so the code expected to handle the error has no idea on how to do that (like passing in a mangled URL).
- maxbond 2y agoProbably most of the time you just want to crash, but that's a pretty easy requirement to satisfy. I don't think either approach has a particular advantage there.
- kaba0 2y agoI never really understood this “exceptions are non-local” stuff. They are basically the same as the sum-typed approach, with sane defaults. They are just “early” returns, that go to the callee, and either go to a local handler logic, or just early return from there as well. As for sum types, it is definitely not good practice, but I would say that if you fail to handle an error condition close to its source, and it managed to get to some “global” scope, determining where it originates is very hard. Similar to that illegal database row that somehow managed to get entered, resulting in a difficult investigation.
- maxbond 2y agoThe try/catch is basically a different stack, and a single try/catch frame may be many stack frames. I've found that for complex stuff I have to walk up and down my abstractions to understand how exceptions are flowing, and that it isn't easy. I think it's fundamentally less local to do a conditional jump elsewhere in the code (even if doesn't cross into a different frame) than to return a value and step to the next line, rain or shine. I don't disagree that there's an isomorphism between the approaches (since they're both addressing the same need, surely there should be), but exceptions introduce a new set of rules whereas monadic error handling repurposes the familiar semantics of function calls. But you're not the first person to mention your second point, so I totally concede that, regardless of whether it is "bad practice," empirically it's a weakness of the approach.