I learned a cool application of Zigs errdefer
while watching kristoff_itβs stream.
Letβs recap what errdefer
is about, by talking about defer
first.
defer
defer
in Zig executes a statement when the scope of the defer
exits.
// π `zig run` will execute this `pub fn main`
pub fn main() void {
defer print("4. defer fn!\n", .{});
{
// π defer is block-scoped
defer print("2. defer block\n", .{});
print("1. block\n", .{});
}
print("3. fn\n", .{});
}
const print = @import("std").debug.print;
defer.zig
Which produces this output:
1. block
2. defer block
3. fn
4. defer fn!
zig run defer.zig
Note
You can save files in the example and run them locally if you want to play around. The first example uses a main function, which you run with
zig run $file
. The following examples will be individual tests, which can be run withzig test $file
. To install zig, head over to ziglang.org
defer
is often used together with resource management, such as allocations. In this example, we allocate a buffer that needs to be freed within the same function.
// allocations in zig must go through an Allocator
// π
fn deferExample(gpa: std.mem.Allocator) !void {
// `try` returns from the function if the
// allocation fails (they can fail!)
// π
const temp_buffer = try gpa.alloc(u8, 4);
// π cleaning up the allocation at scope exit
defer gpa.free(temp_buffer);
// do something with the buffer
@memcpy(temp_buffer, "temp");
try expectEqualStrings("temp", temp_buffer);
}
test deferExample {
// The test allocator will fail the test if
// an allocation has not been freed
// Thanks to `defer`, we have no memory leaks
const gpa = std.testing.allocator;
try deferExample(gpa);
}
const std = @import("std");
const expectEqualStrings = std.testing.expectEqualStrings;
defer_alloc.zig
1/1 defer_alloc.decltest.deferExample...OK
All 1 tests passed.
zig test defer_alloc.zig
As a more advanced example, where we want to return an allocation from a function. This example parsed a 4-digit integer (which can fail) into a freshly allocated buffer.
fn wrongToString(
gpa: std.mem.Allocator,
input: u32, // a number we want to parse
// π return allocated slice
) ![]const u8 {
// the freshly allocated buffer
const buf = try gpa.alloc(u8, 4);
// We can't use `defer` here as we want to return
// this allocation and `defer` would free it
// defer gpa.free(buf); π π
// introduce and error condition
if (input < 1000 or input > 9999) {
// can only format 4-digit numbers
return error.InputHasNot4Digits;
}
// wrap the buffer in a writer
var buf_writer = std.io.fixedBufferStream(buf);
// print the int in decimal format
buf_writer.writer().print("{d}", .{input})
// we already checked the int
// and don't expect formatting errors
catch unreachable;
return buf;
}
test wrongToString {
const gpa = std.testing.allocator;
{
// good input returns a new string
const good = try wrongToString(gpa, 1337);
// this test owns the returned string,
// π so we cleanup with defer here
defer gpa.free(good);
try expectEqualStrings("1337", good);
}
// bad input returns an error
try expectError(
error.InputHasNot4Digits,
wrongToString(gpa, 42),
);
}
const std = @import("std");
const expectEqualStrings = std.testing.expectEqualStrings;
const expectError = std.testing.expectError;
defer_error.zig
If we run this, we can see that it passes⦠or does it?
1/1 defer_error.decltest.wrongToString...OK
[gpa] (err): memory address 0x1003e0000 leaked:
./defer_error.zig:7:30: 0x1001dc7bb in wrongToString (test)
const buf = try gpa.alloc(u8, 4);
^
./defer_error.zig:47:22: 0x1001dde37 in decltest.wrongToString (test)
wrongToString(gpa, 42),
^
zig/master/lib/compiler/test_runner.zig:214:25: 0x10028f9cb in mainTerminal (test)
if (test_fn.func()) |_| {
^
zig/master/lib/compiler/test_runner.zig:62:28: 0x10028a263 in main (test)
return mainTerminal();
^
zig/master/lib/std/start.zig:647:22: 0x100289ccb in main (test)
root.main();
^
???:?:?: 0x18daf8273 in ??? (???)
All 1 tests passed.
1 errors were logged.
1 tests leaked memory.
zig test defer_error.zig
Whoopsie, we have a memory leak π . We have allocated the buffer at the beginning the the function, but later returned an error when the integer is validated. At this point, we should have deallocated the buffer, but doing this everytime there is a possible error is error-prone.
Enter:
errdefer
errdefer
acts like a defer, but only when the function exists with an error. It does not run the block for the happy path.
fn fixedToString(
gpa: std.mem.Allocator,
input: u32, // a number we want to parse
) ![]const u8 {
// the freshly allocated buffer
const buf = try gpa.alloc(u8, 4);
// We still can't use `defer` here
// π but we can use `errdefer` to clean up
errdefer gpa.free(buf);
// rest is identical to the example above
if (input < 1000 or input > 9999) {
return error.InputHasNot4Digits;
}
var buf_writer = std.io.fixedBufferStream(buf);
buf_writer.writer().print("{d}", .{input}) catch unreachable;
return buf;
}
test fixedToString {
const gpa = std.testing.allocator;
{
// good input still returns a new string
const good = try fixedToString(gpa, 1337);
// that we need to clean up here
defer gpa.free(good);
try expectEqualStrings("1337", good);
}
// bad input still returns an error
// but now with out leaking memory
try expectError(
error.InputHasNot4Digits,
fixedToString(gpa, 42),
);
}
const std = @import("std");
const expectEqualStrings = std.testing.expectEqualStrings;
const expectError = std.testing.expectError;
errdefer.zig
1/1 errdefer.decltest.fixedToString...OK
All 1 tests passed.
zig test errdefer.zig
At this point, that could be it.
Β
But errdefer
can do more.
error capture in errdefer
The errdefer
can optionally capture the error.
fn captureError(frobnicate: bool) !u32 {
// π capture the error here
errdefer |err| {
// "handle" error by logging it
std.log.warn("error: {s}", .{@errorName(err)});
}
if (frobnicate) {
return error.Frobnicate;
}
return 42;
}
test captureError {
try expectError(error.Frobnicate, captureError(true));
try expectEqual(42, captureError(false));
}
const std = @import("std");
const expectEqual = std.testing.expectEqual;
const expectError = std.testing.expectError;
errdefer_capture.zig
1/1 errdefer_capture.decltest.captureError...[default] (warn): error: Frobnicate
OK
All 1 tests passed.
zig test errdefer_capture.zig
You canβt return in a errdefer
(same as for defer
). However, we can run something that is noreturn
(like @panic
). When we do that, the handler will remove that error from the inferred error set!
// no error return type π
fn reduceErrorSet(frobnicate: bool) u32 {
errdefer |err| switch (err) {
// π handle the `Frobnicate` case
error.Frobnicate => {
std.log.warn("frobnicate error", .{});
// π a `noreturn` expression
std.process.exit(0);
},
};
if (frobnicate) {
return error.Frobnicate;
}
return 42;
}
test reduceErrorSet {
try expectEqual(42, reduceErrorSet(false));
// π this will now exit the process
_ = reduceErrorSet(true);
}
const std = @import("std");
const expectEqual = std.testing.expectEqual;
errdefer_set.zig
1/1 errdefer_set.decltest.reduceErrorSet...[default] (warn): frobnicate error
zig test errdefer_set.zig
Now we can bring it all together for the final trick:
oom handler
I mentioned earlier that allocations can fail (and they can), but most operating systems use virtual memory, which gives us the illusion of infinite memory. That makes it virtually improbable for allocations to fail, and if they still do, we canβt really do anything better than aborting the process most of the time anyways. Most likely, allocation failure happen when the allocator is limited in some way (for example when it wraps a fixed size buffer) and you would know about this situation. For the general purpose allocation, we can often get away with ignoring allocation failures (itβs basically what every other programming language with managed memory does).
We can combine everything we learned about errdefer
to make this nice and tidy:
Zig version note
The code uses
std.process.fatal
, which exists at this place as of Zig 0.14. In older version, it lives atstd.zig.fatal
. It is still available under that name in 0.14, but using it is deprecated.
// again, no error return type π
fn oomHandler(gpa: std.mem.Allocator) void {
errdefer |err| switch (err) {
// exit the process on OOM π
error.OutOfMemory => std.process.fatal("oom", .{}),
};
// we can still use try for allocator methods
// which is kinda the whole point of all of this
const buf = try gpa.alloc(u8, 4);
defer gpa.free(buf);
const buf2 = try gpa.alloc(u8, 2);
defer gpa.free(buf2);
}
test oomHandler {
const gpa = std.testing.allocator;
oomHandler(gpa); // all ok
}
test "oomHandler with allocation failure" {
var buf: [4]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
oomHandler(fba.allocator()); // will trigger the call to fatal
}
const std = @import("std");
const expectEqualStrings = std.testing.expectEqualStrings;
const expectError = std.testing.expectError;
errdefer_oom.zig
1/2 errdefer_oom.decltest.oomHandler...OK
2/2 errdefer_oom.test.oomHandler with allocation failure...[default] (err): oom
error: the following test command failed with exit code 1:
/.zig-cache/o/f1b2fe6eccfbfea660fb112711fad183/test --seed=0xe8c70d02
zig test errdefer_oom.zig
If you write a library, you should refrain from noreturn
ing on an allocation error. For an application however, especially if it has a limited lifetime, like a cli tool, this seems like a good way to almost pretend like allocation failures do not exist anymore.
Happy zigging!