(To be clear, there's a lot of cases when this precision isn't so useful, but the contention here is how fine grained they can be.)
Is one better than the other? I doubt that question has an objective, one-size-fits-all answer. I'm happy to see experimentation in this domain! My point really is just that Zig is different from Rust in how it handles errors, even if there are some superficial similarities.
My understanding is that something like `fn f() -> %T` returns a T or an `error`, and the latter can be literally any error value that exists anywhere in the program. It isn't restricted to the actual possible errors that might occur inside f, and thus has the same failings of Rust that you point out (don't know which subset of errors might be returned), except the unknown set of possible errors is likely to be larger.
(And, you can still get all the niceties of Rust's error handling infrastructure like the ? operator if you manually define an error type that is restricted to the exact set of possible errors: there's not a privileged type for how to represent errors.)
My reading of the release notes:
https://ziglang.org/download/0.2.0/release-notes.html
...is that error sets can be inferred (at both call-site and in the function declaration); and that inferred error-sets on return types are exact, in the sense that an inferred set will only include the actually-possible errors encountered in the call-tree.
To be fair, I don't know whether a function can declare that it may fail with a given, explicitly-declared error-set, where that error-set includes errors that the function actually cannot produce. (E.g., I say I can throw a network-error code, but I perform no network calls.) If this is permissible (i.e, the compiler doesn't complain), then that undercuts my argument a bit. :) On the other hand (again from the release notes), most functions are encouraged to simply declare their return type as !T, where the ! represents "the inferred error-set for the function," and this seems to support my claim.
For example, the following code
fn errorOrAdd(a: u8, b: u8) !u8 {
if (a == 2) return error.FirstArgumentIsTwo;
if (b == 2) return error.SecondArgumentIsTwo;
return a + b;
}
pub fn main() void {
if (errorOrAdd(0, 0)) |ok| {
} else |err| switch (err) {
// will force a compile-error to see what errors we haven't handled
}
}
emits this error at compile-time: /tmp/t.zig:13:18: error: error.SecondArgumentIsTwo not handled in switch
} else |err| switch (err) {
^
/tmp/t.zig:13:18: error: error.FirstArgumentIsTwo not handled in switch
} else |err| switch (err) {
^
You can use the global `error` type which encapsulates all other error types if needed,
replacing `errorOrAdd` in the previous `main` with the following function fn anyError() error!u8 {}
now requires an else case, since it can be any possible error. /tmp/t.zig:13:18: error: else prong required when switching on type 'error'
} else |err| switch (err) {
^
This works pretty well and is very informative in most cases. The tradeoffs
are it can make some instantiation of sub-types a bit clunky [2] and you need
to avoid the global error type everywhere. The global error infests all calling
functions and makes their error returns global as a result. You can however
catch this type and create a new specific variant so there is a way around this
for the caller at least.Do remember that Rust allows passing information in the Err variant of a Result while Zig's error codes are just that, codes with no accompanying state.
[1] https://github.com/tiehuis/zig-bn/blob/3d374cffb2536bce80453...
[2] https://github.com/tiehuis/zig-deflate/blob/bb10ee1baacae83d...
Yep, as a sibling points out, I was working with a long-ago version of Zig's error handling.
There is also the "failure" crate which has patterns for the kind of thing you mentioned:
https://boats.gitlab.io/failure/error-errorkind.html https://boats.gitlab.io/failure/custom-fail.html
https://ziglang.org/documentation/master/#Errors
https://ziglang.org/download/0.2.0/release-notes.html (see "Error Sets")
enum MyErrorSet {
NotFound(ErrorKind),
SomethingElse(ErrorKind),
}
If you don't care about propagating the original ErrorKind then that's just enum MyErrorSet {
NotFound,
SomethingElse
}I don't think the distinction is super important in application work (for example). But for systems code I can see the benefit of having the compiler tell you, with certainty, why a given function can fail (and why it can't). E.g., a good device driver should handle all its failure modes with care, rather than with a shotgun approach.
https://ziglang.org/documentation/master/#Errors
edit: when I say "full union of all possible errors", I just want to be clear that this means "all the possible errors that could occur at this call point, and no others." It's not just a bag of "all possible errors that the language/library declares to exist."
edit #2: On second thought, I guess it might be possible for a Rust tool to build these types, although I think it would require changes to the language implementation. Variadic type constructors aren't necessarily needed. Another approach would be that every (type-specialized) function returns a Result<T, ES> type, where ES is some type that implements an "ErrorSet" kind, and where ES is very possibly unique to the function (that is, no other function might return Result<ES>). For example function A() might fail due only to two cases (say, out-of-memory or disk-full, but never network-error), and function B() might fail only on disk-full. Then the ErrorSet type for A has two constructors (OOM and DiskFull), where the ErrorSet type for B has only one (DiskFull). You could then use Rust's (excellent) pattern matching to exhaustively handle all cases (i.e., the specific constructors of that ErrorSet type) at the call site.
So the tricks would be:
1. Instrument the language to tag each specialized function with its error-set; this is computed recursively by examining the failure modes in the function body, as well as the error-sets of every function it might itself call, and taking the union of those sets. (Side note: I believe that, in Zig, a type-specialized function maps to a single error-set -- i.e., the error-set doesn't depend on runtime arguments in any way. But I could be wrong about this.)
2. Generate a potentially new ErrorSet-ish type for each function, or more spefically, for each error-set that is generated using the process in step #1. (I suppose you would have to give the type a name, but maybe it could be an anonymous internal type if there is language support for that.)
3. Then you could naturally "match" on the function's return value, and know that you've covered (exactly) the possible failure modes.
But this is just armchair-quarterbacking on my part. I don't know the internals of the Zig error system, nor what changes Rust would actually need.
I haven't tried Rust, but I've seen enough of it to know I'd hate it. Where Zig has a clear advantage is that it is vastly smaller and simpler and does not enforce a memory management paradigm on you. There isn't even a default allocator.