How to assert for fun and profit (2022)
aarsen.me
aarsen.me
I've done things like the following many times:
if (x != 5) {
... 40 lines here ...
} else {
assert(x == 5);
... more lines ...
}
The main purpose of the assert is to remind the reader that x == 5. There was a time where I would use a regular comment for a reminder like this, but since it's something the computer can check, I mine as well turn my regular comment into a checked comment. if (x != 5 && x != 6) {
... 40 lines here ...
} else {
assert(x == 5);
... more lines ...
}
Because of the 40 intervening lines the person might never see your assertion!If you work in a project with automated tests that do not have sufficient coverage, this is just a spectacular way of blowing up production.
Assertions are bad because they are checked at runtime which is too late. The ideal checked comment should have the checks happen at compile time. Something like Liquid Haskell.
If your devs and QA team (you do have QA and not just rely on automated tests I hope) don't trigger this assert *before* deployed to production then there's something seriously wrong with your development workflow.
After all, the code after that assert also assumes that the (now invalid) assumption that was checked by the assert is true. Without the assert telling you exactly what is wrong and where, you'd have a much bigger problem: some piece of code that now runs under wrong assumptions, and which may or may not cause much harder to diagnose problems further down the road.
Think of asserts as trip-wires, they are most useful at the entry of a function and prevent an error from propagating (because the earlier an error condition is caught, the easier it is to diagnose).
Or does QA switch to release builds at some point?
When asserts fail for QA, what do they see/collect?
That's just a convention, and IMHO not a good one. All the C++ games I helped shipping had asserts enabled in the release exe. We had our own custom assert macros, and different levels of assert checks (e.g. special "hot path asserts" were not included in release builds, but that was an exception and should be avoided - specifically, range-check asserts for array accesses were never removed since this was the most common source of problems). In general, the performance difference between keeping asserts in or removing them was mostly negligible (IIRC less than 2% in a typical frame), the only downside is that the executable becomes about 25% larger (and easier to reverse engineer) because of the embedded assert condition strings. This would have been solvable by replacing the assert strings with comptime hashes, but we didn't deem this important enough to implement).
In addition, the custom asserts also generated a stack trace and a mini dump for easier post mortem debugging.
> When asserts fail for QA, what do they see/collect?
A MessageBox with:
- the assert condition string
- filename and line number of the assert
- an optional printf-style programmer message (depending on the assert macro type)
- the pretty-printed function/method name which contains the assert (very useful for generic code, e.g. you don't just see `Array<T>::push()` but also the resolved template parameters)
- a stack trace (we generated a PDB file also for release builds to make the 'inhouse' stack traces human readable, but didn't ship the PDB to users - so stack traces for the shipped game would just display raw addresses, but those could be resolved on our side with the PDB we were storing for each release version - we wrote an extra inhouse tool for that)
- plus a mini dump is written for post-mortem debugging, but with all the information above that was hardly ever needed for figuring out what the problem was
The content of Windows MessageBoxes can be Copy-Pasted, and that's what QA was supposed to do when writing a ticket.
PS: also important to note that we didn't use the C++ stdlib (with very few exceptions, like std::sort), e.g. most importantly we wrote our own container and string types).
Except for writing your own stdlib. How long did that take? Was that to enhance bounds checking?
What I still do in my open source libraries is to allow overriding the assert macro so that people can integrate their own assert implementation (along with allowing to override memory allocation).
> Except for writing your own stdlib. How long did that take? Was that to enhance bounds checking?
TBH the main reason was that I think the C++ stdlib APIs have terrible ergonomics. We modelled our container classes after C# containers and some common sense. Full control over memory allocation and asserts was just a side effect.
Great, I'd rather have this error cause a loud and obvious failure that can be easily hotfixed, than have it lead to subtly incorrect behavior that goes unnoticed for days and takes another day to debug.
if (condition):
...
else:
assert(!condition)
...
When you could instead
if (condition):
...
else: #if (condition)
...Or otherwise explicitly comment the condition that was checked earlier. At least then if the comment is unchanged, whoever is touching your code will assume the comment is outdated, rather than assuming that you're asserting that x should be some undocumented magic number.
If you don't rely on `x == 5` in the else branch it's a bad idea, and too many of these spurious assertions will create a habit of just removing assertions whenever they fail.
The assertion didn't cause you to have limited test coverage that let you edit the branch condition without detecting that it introduced a bug in the false branch.
Yes, if you've got a language that supports compile time checking of complex invariants then that's awesome! But few languages support this today.
I also used to think it might be preferable to crash in some domains. With stories like https://news.ycombinator.com/item?id=37461695 it makes me think perhaps there are none.
In any case my main point is that assertions would be perfect if only they are checked at compile time.
I prefer what Java does: Assertions are disabled at runtime by default, however you can turn them on without requiring a different code-release and you can even do it for specific packages/classes.
So you might even use them in production: If feature X is already failing in some bizarrely uninformative way, turning on assertions in relevant code may provide you a better hint to what's going awry.
Also isn't failing the assertion the whole point here? Without the assertion you would have a possibly hard-to-find bug rather than an assertion failure.
Wouldn't that be nice! Seems like you're conflating two different classes of failures, though. There's no way to bypass runtime constraints entirely, though—all you can do is move it out of the production code and into tests, which takes a great deal of effort compared to an assert (which work just as well under tests, btw).
Also, until almost all the world's software has been rewritten in Idris, compile time checks just cannot do very much. This is a reality of our century.
Keep in mind that my original example isn't really about checking that x == 5. The focus is not on checking integer values, it's just an example. Although, again, even something as simple as checking integer values is beyond the compile time abilities of our compilers. Halting problem sends its regards!
You do not need to have the entire world's software rewritten in Idris (which I don't even like that much). This is purely local reasoning that can be accomplished by minor changes to existing languages if we want to. The compiler doesn't need access to the world. It is you who are unnecessarily raising the bar for what you expect compilers to do and then throwing in the towel just because the compiler can't actually do it.
Any argument that invokes the halting problem has a standard response: you don't need to handle every case; perfect is the enemy of good. Furthermore it is often better to have the programmer rewrite the code to help the compiler deduce these facts, because helping the compiler also helps future readers of the code.
I mean yea, it'd be great if programs propagated every possible error state or violated invariant possible up the stack. I don't see that happening any time soon, nor that being worth the effort. When was the last time you checked and propagated allocation failure? I'm guessing not in a long time, unless you're working with a runtime where allocation failure is explicitly handled as part of normal program progression (like the `alloc` libc family) and where you expect to run into memory limits.
Granted, exceptions are a good deal easier to handle "invisibly", but those have notably been absent from this discussion except in the case where they are unhandled as an assert might trigger.
[1] https://github.com/spc476/mod_blog/blob/master/src/backend.c...
For me assertions are for invariants. The code has no expectation of successful execution
I also like (borrowing from testing) the idea of expects, where we can hope that the object is in a specific state, but have an explicit compensation path for when it isn't, perhaps objects that need to be migrated to a new schema etc.
I feel that assertions are the first line of defense against programming judgement errors/omissions and are much easier to write in the given context than unit tests.
Of course in theory that last bit shouldn't make any difference, but then I'm sure you've already heard the old joke about the difference between theory and practice..
It's not a joke, it's a fact of life that we learn soon after graduating, and then relearn again, and again, and ...
In the extreme case, I can imagine ditching all your small-scale unit tests for a combination of asserts and end-to-end integration tests.
It's better to do both, asserts and tests are not mutually exclusive, they complement each other.
Presumably the assert is used because it doesn't consume resources (ie effort) to write. Testing takes a lot of effort.
This section I like very much. I wish Haskell people had the balls to do this for the X -> x -> x sentences and a myriad of other Haskellisms.
I still pronounce assert(x) as "If you see the message, then the condition it asserts has FAILED"
Assertions are for programmers and their internal work. Exceptions for the rest, mainly the dynamic, user controlled inputs and their derivatives.
The author seems overconfident in this point. Like, what if the "input data" is from another software system I control?
Triggering an assert failure on bad user input is a little sloppy, but if there's no way to recover at that point anyways, I don't see why it matters.
Can someone enlighten me?
Re: asserting on user input. If you assert only then what happens on bad inputs when asserts are switched off in prod? Seems like an outcome you’d like to control at the time rather than allow to propagate (by writing bad data to a db and only being discovered months later, for example).
Good point
i get that its better than memory corruption. but ... is it better than just muddling through and reporting you cannot do operation x?
If wrong input triggers an assert, that means that the input validation is incomplete (and in that case the assert may prevent further much worse damage, and also provide important information to the programmer who's going to fix that bug).
Basically, asserts should never trigger if the user "does something wrong".
Irrespective of throwing an error or returning an error you need to handle it somewhere. If you are returning an error then the type system can handle it and the person calling your function can get an idea.. while if you are throwing an error in most of the languages your caller function will not get an idea that this function can throw an error.
You can have your language hide exceptions from the control flow if you wish, but they are still things that result from function calls that you should deal with. Why not make them front and center?
Consider the following pseudocode:
// Idris
binarySearch : SortedList a -> a -> Maybe Nat
binarySearch = ?implementation
// Java
int binarySearch(List<T> elems, T elem) {
assert isSorted(elems);
throw new NotImplemented();
}
(The generic type parameter should also implement Comparable or similar, but I digress).In this case, it would be nonsensical to pass an unsorted list to either procedure, so we prevent it - in Idris' case by using dependent typing; in Java's case by using assertions.
To contrast, consider the following:
// Idris
readLines : Path -> IO (Either FileError (List String))
readLines = ?implementation
Here, the FileError represents a number of things that could go wrong: no file existing at the given path, lack of permissions, running out of memory during read and so on. However, this is not misuse of the procedure - these are all expected deviations from the happy path, and hence we model that in the type definition.(I've omitted a corresponding Java example, as following a similar style would be terribly non-idiomatic. Java prefers throwing and catching exceptions to manage non-happy paths)
Note that we can't constrain the Path parameter to only resolve to files that exist. Doing so would require knowledge of the current filesystem state, e.g. the type signature would be something like the following:
readLines : (fs : FileSystem) -> (p : Path) -> fileExists fs p -> IO (Either FileError (List String))
readLines = ?implementation
To summarize, use preconditions, assertions or type-level constraints to prevent misuse of procedures. Return errors (using sum types, for example) when it is known and expected that the result of a procedure can deviate from the happy path. You may refrain from modeling conditions at the type level, even if possible in principle, as it may be prohibitively complex.Is this true any more? I feel like most languages, relative to popularity, have compiler-enforced thrown error handling.
Especially painful when using any of the functional style stream operations, like `map`, `filter`, etc, or any other higher order function library. Most take in the standard `Function` or `BiFunction` interface arguments, which will not support method referencing for anything which includes a checked exception as part of its signature.
auto map(collection<T> C collection, function<T> F f) -> invoke_result<F, T> throws(invoke_exception<F, T>);So is returning result or error via Either/Expect/Result sum types. Exceptions or Expected, code gets messed up either way.
The later fits nicely into the `Function<,>` interface, `Function<T, Result<R, Err>>` vs the former requires a different interface entirely, something like `FuncE<T, R, Err>` where `Err` breaks out into an argument for the throws value of the signature.
Because most of the functional libraries in Java work with Function, BiFunction, etc, we end up with incompatible arguments for common patterns such as `map`, `filter`, etc.
TBH, I don’t understand how the absence of checked exceptions or Either in most languages is tolerated!
It seems an unpopular opinion, but I like Checked Exceptions and wish more languages had them. Most of the "problems" people mention about CEs boil down to the damage done by lazy developers who don't want to think of separation-of-concerns/decoupling/layering in their own code, and that can be curbed by better syntactic sugar.
Is this true if the function forwards the value to another function, which may do the same thing?
I don't agree with this. I'm quite happy to assert that an external function call worked.