4 ms·
This feels a bit like Zig's error handling which seems really nice. To rewrite the example in Zig (I am no Zig expert so feel free to correct/improve): co
by mdasen 2y ago
This feels a bit like Zig's error handling which seems really nice.
To rewrite the example in Zig (I am no Zig expert so feel free to correct/improve):
const string = []const u8; //just creating a type alias to make the signature below have string
//Zig
fn main() !void {
s = try greet();
try std.print("{s}\n", .{s});
}
//Boomla
fn main() () (error)? {
s := greet()?
println(s)
}
//Zig
fn greet() !string {
return error.NotGreetingToday
}
//Boomla
fn greet() (string) (error) {
fail "not greeting today"
}
//Zig
fn main() !void {
s = greet() catch {
// Error handler block
}
try std.print("Hello, {s}!\n", .{s});
}
//Boomla
fn main() () (error) {
s := greet() err { // Error handler block
fail err
}
println(s)
}
Both of them look very similar. Boomla uses `?` instead of `try` and `err` instead of `catch`, but the big area where they differ is that errors in Zig are enums that you can switch on - and rely on exhaustiveness checks to know that you're handling all the types of errors that could be coming from your call stack.
fn main() !void {
s = greet() catch |err| switch (err) {
error.NotGreetingToday => //handle this error
};
try std.print("Hello, {s}!\n", .{s});
}
And you know that you've handled the possibilities. Maybe you even find things that are bubbling up that shouldn't be and should be handled further down the stack.
With Go/Boomla, errors are often just strings and there's no way of knowing if a library has created a new error case or easily get a complete list of the errors that might be returned. In fact, since they can just be strings, it would be impossible to know since the strings could be created programatically.
If an error is bubbled up a few levels, even if you explicitly know to look for an error, you might not know what kinds of errors you should be looking for if they're just strings. If I'm saving something to a database and there's a network error, I'll likely want to retry that. If I'm saving something to a database and it's trying to save a string to an integer column, there's no sense in retrying that - it'll just keep failing. But when errors are just strings, it's hard to know what the error is ahead of time. It then becomes a game of whack-a-mole where you log the errors you get back and then create branches for the ones you've seen (and you hope no well-meaning person changes the string without realizing that you're relying on it).
Personally, I like my IDE being able to generate all the error branches that are returned from what I'm calling. I can see that I'm handling everything the way I want it to be handled. Sometimes it means that I notice something bubbling up that should be handled further down and I can just handle it there instead. While Go talks a lot about how error handling is important, it's also important to know what the error is. I've seen a lot of Go that's just `if err != nil { //all errors are the same }` (or even just returning it up the stack where you're less likely to be anticipating random strings).
It's also great because if an error is no longer returned, Zig will have a compile error so that you take out the dead code. How many people are catching exceptions or comparing error strings that aren't returned anymore?
Boomla is definitely onto something here: explicit error handling while allowing errors to be propagated up if that's what you want. I first saw this in Zig (and great languages often independently implement similar stuff). I think the fact that I can know that I'm handling all the different errors with Zig is a further improvement.
- zupa-hu 2y ago(Author) Thanks for the Zig examples, great comparison! I'm puzzled how Zig can do exhaustiveness checks in the presence of interfaces (dynamic dispatch). A quick google search suggested Zig may not have them? The problem is, having dynamic dispatch, I would think the resulting error types can not be inferred, which means they must be explicit in function signatures. As a result, we are back to Java checked exceptions with its refactoring hell. So that seems to be a hard tradeoff. Or please correct me. Just for clarification, Go/Boomla errors can also have dedicated types, so you don't need to compare error strings. Yet it is correct that you can not tell if you have exactly covered all possibilities.
- mdasen 2y agoIt looks like the error types need to be explicit in the function signatures in interfaces, but not in the function signatures of the implementations of the interface or in the signatures of anything calling those interface functions. That would give the compiler the information it needed without getting into the Java checked exception hell, yes? Again, I'm not a language designer or a Zig expert. But here's an example in my head of what something Boomla-ish could look like (noting that I haven't seen Boomla before today so it might look more like Go). type SqlRunner interface { Select(string stmt) ([]interface{}) (SQLSyntaxError, SQLTimeoutError) } fn getData(runner SqlRunner) ([]interface{}) (error) { if (day == Monday) { fail NoMondays } return runner.Select("SELECT * FROM table") } Any implementation of SqlRunner can only fail with one of those three errors or return a slice of interfaces. The compiler can infer which errors getData can return - NoMondays, SQLSyntaxErro, or SqlTimeoutError. Even if someone adds another error to the interface, the compiler can still infer it. What is nice is that wherever you're handling the error would now need to be updated - because you're now not handling one of the errors you're supposed to be handling. It doesn't put you into refactoring hell like Java where every method signature needs to be updated, but it does alert you where your code is now wrong because you aren't handling something. That seems like it'd work without being problematic like Java's exceptions. If it still seems like a problem because maybe you want to implement SqlRunner with different errors, I might wonder if it's really the same interface. Certainly if you wanted to implement it as `Select(string stmt) (int)` we'd both agree that's not the same interface. Your caller isn't expecting an int back. But if they aren't expecting a WontSelectId5 error back, isn't that kinda the same thing? If I have a SqlRunner, I might think "I want to retry on SQLTimeoutErrors and show the user an error on SQLSyntaxErrors since there's no point in retrying that." What do I do with an unexpected WontSelectId5? If I'm expecting it, I won't retry it. If you really want to keep the interface open, maybe you could use `anyerror`. Zig has a special `anyerror` to match any error: Select(string stmt) ([]interface{}) (SQLSyntaxError, SQLTimeoutError, anyerror) Everything upstream would still just have (error) in the signature and the compiler could still help you out - to an extent. For example, your switch could look like: switch (err) { SQLSyntaxError => a, SQLTimeoutError => b, anyerror => c } That would allow for an interface implementation to have its own custom errors while the interface still defines types for the ones that might happen most often across implementations. I'm curious if you'd want the interface to have an `anyerror` addition. It allows for random errors to be returned, but maybe that makes it a different thing or that the interface needs to be updated (and updating the interface wouldn't be a refactoring hell since everything up the call stack would would get inferred by the compiler. > Go/Boomla errors can also have dedicated types, so you don't need to compare error strings I guess I just saw `errors.New` a lot in Go, but part of that might be that I haven't used Go in the past few years. It sounds like maybe there's a shift to using types and `errors.Is` to check?