Hi, so recently I’ve been using a lot more assertions in my code, and realized that the reason I didn’t use more of them before, was because they are in the debug namespace.
But it’s a poor argument to propose a new builtin to be part of the language, so I’ve been thinking about it more and I think it would make a lot of sense to offer the @assert()
as a builtin.
I’m going to list all the reasons why I think it makes sense in no particular order.
-
assertions are important for encoding invariant, intent, and to communicates to both the reader/maintainer of the code, and also to talk to the optimizer.
-
assertions are important in many situations. To enforce correctness, to protect ourselves when refactoring. For testing purposes etc.
-
having a builtin would reduce the friction compared to using
std.debug.assert
(albeit there isn’t much friction in status quo but you get the gist it’s always there if it’s a builtin). -
as a builtin it would be trivial to implement / maintain.
-
as a builtin it stands out more, which improves the readability IMHO
-
as a builtin it could lead to the feature that I’m going to explain below too.
So as you can see I think I’ve made a pretty strong case that assert is useful on it’s own but one of it’s main features is also one of it’s main drawback. As explained in the doc comment.
/// Invokes detectable illegal behavior when `ok` is `false`.
///
/// In Debug and ReleaseSafe modes, calls to this function are always
/// generated, and the `unreachable` statement triggers a panic.
///
/// In ReleaseFast and ReleaseSmall modes, calls to this function are optimized
/// away, and in fact the optimizer is able to use the assertion in its
/// heuristics. <------ THIS RIGHT HERE IS WHAT A BUILTIN COULD FIX.
///
/// Inside a test block, it is best to use the `std.testing` module rather than
/// this function, because this function may not detect a test failure in
/// ReleaseFast and ReleaseSmall mode. Outside of a test block, this assert
/// function is the correct function to use.
pub fn assert(ok: bool) void {
if (!ok) unreachable; // assertion failure
}
because assert is in status quo just a regular function it therefore is constrained by the semantic of regular functions. As such while very helpful in the context of all the things that I’ve mentioned above it can lead to some issues.
Because it’s being used by the optimizer as a way to say “I guarantee this invariant will not be violated, so optimize from that assumption”. It means that you can’t necessarily observe the assertions in Release mode.
take this very small example.
const std = @import("std");
const assert = std.debug.assert;
const MyUnion = union(enum) {
foo: u8,
bar: u8,
};
fn undefinedBehavior(foo: MyUnion) void {
assert(std.meta.activeTag(foo) == .foo);
std.debug.print("Hello World!\n", .{});
}
pub fn main() !void {
undefinedBehavior(.{ .bar = 'a' });
}
Here is the current status quo for the different release mode when running this code.
In Debug
:
v0 ) ./proposal_base
thread 58503 panic: reached unreachable code
/home/pollivie/zig/0.15.0-dev.369+1a2ceb36c/files/lib/std/debug.zig:546:14: 0x104911d in assert (proposal)
if (!ok) unreachable; // assertion failure
^
/home/pollivie/workspace/github/proposal/src/main.zig:10:11: 0x10df73e in undefinedBehavior (proposal)
assert(std.meta.activeTag(foo) == .foo);
^
/home/pollivie/workspace/github/proposal/src/main.zig:15:22: 0x10df712 in main (proposal)
undefinedBehavior(.{ .bar = 'a' });
^
/home/pollivie/zig/0.15.0-dev.369+1a2ceb36c/files/lib/std/start.zig:671:37: 0x10df6d0 in posixCallMainAndExit (proposal)
const result = root.main() catch |err| {
^
/home/pollivie/zig/0.15.0-dev.369+1a2ceb36c/files/lib/std/start.zig:282:5: 0x10df29d in _start (proposal)
asm volatile (switch (native_arch) {
^
???:?:?: 0x0 in ??? (???)
Pretty nice
in ReleaseSafe
v0 ) ./proposal_safe
thread 58587 panic: reached unreachable code
/home/pollivie/zig/0.15.0-dev.369+1a2ceb36c/files/lib/std/debug.zig:546:14: 0x1046fd8 in main (proposal)
if (!ok) unreachable; // assertion failure
^
/home/pollivie/zig/0.15.0-dev.369+1a2ceb36c/files/lib/std/start.zig:671:37: 0x103bd62 in posixCallMainAndExit (proposal)
const result = root.main() catch |err| {
^
/home/pollivie/zig/0.15.0-dev.369+1a2ceb36c/files/lib/std/start.zig:282:5: 0x103b87d in _start (proposal)
asm volatile (switch (native_arch) {
^
???:?:?: 0x0 in ??? (???)
Still usefull
But if you use ReleaseSmall
or ReleaseFast
v0 ) ./proposal_fast
It hangs, and you have no idea what the problem is. But if @assert()
was a builtin we could probably control it’s semantic through the build system or through the use of std.options
, or directly within the builtin itself with an enum like
pub const AssertOptions = enum {
optimize, // still the default
debug, // use this in the optimizer, but don't remove the function.
maybe, // use for testing triggering assertions randomly helps fuzzer
throw, // throw an error instead of triggering unreachable (idk)
};
Obviously I’m not sure if it’s entirely feasible to feed the optimizer the assertion, without removing it, or if making sure it stays is entirely possible. But the idea would be to have a better assertion. One that isn’t constrained by the semantic of regular function,
This builtin could also be a way to unify assertion between comptime/runtime. It could be a way for the compiler to insert instrumentation, it could help with tooling, static analysis tools, etc. And it’s also just more convenient and visible.
Also the maybe mode could be used by the fuzzer to bypass the assertions to reach deeper.
Anyway TLDR; assert is cool but @assert()
would be even cooler in my opinion, and I’d love to hear what everyone thinks about this idea ?
ps : I’m making this post as to not propose on github.