8 ms·
I find this to be a good example where a language with well defined "foundations" can really shine. If a programming language supports union types and type inf
by valenterry 3y ago
I find this to be a good example where a language with well defined "foundations" can really shine.
If a programming language supports union types and type inference then there is no need for such a "special" language feature as described here. The return type will simply have the form "goodresult | error" and the compiler will infer it based on the function-body alone.
At the same time, the following problem of zig is automatically a non-issue:
> A serious challenge with Zig's simple approach to errors that our errors are nothing more than enum values.
If the result is "goodresult | error" then I can choose of what type "error" is and I'm not bound to any predefined types that the language forces upon me.
In fact, the article then shows how to work around the issue with a tagged union. The problem is that this creates another issue: the composition of different error types.
E.g. having function foo calling bar and baz and bar can fail with barError and baz can fail with bazError. Then, without having to do anything, the compiler should infer that foo's return type should be "goodresult | barError | bazError". This won't work if tagged unions are used like in the example - they will have to be mapped by hand all the time, creating a lot of noise.
While Zig's errorhandling is not bad and miles above Go's, it makes me sad to see that we still lack ergonomy of errorhandling in so many languages despite knowing pretty much all of the language-features that are necessary to make it much easier.
- conaclos 3y agoYou are right. This result of the modeling of tagged unions in terms of enums. Hare [0] avoids this issue by using global tags: every type has a unique global tag. This allows merging tagged unions. [o] https://harelang.org https://harelang.org
- MrJohz 3y agoWouldn't that mean that every type always has to include the type tag as an extra byte, making every type larger overall? Or is it only included if a union type is created from that type?
- conaclos 3y agoNo, the tag is only part of the union. The tag is deterministically and globally computed by the compiler. Tags are encoded in a fixed number of bytes.
- nyberg 3y agoThe issue isn't as simple as just having better error unions with payloads. Constructing the error diagnostics object needs to be taken into account and the costs it brings (e.g does it allocate? take up a lot of stack space? waste resources when one doesn't need the extra information?). Such is a design choice for the programmer not the language as only they know which approach will be more effective given they have a clear view of the surrounding context. An alternative is to allow an additional parameter which optionally points to a diagnostics object to be updated upon error. Returning an error here signals that the object contains additional information while not harming composition as much and giving the choice of where to store such information. This is arguably a better pattern in a language where resource allocation is explicit.
- valenterry 3y agoI think what you are saying is pretty orthogonal to what I was saying no?
- rootlocus 3y agoI'm not really experienced with programming language design or with compilers, but it seems to me the design of a systems programming language has to compromise on the side of performance. If the implementation of the design requires additional space or cpu time, it may not be a good fit for the language. As such, it's not orthogonal.
- amluto 3y agoThe union approach fails when, say, barError is the type of a success return somewhere. Sum types (as in Rust, for example) avoid this problem.
- valenterry 3y agoWell, yes and no. What you say also applies if two errors have the same type but you want to differentiate where it comes from. In all these cases you can wrap the result or error into something, which essentially means tagging it (but without actually needing sumtypes as a language concept. Sumtypes sure are useful, but I believe that except for GADTs, you can emulate sumtypes with uniontypes, but not vice versa.
- throwawaymaths 3y agoYou can stuff your error payload in an in-out parameter in an options tuple. This way you don't lose errdefer and if you don't want it the code for it doesn't exist (deleted at comptime)
- aldanor 3y ago"goodResult | barError | bazError" method just don't scale; eventually, especially if using third-party libraries, you may end up with dozens of not hundreds of error types this way. The way Rust does it with its try operator (?), you have to tell it if it's barError (in which case it's up to you to tell it how bazError is convertible to it), or perhaps bazError, or something else altogether (e.g. sum type like you suggested, but conversion still needs to be supplied). There's also some libraries that provide error types for the lazy that are convertible from everything, so you can use try operator without much thinking, but for libraries you almost never use them.
- valenterry 3y agoI use exactly that way of error handling and I find it scales very very well and makes refactoring a breeze. Nothing stops you from defining certain "borders" in your application where you wrap errors into something meaningful. E.g. if I write a file-IO library, I will return a custom "CannotOpenFile" error in my public methods which will encapsulate the underlying error(s) instead of returning a list of potential low-level errors had caused it. The difference is that with uniontypes it is me who decides the level of abstraction and granularity, whereas without it, I'm forced into manually handling errors types pretty much everywhere. Rust is an example where you are always forced to either align error types or where you lose information about what kind of errors it could be by using a more abstract error type. (which is the last thing you mentioned)
- foldr 3y ago> I use exactly that way of error handling and I find it scales very very well and makes refactoring a breeze. The enormous number of Rust crates that attempt to automate various aspects of defining error types suggests that not everyone finds it so pleasant.
- valenterry 3y agoPlease read my post again, especially the last part...
- martinhath 3y agoHow would errdefer work in the general union setting? Having errors as a first class construct in the language allows things like errdefer to be very simple and easy to use. It looks needlessly specialised at first, but I think it’s actually a really good design.
- valenterry 3y agoThat's a very good question! Most advanced languages have some way of defining the concept of a "computation within a context". For example, all languages that support a notion of Monads do have that kind of support. Examples would be Haskell, Scala, F#, ... In those languages there are (or would be) generally two ways of achieving the same thing as errdefer: 1.) having a common interface/tag for errors In that case, if you have a return type "success | error1 | error2" then error1 and error2 must implement a common global interface ("error") so that you can "chain" computations that have a return type of the shape of "success | error". "success | error1 | error2" would follow that shape because the type "error1 | error2" is then a subtype of "error". 2.) Having some kind of result type. This would be similar to how it works in rust or in the example in the article here. So you would have a sumtype like "Result = either success A or failure B" and the errors that are stored in the result-failure (B) would then be uniontypes. The chaining would then just be a function implemented on the result-type. This is personally what I use in Scala for my error handling. Just to make it clear, this "chaining" is not specific for error-handling but a very general concept.
- masklinn 3y ago> How would errdefer work in the general union setting? Well it could not be, and some would argue that would be better. But you could also have a blessed type, or a more general concept of "abortion" which any type could be linked to. Or you could have a different statement for failure returns that way you can distinguish a success and a failure without necessarily imposing a specific representation of those failures.
- duped 3y ago> The return type will simply have the form "goodresult | error" and the compiler will infer it based on the function-body alone. I'm 0% convinced that inferred return types are a feature that a production language should have, ever. I've used it before and it's infuriating on top of being bad code. If a function is annotated f: T -> U where U is not an error, the contract with the caller is that f does not error, and cannot be changed to produce an error, and the compiler upholds this contract for all callers and definitions or redefinitions of f. It also means that another compilation unit can refer to f without having to type check its body, which sure, is a leaky abstraction, but empowers parallel compilation. Changing the signature is a breaking change and as such should have a higher amount of friction. Changing the error type doubly so. And on top of all that, errors should not be non discriminated union types. The example is Result<int, int> - you need the error to have a discriminant to distinguish between a success and an error code.
- golergka 3y ago> Changing the signature is a breaking change and as such should have a higher amount of friction. Why? I'm all for very rigorous static type checking. But if we can make refactorings and changes frictionless — let's. Having the ability to move things around and reorder them to work better results in more iterations and better code. As long as each iteration is very rigorously type-checked, of course.
- jmull 3y ago> there is no need for such a "special" language feature as described here I think the special language feature is the point, not an unfortunate side effect of something lacking in the language design. The purpose of errors is to pass an indication of control-flow from the called function to the caller. That’s why the language has “catch”, “try”, “errdefer”, etc. This is about the language providing good flow control tools for the error path.
- valenterry 3y agoSure. My point is: if a language has certain other fundamental language features, then there is no or less need for those "specific" error features like errdefer.
- memefrog 3y agoBut that isn't true. Every language with ergonomic error handling (i.e. something more than C or Go) has special language features for handling errors. What 'fundamental language features' would replace Zig's specific language features like errdefer?
- masklinn 3y agoRAII. Or linear types. The problem errdefer solves is “resource passthrough” where a function needs to acquire a resource, do some faillible processing, and return the resource. In case the processing fails, the resource needs to be released, which an unconditional “defer” is not great for (now you need extra flags). But RAII solves the issue, in case of failure you just don’t return the resource, and it gets released. Likewise linear types, if you don’t return the resource you will have to explicitly release it. Errdefer only adds a workaround, because now every time you acquire a resource you need to question how it interacts with the call stack and whether you need to defer or errdefer it, and any change to the function requires reconsidering this question.
- valenterry 3y ago
- kosherhurricane 3y ago> If a programming language supports union types and type inference then there is no need for such a "special" language feature Laughs in Go. > While Zig's errorhandling is not bad and miles above Go's Laughs again in Go. Go has no "foundations" regarding errors, it's just a convention. It has no union types. It doesn't have weird corner cases. It's just a returned value you can handle. Or not. Of all the error handling paradigms I've seen, Go's requires the least amount of "specialized thinking" (try/catch or whatnot)--it's just becomes another variable.
- grumpyprole 3y agoGo's lack of sum types mean that there is no static check for whether the error has actually been handled or not. Go's designers went to all the trouble of having a static type system, but then failed to properly reap the benefits. Sum types are the mathematical dual of product types. It makes sense for any modern language to include them.
- kosherhurricane 3y ago> Go's lack of sum types mean that there is no static check for whether the error has actually been handled or not. I dunno, my IntelliJ calls out unhandled errors. I imagine go-vet does as well.
- grumpyprole 3y agoA simple syntactic check will only ever work as a heuristic. Heuristics don't work for all cases and can be noisy. The point is, no modern language should need such hacks. This problem was completely solved in the 70s with sum types.
- dthul 3y agoEver since I learned of sum types, they have ruined my enjoyment of programming languages which don't have them. I sorely miss them in C++ for example (and std::variant is not a worthy alternative). I don't understand why any new language wouldn't have them.
- tsimionescu 3y agoSum types are still a poor error handling strategy compared to exceptions, which actually implement the most common error handling pattern automatically for you (add context and bubble up). Having the language treat errors as regular values fails to separate these fundamentally different paths through the code. It also makes the programmer re-implement the same code pattern over and over again. This cost may be necessary in manual memory management languages, where the vast majority of functions have to do some cleanup. There, having multiple exit points from a function through exceptions (or through early return statements) makes it harder to make sure all resources are properly cleaned up (especially when the cleanup of one resource can itself throw an error). But in managed memory languages, there's just no reason to manually re-implement this pattern, either through Go style regular values or through sum types. And note that the Result monad is not a good substitute for exceptions, as it doesn't actually add the necessary context of what the code was doing when it hit an error case.
- matklad 3y agoNote that Zig supports general sum types and pattern matching. It however deliberately chooses not to use those features for idiomatic error handling.