Zero cost debug assertions in Go
While I was working on my very own TUI library, I had this idea of bringing debug assertions to Go.
From dart.dev:
During development, use an assert statement—
assert(<condition>, <optionalMessage>);
—to disrupt normal execution if a boolean condition is false.
In production code, assertions are ignored, and the arguments to
assert
aren’t evaluated.
Many languages such as Rust, C and Zig have assertions built-ins, but Go doesn’t.
I don’t know the exact reason, maybe they’re not considered idiomatic but they’re definitely useful.
Assertions really shine when you want to enforce an invariant that can’t be encoded in the type system. They’re not mean to replace runtime errors but programming errors such as passing a nil pointer to a function that always expect a valid pointer.
How are assertions implemented?
Typically, assertions are implemented using either macros or conditional compilation.
Macros are great as they can print friendly, easy to read failure descriptions with the failing expression and its location (line number). This is how C assert works.
int even_num = 1;
assert(even_num % 2 == 0);
Will print:
Assertion `even_num % 2 == 0' failed.
Aborted
Another way is to use conditional compilation and include different assert
implementation at build time. Go supports conditional compilation via build constraints.
Similar to //go:embed file.txt
, //go:generate ...
and //go:noinline
constraints, //go:build ...
can be used to include or exclude content of a
file depending on go build
tags specified.
Building an assert like function is trivial:
// debug.go
//go:build debug
package assert
func Assert(cond bool, message string) {
if !cond {
panic(message)
}
}
// prod.go
//go:build !debug
// Note the "!" before debug.
package assert
func Assert(_ bool, _ string) {
// noop
}
If a program importing the above assert
package is build using
go build -tags debug
, panicking assert implementation will be included.
Voilà! Our implementation is now fully functional.
Going further…
While our simple implementation works, it sucks from a DX point of view as we can’t know asserted value unless it is specified in the message.
There are 2 ways to work around this limitation:
- Generate code for each assertion
- Create multiples specialized assert functions for specific usage
Generating code is generally the recommended way to tackle this kind of problem. Nevertheless, it adds complexity to the build process that may not be worth it. Personally, I would strongly reconsider using assertions as it adds an external tool with its own set of problems.
Creating multiple specialized assert functions seems more appealing as users of
the package can import it like a regular Go package. This option can also
improve code readability with descriptive function name such as
assert.LessThan
.
This is the solution I chose for my assert package which is a fork of the well-known testify package.