Zig Assert Strategies
This post is about how to express various kinds of assert
in Zig.
Here's the short version:
std.debug.assert
is a normal function, and it may evaluate its parameter even inReleaseFast
mode.- All safety-checked illegal behaviour can be dealt with using a custom panic handler
- Use
if (@import("builtin").mode == .Debug)
when you're feeling paranoid about slow asserts, or you want to mimic C assert macros
1. A note on humans
Different people mean different things when they say assert
.
In online discussions this can lead to confusing posts of people talking past one another.
Whether reading a forum, or reading a piece of code make sure you know:
- What does the assert mechanism they're using do in various release modes?
- What is the author communicating when they assert on a condition?
It's best to not assume that you and the person who wrote the code have the same idea on 1 and 2.
If them using the word assert
in a different way to you is annoying, feel free to s/assert/blassert/
2. std.debug.assert
The standard library has an explicit assert function, whose definition looks like this:
pub fn assert(ok: bool) void { if (!ok) unreachable; // assertion failure }
If the condition is false it hits unreachable
, which does different things depending on the current safety mode.
In safe modes this invokes the panic handler.
This is nothing magic, just a function that is automatically called in conditions like this. You can override it if you'd like (we will later), and you can read the default function at std.debug.defaultPanic
.
In unsafe modes, this can do anything, hopefully just a crash but actually the compiler is free to use the "unreachability" of this to do arbitrary things for optimization.
If you're coming from C, it's important to note that this assert
is a normal function, which has 2 implications:
- It doesn't just "disappear" in release builds, it's full-on illegal behaviour for an assertion to fail
- the
ok
bool passed to the function may be evaluated. That is to say, if you callassert(expensive_test_with_side_effects()
), the call will still be made in all modes. More on how to avoid this later.
3. asserts are all over the show
One nice thing about this definition is it's in no way special.
Zig has all sorts of illegal behaviours that are safety checked in safe modes, and reaching unreachable
is just one.
You can see a full list here but it includes things like: arithmetic overflow, unwrapping a null
, out-of-bounds accesses.
So in a real sense you should read all of those as implicit assertions. That is to say that for safe release modes you should read this:
const b = array[5];
as
assert(array.len > 5); const b = array[5];
This is very nice and symmetrical, but "seeing" these implicit assertions in all these places and keeping them in my head is quite difficult for me. When I read heavily asserted code I really enjoy "turning my brain off" to the possibility the assert excludes, and I find that a lot harder to do for all the different places that would trigger safety-checked illegal behaviour.
Which is why I'm sure I've written the second kind of code from above at least once!
4. panic handler is the place to be
Another nice thing about this "all assertions are just triggers of illegal behaviour" is that there's a single thing invoked in all of them: The panic handler.
Whether it's an explicit assert
on an implicit one from an out-of-bounds, you just need to implement a single function to handle them all.
The default panic handler is here , it nicely prints the stack trace and aborts.
You can define your own by declaring a const panic
in your root, it's described in good detail here in the Zig Reference
Some possible things you could do here:
- Display a popup window to the user with a "Send error log to dev" button.
- Unify all your fatal crash codepaths into one place, like Bun does
- Be more debugger friendly, which we'll see next.
5. debugger-oriented assertions
Personally, when developing I don't like the abort
behaviour in defaultPanic
.
I'm trying to be a lot more debugger-first lately, and the abort
means my debugger stops way further down the stack.
(Chris Wellons wrote a post about this issue for C here )
pub fn main() void { @import("std").debug.assert(false); }
If you run this in a debugger (lldb
in my case) you get this:
(lldb) bt * thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGABRT * frame #0: 0x00000001998f25b0 libsystem_kernel.dylib`__pthread_kill frame #1: 0x000000019992c888 libsystem_pthread.dylib`pthread_kill frame #2: 0x0000000199832808 libsystem_c.dylib`abort frame #3: 0x0000000100019120 test`posix.abort at /Users/josh/.local/bin/zig/lib/std/posix.zig:732 frame #4: 0x0000000100005e34 test`debug.defaultPanic at /Users/josh/.local/bin/zig/lib/std/debug.zig:722 frame #5: 0x00000001000147f8 test`debug.FullPanic((function 'defaultPanic')).reachedUnreachable at /Users/josh/.local/bin/zig/lib/std/debug.zig:64 frame #6: 0x0000000100007e08 test`debug.assert at /Users/josh/.local/bin/zig/lib/std/debug.zig:559 frame #7: 0x00000001000996d4 test`test.main at /private/tmp/test.zig:2 frame #8: 0x00000001000995f8 test`start.callMain at /Users/josh/.local/bin/zig/lib/std/start.zig:618 frame #9: 0x00000001000995f4 test`start.callMainWithArgs at /Users/josh/.local/bin/zig/lib/std/start.zig:587 frame #10: 0x0000000100099578 test`start.main at /Users/josh/.local/bin/zig/lib/std/start.zig:602 frame #11: 0x000000019956dd54 dyld`start
There's 7 frames of stuff we have to up
through to get to the actual spot of interest.
Let's try to make a minimal panic handler that stops as close to the action as possible
const std = @import("std"); pub fn main() void { std.debug.assert(false); } pub const panic = PanicHandler; pub const PanicHandler = struct { pub fn call(_: []const u8, _: ?usize) noreturn { @branchHint(.cold); @breakpoint(); unreachable; } pub fn sentinelMismatch(_: anytype, _: anytype) noreturn { @branchHint(.cold); @breakpoint(); unreachable; } // ... Many more functions to add here, see std.debug.simple_panic for the "interface" you have to present. };
Now this gives us:
(lldb) bt thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x1000061d4) * frame #0: 0x00000001000061d8 test`test.PanicHandler.reachedUnreachable at test.zig:55:9 frame #1: 0x0000000100007afc test`debug.assert(ok=false) at debug.zig:559:14 frame #2: 0x0000000100096500 test`test.main at test.zig:4:21 frame #3: 0x0000000100096424 test`start.callMain at start.zig:618:22 [inlined] frame #4: 0x0000000100096420 test`start.callMainWithArgs at start.zig:587:20 frame #5: 0x00000001000963a4 test`start.main(c_argc=1, c_argv=0x000000016fdff528, c_envp=0x000000016fdff538) at start.zig:602:28 frame #6: 0x000000019956dd54 dyld`start + 7184
That's… better, just the frame for the panic handler and the assert.
We can inline
the assert, but our panic handler functions have to be exactly what is expected, which means they can't be inline
and they have to be noreturn
.
This is a fair bit worse than the C macro, however on the plus side we get the exact same result for all illegal behaviour, like out-of-bounds access on an array.
pub fn main() void { const array: [2]u8 = .{ 1, 2 }; _ = array[2]; }
zig build-exe test.zig test.zig:5:15: error: index 2 outside array of length 2 _ = array[2];
Shoot, it won't even compile, the Zig compiler can check some of these illegal behaviours at compile time, in which case it will just fail. Let's try and be sneaky.
pub fn main() void { const array: [2]u8 = .{ 1, 2 }; const slice: []const u8 = array[0..]; _ = slice[2]; }
zig build-exe test.zig test.zig:6:15: error: index 2 outside slice of length 2 _ = slice[2];
Still bested by the compiler, okay time for the big guns:
pub fn main() void { var prng = std.Random.DefaultPrng.init(0); const rand = prng.random(); const array: [2]u8 = .{ 1, 2 }; const idx = rand.intRangeLessThan(usize, 2, 10); _ = array[idx]; }
Phew, finally it compiles (until someone decides that doing RNG with comptime known seeds is a good idea!)
And in the debugger we get what we expect, in fact this time it's even better because we don't have the assert
frame.
(lldb) bt thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x10000597c) * frame #0: 0x0000000100005980 test`test.PanicHandler.outOfBounds((null)=4, (null)=2) at test.zig:36:9 frame #1: 0x00000001000965b4 test`test.main at test.zig:9:14 frame #2: 0x0000000100096424 test`start.callMain at start.zig:618:22 [inlined] frame #3: 0x0000000100096420 test`start.callMainWithArgs at start.zig:587:20 frame #4: 0x00000001000963a4 test`start.main(c_argc=1, c_argv=0x000000016fdff528, c_envp=0x000000016fdff538) at start.zig:602:28 frame #5: 0x000000019956dd54 dyld`start + 7184
That's looking okay, probably we can script the debugger to do an "up" to get where we want automatically. One important thing: Make sure you're parameterising this panic handler on the release mode, we wouldn't want to leave it in a shipped build!
6. custom behaviours
So, Zig has "asserts" all over it in safe modes, and trying to have a special case assert
is maybe going against the grain.
But, there are some cases where you genuinely have unique assert behaviours that can not be fulfilled by the custom panic handler approach
6.1. No really, do NOT evaluate that
Some times you really want to control if the condition of the assertion is evaluated. For example you might have an expensive assertion that walks some data structure and checks everything is as it should be. You really don't want this to ever get ran outside of debug modes, so you can use this idiom
const dbg_mode = @import("builtin").mode == .Debug; if (dbg_mode) std.debug.assert(super_expensive_check()); // Or if you're feeling really really paranoid! if (comptime dbg_mode) std.debug.assert(super_expensive_check());
Here we switched on the release mode, but we could define various assertion levels with build flags.
As far as I can tell, since Zig doesn't have macros there is no way to avoid the inline if (dbg_mode)
, as any attempt to abstract this to a function will potentially evaluate the parameter.
6.2. Don't carry on in release modes
Or, maybe you don't like the idea of illegal behaviour in ReleaseFast
mode if one of your assertions fails.
For that really all you can do is define your own assert function.
pub inline fn assertAlwaysPanic(ok: bool) { if (!ok) { @panic("assertion fail"); } }
Alternatively, you can explicitly turn on the safety checking features with @setRuntimeSafe(true)
, so using that we could use the standard assert and still get a panic
{ @setRuntimeSafety(true); std.debug.assert(very_scary_condition_we_always_abort_on()); }
6.3. I really want a clean debugger trace
If you really want the perfect debugger experience then you can have a special assert just for that
pub inline fn debugAssertOrSkip(ok: bool) { if (dbg_mode and !ok) @breakpoint(); }
Since it's inline
this will break exactly where you want, but remember that you'll still get all the junk stack frames for any other kind of safety violation!
7. What do I use?
It is 100% dependent on your project. If it's a toy, do what you want, if it's not a toy then probably you can sit down and decide what behaviour you want, then choose from the above on how to get that behaviour.
For me, I use the @breakpoint()
assert from above, if I ever release this software to anyone I'll probably make those panic if not in debug mode.
But, like I said, you shouldn't care what I do, only what your project needs.
Thanks for reading! If you spot any errors let me know :) email