I’d like to mention Go. This seems to me like a pointless quibble about how to catch and handle errors, the kind of argument you see buried in the HN comments and gloss over because you’ve heard it a bunch of times before. The assert() macro in C is roughly just if ... { panic() } in Go. You want that, use it. So the idea that getting rid of assert is “essentially telling developers that they are not allowed to do run-time verification of invariants” is completely nonsensical.
The Go philosophy against assert() and assertion libraries is largely that the error messages produced by assert() are low quality. The idea is that you should not just have a stack trace, you should have a good error message. The assert() macro can give you a stack trace with some effort, but rarely gives you a good message. Good use of panic() is easy and gives you both. It’s also used mainly for runtime invariant checking, although there are some other ways it can be used (since it can be caught).
I dont kbow about go, but in C or C++, it's a common idiom to do something like:
asset(condition && "static reason why we expected this condition to be true");
Unfortunately, this doesn't allow for a dynamic string, but does generally present a message about the assert. Also, if properly configured, a failed assert results in an abort and should generate a core/crash dump. Pretty useful to be able to load in a debugger for further inspection.
But to the point about provide a good message: that's on the developer, and its going to be true no matter the language.
This will unwind the stack, executing destructors. Which is the last thing that you want to do in a scenario where conditions that were supposed to be invariants are broken. If your invariants don't hold, you don't really know what's going on - think of it as high-level undefined behavior. The faster you stop executing code within the security boundary (usually process), the better. This is exactly why assert() calls abort(), and why you should do so as well as soon as you've logged enough data to diagnose the issue.
It still incurs a nontrivial runtime cost, unless you've ifdef'd it away in release builds, but even so it is a heavy price to pay in debug or semi-debug^ builds for things like container operator[] bounds checking. The best implementation of assert I've seen raises SIGTRAP on error, which neatly lets you attach a debugger and break when the error occurs, at which point you have the full stack trace and program state.
^ semi-debug in our case means minimal optimisations and debug asserts enabled.
Bounds checking is one of those things that's easy for a compiler to hoist out of a loop and for branch prediction to guess right when not. It has very high value to cost ratio.
>why do you care about the runtime cost of something that makes your program crash ?
I think both parent commenters (htfy96 and Asooka) got on a tangent because htfy96 substituted a throw exception for an assert() in gp's (hermitdev) code -- as if they were equivalent. However, exceptions have fundamentally different semantics from asserts and are not substitutes for each other. (For anyone unfamiliar with the different purposes of exceptions vs asserts, please read the top 2 answers in this Stackoverflow question.[0])
Things like array bounds checking (used as a runtime safety check instead of an "impossibility" check) would not be tested with assert().
To answer your direct question, programmers who use assert() correctly for "testing their understanding of the world by testing an impossible condition" don't want to pay a runtime penalty for it.
That is not true on most platforms in use today. The Itanium ABI uses "zero cost" exceptions which do not have any setup or runtime cost when an exception is not thrown (at the cost of making throwing exceptions much more expensive than other schemes). 64-bit Windows and arm64 use similar schemes.
The only "relevant" platform where exceptions aren't zero cost nowadays is 32-bit windows - not even 64-bit one. so no, absolutely not "in most cases".
Easier is to embed file and line number and possibly stringified assert expression via a macro. The string approach might help as a comment when reading the code, but suffers from all the problems comments do.
> The assert() macro in C is roughly just if ... { panic() } in Go
The article very clearly says that (unlike normal C behaviour) it's a no-op in release builds of SQLite, because otherwise they get a ~3x slowdown. Does Go compile away those `if` statements?
> Does Go compile away those `if` statements, or did you not read the article?
The HN guidelines specifically request that we not make comments like this.
> Please don't insinuate that someone hasn't read an article. "Did you even read the article? It mentions that" can be shortened to "The article mentions that."
Well, thank you for taking criticism so well! We all get a little snippy at times - goodness knows I've done it myself more than I want to admit.
I was about to delete my comment since it was no longer relevant, but maybe I will leave it here since your reply and edit are such a great example of how to be constructive and civil. (OTOH, let me know if you'd prefer for me to delete it.) Thanks!
I find the fastest way to introduce bugs into programs is to compile programs differently in development than in production. It also warps developer's perspectives of how their program performs. As they say at NASA, "Test what you fly, fly what you test."
That said, you can do this easily enough in Go. Define a package:
Have your build system generate the file that contains Debug = true. In optimization mode, make it Debug = false. As it is const, the compiler will propagate it, Assert will be inlined and the dead code removed. If you don't like relying on the compiler, have your code generator generate empty Assert functions. If you are working in a very bad code base that catches all panics, you'll need an extra 5 lines of code in the implementation of Assert, and your stack traces won't be pretty. (Roughly: put the condition check in a goroutine, block the Assert call on a channel signal from that goroutine.) I'd recommend the first project be to remove those panic catch-alls.
But really, all of this is only appropriate when you are far out in the weeds. Ship your program with those asserts. If they are too slow for production they are too slow for development.
"Test what you fly and fly what you test" is what we live by at SQLite. Testing is done three times:
(1) One test uses compiler and preprocessor options set up to measure coverage. This is not so much a test of SQLite itself as it is a test of the test logic, to verify that the testing provides 100% MC/DC.
(2) The second test runs with assert(), ALWAYS(), and NEVER() enabled. This is like a unit test. It verifies internal assumptions and state, at the expense of running 3x slower.
(3) Finally, we build as for delivery and test once again. This is the string test, where we "test what we fly".
All three test runs must give the same answer (modulo performance) before a release.
During day-to-day development work, we usually run (2), but occasionally toss in a (1) or (3) just to confirm that we haven't introduced any gaps in test coverage, or bugs that are masked by the debugging logic.
So you are correct that you should not do all your testing using one configuration and then deliver a different build. But that does not mean you can't have extra logic in your code that helps do unit testing and debugging during development and which is excluded from release builds. You just need to make sure that you rerun all tests in the release configuration.
If you do Assert(ExpensiveOp()), removing all of it is only a valid optimization if ExpensiveOp() is 100% side-effect free. But even when that is the case, the compiler won't inline ExpensiveOp() and go through the effort of proving that it's side effect free if it's sufficiently complex.
It’s not the if staments: for an assertion the panic branch should be ice-cold and likely proven by invariants up the stack frame — other assertions or plain old conditionals — so in theory should be optimized away every time. It’s the expression in the condition that’s often expensive. The assert macro eliminates these trivially with the preprocessor , but go cannot as easily because the condition expression may have side effects.
Help me understand why this matters? Is it really Hipp's argument that Go is unsuitable for serious systems work because invariant signaling in the code is too slow? That seems pretty nitpicky, and also ironic since the "serious" systems language to which he's comparing Go is C.
Not being able to have code that compiles in debug but doesn't in release is a pretty serious issue for CPU-bound performance-critical code. Otherwise, you're going to be commenting things out a lot or using preprocessors, both of which are annoying and slow down development velocity.
As an example, checking errors on GPU is enormously slow because you stall the CPU until the hardware FIFO drains every time you do. You can't ship software that checks for errors after issuing each DirectX or OpenGL call. But writing graphics code is also really annoying without a debug mode that checks for errors after each render command, because the failure mode of graphics programs tends to be "black screen" or "screen full of garbage".
Isn't a preprocessor the right place for something like this? It doesn't have to operate at the level of source code but if you want your actual code/AST, not just the behavior, to be different from dev to prod then you need a language that compiles to Go?
Baking that into the language seems like it just replaces Go with that meta-language and hides the Go underneath like how "C" is really CPP-lang with a C compiler underneath.
I would take anything Dr Hipp says very seriously. While he may be wrong, I wouldn't discard his thoughts without some facts to back it up. SQLite, both its functionality and amazing test suite, as well as his other achievements, certainly make him worthy of my respect.
Hipp's argument --- which comes kind of out of nowhere in his post --- is that Go is unsuitable as a serious systems language because it lacks compile-time asserts. Which is, I think, ironic, because his language is C, and so produces memory corruption vulnerabilities that languages like Go and Rust preclude.
I don't think assert got used correctly if it needs a meaningful error message, personally. When an invariant of my algorithm fails, I've generally found I have a fundamental logical error with no particular relationship to the point in the code where it fails.
Does panic() in Go give you source code line numbers and file names?
As far as I'm concerned, assert() has two jobs, both essential: Killing the program AND giving an error message which gives the developers knowledge of precisely where the deadly assert() is. Doing only one of those things leaves the job undone.
The Go compiler has `assert` and uses it for checking invariants, as mentioned in the article. I love Go, I'm down with the decisions the designers have made for the language but not exposing a proper and simplified assertion statement/expression while expecting the user to make their own doesn't sit well with me.
They already have precedence in this, having a special version of the language only the golang authors can use (e.g. generics and asserts), and then claiming that they're not useful or too complicated for others to use.
> The CORRUPT_DB macro is used in many assert() statements. In functional testing builds, CORRUPT_DB references a global variable that is true if the database file might contain corruption. This variable is true by default, since we do not normally know whether or not a database is corrupt, but during testing while working on databases that are known to be well-formed, that global variable can be set to false.
This is interesting - this really reveals their philosophy where they assume that until proven to be good, stuff is considered to be broken.
Not only does zig embrace this philosophy, it introduces the concept of "language-level assertions". For example, integer operations, both signed and unsigned, assert that no overflow occured. Likewise many of the casts, such as non truncating integer conversion, assert that the numerical value is unchanged.
In safety-protected build modes assertion failures trigger a panic, which is globally overridable, but the default behavior is to print a full stack trace.
Finally, the assertion behavior can be modified at any scope. So one can identify the performance bottlenecks and turn assertion failures into undefined behavior.
ALWAYS and NEVER don't pass-through their values consistently as claimed in debug builds, they pass through truthiness instead, with actual values in release builds. Perhaps this would be better for debug builds:
I've always found the name `assert()` to be particularly bad.
Many people do not know that their compilers optimise away assertions in `-O` ("release") builds.
They use assertions for control flow and input validation, which is very wrong.
I believe that could be trivially fixed by giving functions proper names that indicate what they do or what they are intended for. For example, calling such an optimised-away assertion function `debug_only_assert()` would immediately rule out such unintentional misuse.
> I believe that could be trivially fixed by giving functions proper names that indicate what they do or what they are intended for. For example, calling such an optimised-away assertion function `debug_only_assert()` would immediately rule out such unintentional misuse.
That is Rust's tack:
> [assert!] assertions are always checked in both debug and release builds, and cannot be disabled.
> Unlike assert!, debug_assert! statements are only enabled in non optimized builds by default. An optimized build will omit all debug_assert! statements unless -C debug-assertions is passed to the compiler.
Ha, reminds me of the time I was working on a project that worked great in debug mode and then crashed and burned when they went to compile it in release mode. Turned out someone had put the call to the initialization code inside an assert function. The bug took some time to figure out because all the crashes happened in code that was nowhere near the actual problem and of course when you turned on debugging the problem disappeared.
While I'm still a fan of the name `assert()`, one alternative that I like is `invariant()`. It seems to capture the idea a little better.
The debug vs release build is a bit of a problem still but I think part of the issue is around the idea that debug builds are too slow to use at all or take so long to build anyway so you might as well use release optimizations. This is something to address with faster compilers and better teaching (and perhaps encouraging folks to try learning how to use debuggers effectively).
I did not understand this comment, as they also wrote ALWAYS, NEVER and testcase. Why could you not write your own assert in Go? (I have not programmed in Go)
Go does have build tags, so one could have 2 files for the assert function, each with opposite build tag logic. It does allow for an effective NOP (it would be a stub function and I believe the compiler would eliminate that).
I'm wondering if it inlines it (and thus eliminates it).
But this would come with a performance penalty: it makes the function you assert() in no longer a leaf function (it calls assert()) and AFAIK, Go only inlines leaf functions. (So, any function in which you call assert() is no longer eligible for inlining.)
You can always log to stderr with a stack trace and exit with a non zero static code. I’m guessing they’re referring to the fact that the built in panic keyword can be “caught” further up the stack a la an exception.
It’s far better to let the caller decide. As a user of a library, I absolutely do not want a library terminating my process. Sure, some programs can benefit from this, but some programs need to guarantee that they keep running. Being able to recover (Go’s counterpart to panic()) allows the caller to decide which behavior they want.
I'm not sure I agree. There's a very, very small limited set of conditions that a client can recover from a condition that would trigger an assertion failure. Dont want an abort? Then use the release libs where those have been preprocesed out.
Conversely, at a former employer we used a set of 3rd party database libs for ODBC on Linux that for the smallest config error would emit nothing to stderr and call exit(1) from a shared lib. that was beyond annoying
> I was afraid someone would mention preprocessing out the asserts :)
Problem is, you need to understand what you're using, and like it or not, assert is defined as a macro by the C standard. And, assert is conditionally defined (and it's not the only conditionally defined macro).
I know its long, verbose, and dry; but if you want to understand C (or C++ for that matter), you really need to read the ISO standards that correspond to the implantation.
If you're in the same process as the code that had its invariant fail, how can you ensure that your process is in a safe state afterwards? A failed invariant, by definition, means that you're in uncharted territory - any anticipated failures are (assuming proper design) reported via the usual error mechanisms.
You could, but then when you get an error, you'll see the file and line number of the assert function rather than the code that actually produced the error.
I haven’t worked with a lot of code bases, but am I correct in assuming this level of precision with error handling is fairly rare? Impressive stuff.
Orthogonal: one of my favorite talking points about Erlang is the fact that nearly every line of code is an assertion, and that those assertions are always enabled, even in production.
You’d not want to use Erlang for truly high performance code, granted.
If you can tolerate a video, my talk from Midwest.io covers it to some degree.
It’s really just a happy byproduct of the overall design of the language and VM. The two primary features that make it happen are immutability and ubiquitous pattern matching.
# define testcase(X) if( X ){ sqlite3Coverage(__LINE__); }
If the testcase(condition) always evaluates to true, the code coverage analyzer would complain that the if ( X ) {sqlite3...} statement never evaluates to false and the branch coverage drops below 100%, the converse holds conversely if it was never evaluated to true in the first place.
Only if the code using the testcase macro gets repeatedly called in a way so that the testcase macro evaluates sometimes to true and sometimes to false, the coverage stays at 100%.
sqlite3Coverage() does some dummy work so that the call will not be optimized away by the compiler.
You're only using it locally for specific invariants. That doesn't have to depend on the system's complexity. For example assert(2+2==4) works the same, regardless of it's only a one line app, or in the middle of an OS kernel.
Probably the same mechanism by which most proofs work, not fully formal but it seems to follow. Without the proofs in comments (or transitively through other asserts) it's up to the reader to recreate the argument, though. The purpose is to be more explicit than a comment, I suspect, using the same notation as the code.
sqlite has extremely thorough test coverage with something approaching 100% branch coverage and such wide production usage it's basically proven that a lot of this stuff works.
The Go philosophy against assert() and assertion libraries is largely that the error messages produced by assert() are low quality. The idea is that you should not just have a stack trace, you should have a good error message. The assert() macro can give you a stack trace with some effort, but rarely gives you a good message. Good use of panic() is easy and gives you both. It’s also used mainly for runtime invariant checking, although there are some other ways it can be used (since it can be caught).