how is it not obvious, both examples are the same, the only difference is one gives a name to a value the other doesn’t. Whether or not a value has a name doesn’t change the semantics of unused values, or operations with/without side effects.
At the risk of going in circles a bit, if foo were a macro (which Zig does not have, but bear with me), then foo(bar()) could evaluate to at compile time, the bar wouldn’t exist, so the () would not happen.
This being how asserts work in C, which does have macros. This is not a mistake someone would make consciously, but it’s an easy oversight coming from languages where that is how it works.
This next part was stated already, but also bears repeating (maybe): a comptime false branch is elided, so for assertions with considerable setup, I tend to do this kind of thing:
if (is_debug) {
assert(execute_expensive_thing());
}
Since the language semantics guarantees that none of that will happen, granted that is_debug is comptime known, which it would be.
More than just not being executed, the object code with which to execute it will not appear in the binary. Much like a macro, just cleaner.
I don’t make a religion of this, compilers are pretty good at dead code elimination. But I do also like that it creates its own block, which visually distinguishes the asserting code from its surroundings.
I understand that, but would not say that makes it less obvious. Rather, that baggage from another language made me make a mistake.
As humans, this kind of mistake is inevitable, especially when using different tools that look similar and are used similarly. But I also think you shouldn’t blame the tools for being similar.
My opinion on assert specifically is that it shouldn’t exist, I prefer to write the if myself to be more explicit of what behaviour I want.
ReleaseSafe isn’t appropriate for all codebases that want runtime safety. The binary size difference between it and ReleaseSmall matters for my use case (low spec embedded linux w/ OTA capabilities), so we use @setRuntimeSafety and custom assert functions for critical sections.
The linked issue seems to solve the exact problem we have very elegantly, though. Looking forward to trying it out in the future. ![]()
I think it also becomes non-obvious if one starts to imagine what happens if the function gets inline.
So this:
assert(foo());
can be inlined to this:
if (foo()) unreachable;
Now an optimiser could start seeing that foo is never supposed to return true and from that start to argue that removing it is a valid optimisation:
Now if the function has side effects, this is an invalid optimisation and an optimiser that does this, obviously has a bug.
But of course if the compiler knows that a function doesn’t have side effects, this optimisation would be allowed (and that’s the reason why C23 has [[unsequenced]] and [[reproducible]] among other things).
Yes… thats the point…
Even if assert doesn’t get inlined (this would be surprising), the optimiser will still see the function does nothing and remove it and calls to it, resulting in values passed to it no longer being used (unless, they are used elsewhere), then those values get practically the same treatment as if it were inlined (it could be slightly different semantically).
The result is the same.
This shouldn’t be surprising, this is what unreachable exists to do.
I think we should always be careful with words like “obvious” or “easy”, since they reflect whatever we’re already used to.
In “slow mode” thinking, of course, Zig doesn’t have macros, the inner function call must be evaluated before the outer one, any side effect before returning the Boolean will execute, and the outer function becoming a no-op can have no effect on that.
But when we’re programming, we’re using the slow mode for the domain at hand. It’s easy to fall back on a fast-mode rule from another language “as long as I put it between these parentheses, it’s as though it doesn’t exist”, even though in Zig, that’s not true.
That conflict can make the actual behavior counterintuitive, with more practice it becomes “obvious”. But obvious is a standpoint fact, not an inherent one.
I agree that there’s no profit to be had in getting upset with Zig for not working like C does in this one matter. But it’s easier not to once the bigger picture is understood.
See, I like it. It’s common vocabulary, I can use it to communicate with other people who might use my code, including myself, later. At this point I find the behavior “obvious”, so that helps.
If I saw this in a function:
if (is_safemode and bob != your_uncle) unreachable;
I’d need at least a moment to wonder if they don’t realize that this is spelled assert.
It’s also easier to point a student at std.debug.assert, rather than explain the several moving parts which would go into implementing it by hand, even though it is in fact quite simple. But you do have to know about "builtin", safety modes, comptime elision of if branches, and the behavior of unreachable; and no one starts out with Zig knowing any of these things.
I think the problem with assert comes from that there are two groups of people/language on what it should mean:
- “Computer/Reader, you can assume that this statement should always be true”
- “Computer, I assume that this statement is also true, please check that it actually is”
If you read for example through NASA’s/JPL’s “The Power of 10”, you will think that they are in the second camp, while if you read through how the C standard thinks of it (or has defined it), it’s the first camp.
If you’re manually checking if you’re in debug mode or not, then you might as well not use assert(). It’s easier to write this:
if (is_debug) {
if (execute_expensive_thing()) @panic("Wife is angry");
}
That way you can fail with a message.
std.debug.assert() just isn’t particularly powerful. That’s why I was thinking maybe it’s worthwhile to having dedicated assert mechanism based on the test keyword. The key benefit is that (a) a test block can have a label and (b) you can return error in a test block. The above could be expressed as such if we have such a mechanism:
test "is wife angry" {
try execute_expensive_thing();
}
So instead of an explicit panic, it’d be triggered by an error. That’d give us the ability to use functions in std.testing for the purpose of asserting. Meanwhile the label is useful when you actually intend to distribute a program compiled under ReleaseSafe (meaning with debug info stripped).
I seldom do actually, that was just minimal code to illustrate the pattern. When I’m going to that much effort, I agree with you, it’s nice to print something less generic if and when the assertion fails.
The idea of basing a ‘fancy assert’ library on std.testing has merit, it’s come up before. I’ve even considered writing one, and then, haven’t done so. You can sort of borrow std.testing itself with a catch @panic, but I’ve always backed out of that decision when I’ve made it.
It just turns out to be pretty easy to write the asserting code I want, is all. std.debug.panic does formatting when I want that. I have a devpanic function in one project, it panics in .Debug and otherwise logs an error, fits the philosophy of what I’m doing there. Couldn’t have taken me fifteen minutes to write it, I mean, here it is:
pub const is_debug = builtin.mode == .Debug;
/// Panic only in debug mode, otherwise log as error
pub fn devpanic(comptime fmt: []const u8, args: anytype) void {
if (is_debug) {
std.debug.panic(fmt, args);
} else {
logger.err(fmt, args);
}
}
This could take an assertion bool as the first argument? But I thought it was clearer to use an if. ¯\_(ツ)_/¯
Still, if someone does want to tackle enhanced assertions, and it catches on, that would be the best way to get it into the standard library.
I don’t love the idea of overloading test, sorry, but I’m confident that a good assertion system wouldn’t need that. Right now it’s easy to reckon with the test keyword, it’s a separate program mixed in with the one you’re writing. Having it also control some runtime code inside function context seems untidy to me.