5 ms·
What do you think of languages that use sum types for error handling, but can still unwind in a few scenarios? Reasonnable compromise, or should we get rid of
by tux3 5y ago
What do you think of languages that use sum types for error handling, but can still unwind in a few scenarios?
Reasonnable compromise, or should we get rid of all unwinding always? And if so, do we abort() or do we ask users to handle any and all possible errors.
- WalterBright 5y agoI've read the proposals for it. It certainly looks good, yet exception handling looked good 30 years ago, too. I haven't used sum types myself, and often it takes years to discern whether things are really good ideas or not. What I personally use is the "poisoning" technique. This involves marking an object as being in an error state, much like a floating point value can be in a NaN state. Any operation on a poisoned object produces another poisoned object, until eventually this is dealt with at some point in the program. I've had satisfactory success with this technique. It does have a lot of parallels with the sum type method.
- winstonewert 5y agoSo that sounds like the way invalid floating point operations give NaN, and then the NaN propagates everywhere. I've always found this super annoying because its often hard to figure out where the NaN comes from. Does your solution differ from this in a way that's less annoying?
- WalterBright 5y agoThe FPU does not include the source in the NaN, but that doesn't mean your own objects can't. What I do is have the error reported at the source, and then return the poisoned object. A better way would possibly be put the error message in the poisoned object, and report the error somewhere up the call stack.
- erik_seaberg 5y agoWhat are the return values from a poisoned object’s methods? Does a poisoned vector have a poisoned integer as its size?
- WalterBright 5y ago> What are the return values from a poisoned object’s methods? That's up to you. You can do it as: 1. return a poisoned value 2. return a safe value, like `0` for its size 3. treat it as a programming bug, and assert fail 4. I know `null` is hated, but it is the ultimate poisoned value. Try to call a method on it, and you are rewarded with a seg fault, which can be considered an assert fail. 5. design your poisoned object to be a real object, with working methods and all. It can be the same as the object's default initialized state. In other words, it's necessary to think about what the poisoned state means for your use case. I use all those methods as appropriate.
- Gibbon1 5y agoI do that a lot with the firmware I write in C. typedef struct { err_t error; int error_line; char *error_msg; ... ... } thing_t; // set out of range error thing->error = THING_ERROR_OOR; thing->error_line = __LINE__; thing->error_msg = "outofrange" You can grep on 'outofrange' and find where the error was set. I originally started doing that to mark 'bad' analog readings in process control equipment. I wrote my filters and control loops to be able to 'eat' occasional bad readings without barfing. Worked very well.
- Const-me 5y agoI often using something like this in C++ if( nullptr != pfnErrorSink ) pfnErrorSink( "outofrange", __FILE__, __LINE__ ); return E_BOUNDS; // Or sometimes throw E_BOUNDS; Where pfnErrorSink is either global, thread_local or a field keeping C function pointer provided by whoever consumes the code.
- lerno 5y agoI'm experimenting with a solution in C3 that has this behaviour. I don't have a sum type as such, but the binding acts as one. I call the binding a "failable". int! a = getMayError(); // a is now either an int, or contains an error value. // foo(a) is only conditionally invoked. int! b = foo(a); // The above works as if it was written: // int! b = "if a has error" ? "the error of a" : foo("real value of a"); // A single if-catch with return will implicitly unwrap: if (catch err = b) { /* handle errors */ return; } /* b is unwrapped implicitly from here on and is treated as int */ if (try int x = a) { /* conditionally execute if a isn't an error */ } I think this is kind of formalizing the poison technique but external from the call (that is, "foo" does not need to know about "failables" or return one, the call is skipped on the caller side). Here are some more examples: http://www.c3-lang.org/errorhandling/ http://www.c3-lang.org/errorhandling/ I'd be interested in hearing what you think about this (experimental) solution Walter.
- mananaysiempre 5y agoSo you’ve built in monadic bind for the Either monad into the language: Right x >>= f = Right (f x) -- normal case Left y >>= f = Left y -- error propagation case (The slogan is “monadic bind is an overload for the semicolon”.) I don’t expect this knowledge will dramatically change what you’re doing, but now that you know that’s how some people call it you have one more place to steal ideas from :)
- lerno 5y agoNo I'm quite aware of this. It's a restricted, implicit variant of it. But not also that it's not the type but the binding, which makes it slightly different from using a `Result`.
- mananaysiempre 5y agoHm. OK. I tried writing a response several times but I still feel confused. Can you explain what you mean by “not the type but the binding”? Note that I know the Haskell but not the Rust (guessing from the “Result” name) way of working in this style. (Not necessarily relevant or correct thoughts: - Your language still seems to mark potentially-failed values in the type system, even if it writes them T! not Either Error T or Result<Error, T>; - The way Haskell’s do-notation [apparently implemented as a macro package in Rust] is centred around name binding seems very close to what you’re doing, although it [being monadic, not applicative] insists on sequencing everything, so fails the whole block immediately once an error value occurs; - Of course, transparently morphing a T-or-error into a T after a check for an error either needs to be built into the language or requires a much stronger type system; Haskell circumvents this by saying that x <- ... either gives you a genuine T or returns failure immediately, which is indeed not quite what you’re doing.)
- c-cube 5y agoYou should give a serious try to sum types, btw. They're unambiguously good, and have been in use for the last 40 years at least. To me, not having them is an immediate disqualifier for a modern static language (along with some basic form of pattern matching that goes hand in hand with them).
- oconnor663 5y agoHow does this work with operations that return basic types like int or string? Can a string be poisoned?
- mbrubeck 5y agoRust is one such language. I wrote a bit about exception safety in Rust here: https://users.rust-lang.org/t/c-pitfalls-hard-to-avoid-that-are-elegantly-managed-in-rust/38006/16 https://users.rust-lang.org/t/c-pitfalls-hard-to-avoid-that-... In short, while the problem is mitigated somewhat compared to C++, it's still one of the most common causes of bugs in unsafe Rust code. Rust programs can choose to abort on all panics, rather than unwind. Firefox does this, for example.
- volta83 5y agoWe do this on all our Rust code as well (1.1 million LOC at this point). While we can benefit from #[no_std] crates on crates.io, unfortunately we can't use any crates that require standard because the standard library does not propagate errors properly, so we maintain our own implementation of most of the standard library for Linux only, that propagates all errors using Result.. It's a huge pain point, but at least Rust allows us to do it.
- oconnor663 5y ago> does not propagate errors properly Is "panicking on allocation failure" the only example of this, or are there others?
- AndyKelley 5y agoNow that I've been using Zig's error handling language primitives for some time, I've come to realize what the paradigm really is: a way to encode a "forwards" and a "backwards" at the same time, for the same block of code. The usual way control flow progresses is forwards, but when an error occurs, it goes backwards, over the defers and errdefers. C++ exception handling and other languages with destructors force you to do this declaratively, but then don't give enough control over exactly the situations they matter in: setup and teardown. Meanwhile with explicit control, you just encode exactly what happens in the "backwards" control flow. No surprises, no trying to figure out what happens based on declarative rules. Once I figured this out, I was able to use it to simplify the logic of some things in the self-hosted compiler that are extremely error prone in the C++ implementation: * Lazy source locations: passing in "none" for a source location, and then handling the "error.SourceLocationNeeded" and then doing the expensive calculations to find the source locations before retrying the operation. * Generic instantiations: returning "error.GenericPoison" for when a type parameter cannot be determined without information from the callsite. In this case, the analysis is cleanly aborted and function marked as generic. I'm pleased with how this turned out, and I've started to think of other languages in terms of how they map to this "forwards" and "backwards" control flow concept.