I started liking asserts to mark every clearly invalid state my function can happen to be run with. But at the same time, I feel like asserts give quite poor debug information - only stacktrace containing line with assertion is printed, which is ok, but forces me to give some descriptions as inline comments, and those can get long.
So, do you also put some inline comments(I do that), or you modify panic handler and use custom assert that also takes comptime message, that is put into some section in the binary where it can be retrived by adress(Now I think I might try doing that)?
Some code for my IRC server/client (WIP):
fn take_delimiter(line: []const u8, cursor: usize, delimiter: u8) usize {
std.debug.assert(cursor < line.len); // cursor must point at position in line
std.debug.assert(line[cursor] == delimiter); // cursor must rest on delimiter
const newCursor = v: {
for (cursor.., line[cursor..]) |i, c| {
if (c != delimiter) break :v i;
}
break :v line.len;
};
std.debug.assert(cursor < newCursor); // cursor must be moved forward
std.debug.assert(newCursor <= line.len); // cursor must point at position in line or its end
if (newCursor == line.len) {
for (line[cursor..]) |b| std.debug.assert(b == delimiter); // if end, line must consists fully from delimiter
} else std.debug.assert(line[newCursor] != delimiter); // if not end, cursor must not point at delimiter
return newCursor;
}
(I forgot wdy I didnt use std.mem.SplitXXX, but ^ is nicer for me to use)
I only document the complex assertions with a comment. Meaning those that require some additional thinking to get into the right head space for debugging.
Depending on what mode you expect to build this in I would also remove bounds checks as those are automatically inserted by the Zig compiler.
So assuming a safe release mode I would’ve written your example like this:
const assert = std.debug.assert;
fn take_delimiter(line: []const u8, cursor: usize, delimiter: u8) usize {
assert(line[cursor] == delimiter);
const newCursor = v: {
for (cursor.., line[cursor..]) |i, c| {
if (c != delimiter) break :v i;
}
break :v line.len;
};
assert(cursor < newCursor);
if (newCursor < line.len) {
assert(line[newCursor] != delimiter);
} else {
assert(newCursor == line.len);
// if end, line must consists fully from delimiter
for (line[cursor..]) |b| assert(b == delimiter);
}
return newCursor;
}
Notice how I’ve also changed the if statement around as it’s more readable for me. And then noticed that the assertion that was directly above(newCursor <= line.len) is a bit redundant and could be made simpler by moving it into one of the branches.
Sometimes it’s also helpful to log something before firing the assert. So to build on what @vulpesx said:
fn take_delimiter(line: []const u8, cursor: usize, delimiter: u8) usize {
assert(cursor < line.len);
assert(line[cursor] == delimiter);
const newCursor = v: {
for (cursor.., line[cursor..]) |i, c| {
if (c != delimiter) break :v i;
}
break :v line.len;
};
assert(cursor < newCursor);
if (newCursor < line.len) {
assert(line[newCursor] != delimiter);
} else {
assert(newCursor == line.len);
// if end, line must consists fully from delimiter
for (cursor..line.len) |i| {
const b = line[i];
if (b != delimiter) {
log.err(
"Expected delimiter '{x}'({c}). Got: '{x}'({c}). Index: {x}, Line: {s}",
.{ delimiter, delimiter, b, b, i, line },
);
unreachable;
}
}
}
return newCursor;
}
here is a YouTube short here from ThePrimegen, I think it’s the first video I saw on the matter and it summarizes the howto another one
as for me, when I assume something mentally and it is not apparent in the code, it is then when I do an assert.
or when I want to ensure a data-structure e.g ‘the root node’s value should always be an empty string’ or ‘a special node should only be placed last in children list’
or for if statements when I do the if (!condition) return;, like if (!logged_in) return ...; if (!allowed_here) return ...; std.debug.assert(cant_think_of_acondition_rn)
IMO assert(condition) is best. Important code has a lot of assertions:
If you require any kind of comments on asserts, you heavily penalize adding assertions.
The great way to ease debugging is to ensure that baseline software has negligible amount of bugs, and to implement thorough generative testing for new functionality:
That way, any bug you have is in the new functionality, and is easy to tackle as the code is fresh in your mind.
This is an extreme position, of course, but I think one worth trying!
It’s purely a style thing, but in that example I would write the post-asserts like this:
assert(cursor < newCursor);
if (newCursor < line.len) assert(line[newCursor] != delimiter);
if (newCursor == line.len) assert(std.mem.allEqual(u8, line[cursor..], delimiter));
if (newCursor > line.len) unreachable;
I just think it reads a bit more clearly, but it could just be a personal thing.
But yes, as others say, I try not to rely on comments with asserts (most of the time). If you can’t understand an assert from its context, I would lean more towards rewriting the code/asserts rather than add comments.
Here’s another example from sqlite:
The asserts are easy to understand, without comments, once you read the function documentation. The conditions in the asserts tell you the logic of what is assumed.