defer and errdefer are the devil
defer increases the cognitive load for the reader since it violates the typical expectation that code is executed top down. defer and errdefer only exist in zig as a necessary evil to prevent a worse problem: error-prone cleanup code duplication.
For example, observe this allocating code that sorts and filters u32 values based on whether they’re even or odd:
const std = @import("std");
const Allocator = std.mem.Allocator;
const EvenOdd = @This();
even: []u32,
odd: []u32,
pub fn filter(alloc: Allocator, numbers: []u32) !EvenOdd {
// Local allocation
// Free every time
const sorted_numbers = try alloc.dupe(u32, numbers);
defer alloc.free(sorted_numbers);
std.mem.sort(u32, sorted_numbers, {}, std.sort.asc(u32));
// Normally free'd by .toOwnedSlice()
// Free manually on error
// (note: defer would be safe here since .deinit()
// can safely be called after .toOwnedSlice)
var even_list: std.ArrayList(u32) = .{};
errdefer even_list.deinit(alloc);
var odd_list: std.ArrayList(u32) = .{};
errdefer odd_list.deinit(alloc);
for (sorted_numbers) |val| {
if (val & 1 == 0) {
try even_list.append(alloc, val);
} else {
try odd_list.append(alloc, val);
}
}
// Caller owned
// Free on error only
const even = try even_list.toOwnedSlice(alloc);
errdefer alloc.free(even);
const odd = try odd_list.toOwnedSlice(alloc);
// Note: this is redundant as there is no error returns (try) after this
errdefer alloc.free(odd);
return .{ .even = even, .odd = odd };
}
pub fn deinit(self: *EvenOdd, alloc: Allocator) void {
alloc.free(self.even);
alloc.free(self.odd);
}
test "filter works" {
const alloc = std.testing.allocator;
var vals = [_]u32{ 9, 7, 6, 5, 4, 2, 1 };
var even_odd = try EvenOdd.filter(alloc, &vals);
defer even_odd.deinit(alloc);
try std.testing.expectEqualSlices(u32, &.{ 2, 4, 6 }, even_odd.even);
try std.testing.expectEqualSlices(u32, &.{ 1, 5, 7, 9 }, even_odd.odd);
}
fn testHarness(alloc: Allocator) !void {
var vals = [_]u32{ 9, 7, 6, 5, 4, 2, 1 };
var even_odd = try EvenOdd.filter(alloc, &vals);
defer even_odd.deinit(alloc);
}
test "No leaks on failed alloc" {
const alloc = std.testing.allocator;
try std.testing.checkAllAllocationFailures(alloc, testHarness, .{});
}
When test is ran, they both pass:
$ zig test EvenOdd.zig
All 2 tests passed.
Now, imagine if defer and errdefer no longer existed in zig. In order to pass the same tests, we have to use catch instead of try on every line we early return:
pub fn filter(alloc: Allocator, numbers: []u32) !EvenOdd {
// Local allocation
// Free every time
const sorted_numbers = try alloc.dupe(u32, numbers);
std.mem.sort(u32, sorted_numbers, {}, std.sort.asc(u32));
// Normally free'd by .toOwnedSlice()
// Free manually on error
var even_list: std.ArrayList(u32) = .{};
var odd_list: std.ArrayList(u32) = .{};
for (sorted_numbers) |val| {
if (val & 1 == 0) {
even_list.append(alloc, val) catch |err| {
odd_list.deinit(alloc);
even_list.deinit(alloc);
alloc.free(sorted_numbers);
return err;
};
} else {
odd_list.append(alloc, val) catch |err| {
odd_list.deinit(alloc);
even_list.deinit(alloc);
alloc.free(sorted_numbers);
return err;
};
}
}
// Caller owned
// Free on error only
const even = even_list.toOwnedSlice(alloc) catch |err| {
odd_list.deinit(alloc);
even_list.deinit(alloc);
alloc.free(sorted_numbers);
return err;
};
const odd = odd_list.toOwnedSlice(alloc) catch |err| {
alloc.free(even);
odd_list.deinit(alloc);
even_list.deinit(alloc);
alloc.free(sorted_numbers);
return err;
};
alloc.free(sorted_numbers);
return .{ .even = even, .odd = odd };
}
This is what defer prevents. It gives you a way to register cleanup code to run upon scope exit. If all you need is code that runs before a block ends (Writer.flush()), you should simply invoke the code directly. That’s easiest to read. Do not indulge the devil.
We should especially not allow the devil to modify return values. Let’s imagine defer was allowed to return for a moment:
errdefer foo();
defer try bar(); // could return error
errdefer baz();
If bar() errors, should foo() be called? What about baz()? See how hard you’re thinking right now? Even if we define that behavior, reasoning about this code is a nightmare. It goes directly against the zen of zig, which says to favor reading code over writing code.
In conclusion, defer/errdefer is the devil. We made a deal with the devil to clean up our messes. We keep the devil from taking over our lives by limiting what it can do. We should not indulge the devil unless necessary.