Blog Post: Zig Assertion Strategies

Hi all, I recently read some good discussions on here about assertions so I wrote a blog about replicating some other assert-y patterns in Zig.

I didn’t go into lang-ref level detail so please excuse some practical simplifcations! (“invoke panic handler” vs “safety-checked illegal behaviour”)

Let me know if I got anything wrong :slight_smile:

10 Likes
const dbg_mode = @import("builtin").mode == .Debug;

You probably also want to check for ReleaseSafe there!

5 Likes

Possibly!

But this is what I meant about choosing to suit your project.

I’m sure as my code gets older and older I’ll end up with some assertions that are so slow/pedantic my only options are “let’s just delete this, it never fails” or “well let’s leave it on only in debug mode at least, we’re still usable even in that mode”.

I agree though that until you realise you have this requirement it’s probably best to go with the grain of “Debug/ReleaseSafe/ReleaseFast”

In regards to 6.2 and especially this paragraph:

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.

Wouldn’t something like this be possible?

const std = @import("std");
const dbg_mode = @import("builtin").mode == .Debug or @import("builtin").mode == .ReleaseSafe;

// Same as std.debug.assert
fn assert(ok: bool) void {
    if (!ok) unreachable; // assertion failure
}

fn my_assert(check_fn: anytype, args: anytype, check: anytype) void {
    if (!comptime dbg_mode) return;
    std.debug.print("run my_assert\n", .{});
    if (!(@call(.auto, check_fn, args) == check)) unreachable;
}

pub fn main() !void {
    std.debug.print("dbg_mode: {}\n", .{dbg_mode});
    assert(add(1, 2) == 3);
    my_assert(add, .{ 1, 2 }, 3);
}

fn add(a: u64, b: u64) u64 {
    return a + b;
}

Prints this in Debug/ReleaseSafe:

dbg_mode: true
run my_assert

And this in ReleaseFast/ReleaseSmall:

dbg_mode: false
1 Like

While that works, it is far less readable than a simple if(dbg_mode) //check. So I wouldn’t recommend it.

It also only works when all the logic is in a function which returns the value you want to check, more complex checks would have to be wrapped in a function which is not very desirable.

I’m with you there because I wouldn’t do it either, but its possible so I thought it could be nice for someone ;^)

Edit: passing the function and the arguments to the function as separate arguments is the key to why this does work. I missed that earlier.

Original reply:

That won’t work. The assumption is that the add function here is the expensive part, not the assert; exiting the assert early does not matter. Try adding a print to your add function and observe that it is always called.

fn add(a: u64, b: u64) u64 {
    std.debug.print("in add\n", .{});
    return a + b;
}

(Technically the print statement forces add to always be called because it introduces a side effect, and the non side-effecting function could have been optimized away, but I think you should write your code in a way that it doesn’t matter whether or not the function has side effects if it is important that it is not included in certain build modes)

This is because there are no macros in Zig, as the OP said. The std.debug.assert function call being optimized away does not mean that functions called as arguments are optimized away.

This comment and the replies have some relevant discussion that may be interesting

No it doesn’t print it through my_assert. That would be very weird. Please look at the slighty reformatted code.

const std = @import("std");
const dbg_mode = @import("builtin").mode == .Debug or @import("builtin").mode == .ReleaseSafe;

// Same as std.debug.assert
fn assert(ok: bool) void {
    if (!ok) unreachable; // assertion failure
}

fn my_assert(check_fn: anytype, args: anytype, check: anytype) void {
    if (dbg_mode) {
        std.debug.print("run my_assert\n", .{});
        if (!(@call(.auto, check_fn, args) == check)) unreachable;
        // It would be very weird if the above would be called, no?
    }
}

pub fn main() !void {
    std.debug.print("dbg_mode: {}\n", .{dbg_mode});
    assert(add(1, 2) == 3); // Comment this line and run again
    my_assert(add, .{ 1, 2 }, 3);
}

fn add(a: u64, b: u64) u64 {
    std.debug.print("in add\n", .{});
    return a + b;
}

I get these outputs:

// zig run main.zig 
dbg_mode: true
in add
run my_assert
in add
// zig run main.zig -O ReleaseFast
dbg_mode: false
in add
// Now comment out the `assert(add(1, 2) == 3)` line
// zig run main.zig 
dbg_mode: true
run my_assert
in add
// zig run main.zig -O ReleaseFast
dbg_mode: false

I’m using 0.15.1, btw.

The problem with approaches like these is that they only really work for the trivial case, you’re only kicking the can down the road. What if you want to do my_assert(add, .{ getValue(), getOtherValue() }, getExpected())? The functions will still be evaluated for side effects, so you’d need to add support for another level of indirection with my_assert(add, .{ .{ getValue, .{} }, .{ getOtherValue, .{} } }, .{ getExpected, .{} }) and at a certain point it looks like a huge mess and like you’ve reinvented Lisp but with curly braces :slightly_smiling_face:

if (assertions_enabled) assert(sideEffects()) is the only way to eliminate evaluations that scales indefinitely. If you prefer a different style you can of course get a bit creative and try something like if (getAsserter()) |assert| assert(sideEffects()) but you can’t escape from the fact that the “assertions enabled/disabled” conditional control flow needs to occur on the caller side.

Yes I know. I wouldn’t use it myself as I already said above. But thank you.

I apologize, I misread your call to my_assert. I assumed you were passing it the same expression as assert as an argument.

1 Like