notes/Media/articles/Error Handling in Zig.md

169 lines
7.1 KiB
Markdown
Raw Normal View History

2023-08-07 11:32:05 +02:00
---
author:
link: https://www.aolium.com/karlseguin/4013ac14-2457-479b-e59b-e603c04673c8
status: not-finished
date: August 7, 2023
---
# Error Handling In Zig
#programming #error-handling #zig #enum #error-set
There are two aspects to Zigs simple error handling: error sets and try/catch. Zigs error set are essentially a specialized enum that can be created implicitly:
```
fn divide(dividend: u32, divisor: u32) !u32 {
if (divisor == 0) {
return error.DivideByZero;
}
return dividend / divisor;
}
```
Which is, in effect, the same as making it explicit:
```
const DivideError = error {
DivideByZero
};
fn divide(dividend: u32, divisor: u32) !u32 {
if (divisor == 0) {
return DivideError.DivideByZero;
}
return dividend / divisor;
}
```
In both cases our function returns an `!u32` where the exclamation mark, `!`, indicates that this function can return an error. Again, we can either be explicit about the error(s) that our function returns or implicit. In both of the above case, weve taken the implicit route.
Alternatively, we could have used an explicit return type: `error{DivideByZero}!u32`. In the 2nd case we could have used the explicit error set: `DivideError!u32`.
Zig has a special type, `anyerror`, which can represent any error. Its worth pointing out that the return types `!u32` and `anyerror!u32` are different. The first form, the implicit return type, leaves it up to the compiler to determine what error(s) our function can return. There are cases where the compiler might not be able to infer type where well need to be explicit, or, if youre lazy like me, use `anyerror`. Its worth pointing out that the only time Ive run into this is when defining a function pointer type.
Calling a function that returns an error, as `divide` does, requires handling the error. We have two options. We can use `try` to bubble the error up or use `catch` to handle the error. `try` is like Gos tedious `if err != nil { return err }`:
```
pub fn main() !void {
const result = try divide(100, 10);
_ = result; // use result
}
```
This works because `main` itself returns an error via `!void`. `catch` isnt much more complicated:
```
const result = divide(100, 10) catch |err| {
// handle the error, maybe log, or use a default
}
```
For more complicated cases, a common pattern is to switch on the caught error. From the top-level handler in [http.zig](https://github.com/karlseguin/http.zig/blob/bda55c35c6167d073f3ec5e870b6fce89651d35b/src/httpz.zig#L289):
```
self.dispatch(action, req, res) catch |err| switch (err) {
error.BodyTooBig => {
res.status = 431;
res.body = "Request body is too big";
res.write() catch return false;
},
error.BrokenPipe, error.ConnectionResetByPeer => return false,
else => self._errorHandler(req, res, err),
}
```
Another somewhat common case with `catch` is to use `catch unreachable`. If `unreachable` is reached, the panic handler is executed. Applications can define their own panic handler, or [rely on the default one](https://github.com/ziglang/zig/blob/1c7798a3cde9e8dad31c276aab4c1affb2043ad2/lib/std/builtin.zig#L733). I use `catch unreachable` a lot in test code.
One final thing worth mentioning is `errdefer` which is like `defer` (think Gos `defer`) but it only executes if the function returns an error. This is extremely useful. You can see an example of it in the [duckdb driver](https://github.com/karlseguin/zuckdb.zig/blob/master/src/conn.zig#L27)
```
pub fn open(db: DB) !Conn {
const allocator = db.allocator;
var slice = try allocator.alignedAlloc(u8, CONN_ALIGNOF, CONN_SIZEOF);
// if we `return error.ConnectFail` a few lines down, this will execute
errdefer allocator.free(slice);
const conn: *c.duckdb_connection = @ptrCast(slice.ptr);
if (c.duckdb_connect(db.db.*, conn) == DuckDBError) {
// if we reach this point and return an error, our above `errdefer` will execute
return error.ConnectFail;
}
// ...
}
```
A serious challenge with Zigs simple approach to errors that our errors are nothing more than enum values. We cannot attach additional information or behavior to them. I think we can agree that being told "SyntaxError" when trying to parse an invalid JSON, with no other context, isnt great. This is currently an [open issue](https://github.com/ziglang/zig/issues/2647).
In the meantime, for more complex cases where I want to attach extra data to or need to attach behavior, Ive settled on leveraging Zigs tagged unions to create generic Results. Heres an example from a PostgreSQL driver Im playing with:
```
pub fn Result(comptime T: type) T {
return union(enum) {
ok: T,
err: Error,
};
}
pub const Error = struct {
raw: []const u8, // fields all reference this data
allocator: Allocator, // we copy raw to ensure it outlives the connection
code: []const u8,
message: []const u8,
severity: []const u8,
pub fn deinit(self: Error) void {
self.allocator.free(self.raw);
}
};
```
Because Zig has exhaustive switching, we can still be sure that callers will have to handle errors one way or another. However, using this approach means that the convenience of `try`, `catch` and `errdefer` are no longer available. Instead, youd have to do something like:
```
const conn = switch (pg.connect()) {
.ok => |conn| conn,
.err => |err| // TODO handle err
}
```
The "TODO handle err" is going to be specific to the application, but you could log the error and/or convert it into a standard zig error.
In our above `Error` youll note that weve defined a `deinit` function to free `self.raw`. So this is an example where our error has additional data (the `code`, `message` and `severity`) as well as behavior (freeing memory owned by the error).
To compensate for our loss of `try` and `catch`, we can enhance or `Result(T)` type. I havent come up with anything great, but I do tend to add an `unwrap` function and a `deinit` function (assuming either `Error` or `T` implement `deinit`):
```
pub fn unwrap(self: Self) !T {
switch(self) {
.ok => |ok| return ok,
.err => |err| {
err.deinit();
return error.PG;
}
}
}
pub fn deinit(self: Self) void {
switch (self) {
.err => |err| err.deinit(),
.ok => |value| {
if (comptime std.meta.trait.hasFn("deinit")(T)) {
value.deinit();
}
},
}
}
```
Unlike Rusts `unwrap` which will panic on error, Ive opted to convert `Error` into an generic error (which can then be used with Zigs built-in facilities). As for exposing `deinit` directly on `Result(T)` it just provides an alternative way to program against the result. Combined, we can consume `Result(T)` as such:
```
const result = pg.connect();
defer result.deinit(); // will be forwarded to either `T` or `Error`
const conn = try result.unwrap();
```
In the end, the current error handling is sufficient in most cases, but I think anyone who tries to parse JSON using `std.json` will immediately recognize a gap in Zigs capabilities. You can work around those gaps by, for example, introducing a `Result` type, but then you lose the integration with the language. Of particular note, I would say losing `errdefer` is more than just an ergonomic issue.