Do we need an @assert?

Looks good to me!

It could be helpful to stress that there aren’t two ways to convey optimization potential to the compiler, named assert and unreachable. There’s just unreachable, assert is just a very thin wrapper around it.

You make that clear early on, I think it would help to carry that distinction forward and echo it in some of the later explication. Since it is effectively the topic of the post: assert is not magic in Zig, like in other languages, it’s merely a convention for using unreachable, which is much more broadly applicable.

4 Likes

Super, input deeply appreciated. Updated (diff), including short mention of @hasSideEffects, etc. in addenda.

Today,std.debug.assert is already clear and explicit. Its behavior is well defined, and its implementation is simple and correct. I don’t want to argue about that, this is true and you are right, but I personally think that assertions are important, and deserve to be upgraded for several reasons.

Zig already has many builtins whose main purpose is consistency rather than convenience. Builtins like exact division, overflow-aware arithmetic, or power-of-two related operations exist because they express intent directly to the compiler and allow consistent lowering to the right instructions when the platform supports it. They sit at the boundary between what the programmer means and how the code is lowered. Assertions naturally belong in that same category, in my opinion.

std.math.divExact(comptime T: type, numerator: T, denominator: T)
@divExact(numerator: T, denominator: T)

std.math.log2(x: anytype)
@log2(value: anytype)

std.debug.assert(ok: bool)
@assert(ok : bool) // makes sense :)

Assertions encode invariants. Those invariants are used by humans reading the code, and usefull for the optimizer as well. A builtin makes this intent visible earlier and more reliably than a library function, without depending on inlining or optimization heuristics. The goal is not micro optimization, but rather consistency and confidence in the lowering.

Some real-world projects have ended up reimplementing their own assertion helpers to get more predictable behavior in hot paths or across build modes. That does not mean std.debug.assert is wrong, it just shows that assertions are important enough that people want stronger guarantees than what a regular function can provide, which again seems like what builtins really do at the end of the day. Especially because it would also reduce fragmentation, if each library has a different assert semantic, that’s not great for code reuse.

Tooling is another strong reason. Zig has a long-term plan to implement a compiler server protocol (or something like that i don’t remember) that other tools can build on top of. In that context, assertions are not just function calls, they are semantic facts. If assertions are a builtin, I assume it would be easier for external tools, to build upon those assertions, even the compiler could potentially leverage that.

In short, std.debug.assert is already good, but it could be improved by making assert a first-class, builtin feature. Doing so would make assertions more useful, more visible to the compiler and tools, and more consistent with other Zig builtins that exist specifically to express intent and ensure correct lowering.

Also I really like builtins, and how they stand out, and it’s simpler to type @assert (but i know those aren’t good reason ahah)

I could also see an argument for splitting assert into 2 builtins, with @assert() and @assume() and you could get the same behavior with @assume(@assert())

3 Likes

Translating the Lemon template for Zitron really drove home a point for me: if (comptime flag) in Zig is an #ifdef. Same thing, different mechanism.

Example:

fn returnsUsize() usize {
    if (false) return "lol";
    return 42;
}

You can do this! That’s how little attention the compiler pays to comptime-false blocks.

So

if (comptime is_debug) {
    assert(expensiveWithSideEffects(big_struct_ptr));
}

Is precisely equivalent to

#ifdef NDEBUG
        expensive_with_side_effects(big_struct_ptr);
#endif

The point about the greppability of @assert, and other ease-of-tooling considerations, is a reasonable one, and the fact that the compiler doesn’t consistently elide assert calls in ReleaseFast is at least worth considering, I don’t doubt that having it as a builtin would make that easier to ensure.

But fixing whatever that is gives benefit to every case of the compiler failing to remove a function call of no effect, not just assert, so it’s worth doing anyway.

I remain inclined to the opinion that this is a case where Zig code does things a little differently, and that promoting assert to @assert doesn’t provide adequate benefit.

Although I do like to run these kinds of questions backward: if we had @assert, would I advocate removing it? Hmmm… y’know I honestly might. If someone pointed out that it should just be a synonym for if (!ok) unreachable; I would have to agree with that.

You make a good case for it, but I’m still on the side of improvement to unreachability analysis, and use of if (comptime flag) to ensure that complex effectful assertions disappear when not wanted.

4 Likes

I understand your opinion, and I must say the status quo is fine, but I do still believe that it makes sense, even forgetting about the convenience, the greppability or other benefit, I think from a consistency stand point, I can’t think of a good argument for having something like @log2() and not @assert() both can be implemented in userland, but both benefit from additional integration, log2 because it can be lowered very efficiently on a given platform, and @assert() because it will probably help make it easier for the compiler to differentiate it from a regular function, to enforce the intended semantic. Otherwise it’s LLVM trying to remove it, not Zig.

1 Like

IF it was decided that @assert should become a builtin, I think it should remain nothing more (or less) than its current if (!ok) unreachable, in terms of its effects. That is, it should even remain as ambiguous about eliding code. That is - it should remain fixed on the task of optimization, and not become the “debug thing” that it is in other languages. I’m sold on that much. I would not be fond of changing that purpose to another name, like “assume”, mostly because of what I think of when I think of the dictionary meaning of the word “assert”, despite the programming meaning the word has come to inherit. But perhaps a bifurcation is in order, even if there’s no movement on the builtin front. Another function (or builtin) might be called something completely new, like “assume”, which simply added the if (builtin.mode == .Debug) gate (mere convenience, and to avoid endless ziggit re-conversations for years). If these were functions, I’d advocate for std.debug.assume(), but std.assert() (not in debug, since it really has nothing to do with debug). Two more cents. :slight_smile:

3 Likes

Yeah agree, I don’t have a strong opinion, but would rather just have assert as a builtin, because it makes me thing of another argument in favor of assert, which is readability, and not in the sense that it stands out more, but because when you see someone using assert(cond), you don’t necessarily know if it’s from std.debug.assert imported somewhere or if it’s some custom implementation, and @assert() removes that uncertainty completely. Which i think is a nice perk.

1 Like

My preference would be to remove std.debug.assert entirely and not add a built-in. All of the confusion about assert and most of this discussion is due to expectations from using assert in other languages, and these would all go away, replaced by simple use of unreachable. unreachable is the only thing that needs to be understood clearly.

4 Likes

The status of the builtins which aren’t entirely compiler directives, type manipulation and such, is a bit ambiguous. I think it’s straightforwardly good to not force the compiler to detect a fairly complex arithmetic function (@popcount is another good example) and replace it with an intrinsic, but it doesn’t really seem like one can draw a strong a priori boundary around what gets built in and what doesn’t.

It does occur to me that an @assert builtin makes it practical for the specification to say that nothing between the parentheses will have any effect in Release(Fast|Small), that’s a big ask if it’s just a function living in a corner of the namespace. That would be another variation on the ‘detect something and replace it with intrinsic’ thing, but this time with surprising effects.

Because that version is a substantial behavior change:

assert(printAndReturnTrue("log this"));

My understanding of how things work makes it certain that the function would be called with std.debug.assert, mode notwithstanding, so this would be a different beast.

Thinking about it specifically in terms of specification I like this idea more. I’m not quite sold, but it seems less extraneous that way. But then I come back to: but that’s if (comptime flag), right now, already… Hmm.

I think the most important feature is the guarantee, what I would like is for it to be consistent, and a builtin does help in that regard. I mean fundamentally, zig always takes the hard path, and always tries to do everything to guarantee optimal code generation, and consistent behavior, assert as a builtin seems like it’s just one more step in that direction, better code, stronger guarantees, strict semantic :slight_smile: