Compile-time assertions in Go
January 24, 2017
This post is about a little-known way to make compile-time assertions in Go. You probably shouldn’t use it, but it is interesting to know about.
As a warm-up, here’s a fairly well-known form of compile-time assertions in Go: Interface satisfaction checks.
In this code (playground),
the var _ =
line ensures that type W
is a stringWriter
,
as checked for by io.WriteString
.
package main
import "io"
type W struct{}
func (w W) Write(b []byte) (int, error) { return len(b), nil }
func (w W) WriteString(s string) (int, error) { return len(s), nil }
type stringWriter interface {
WriteString(string) (int, error)
}
var _ stringWriter = W{}
func main() {
var w W
io.WriteString(w, "very long string")
}
If you comment out W
’s WriteString
method, the code will not compile:
main.go:14: cannot use W literal (type W) as type stringWriter in assignment:
W does not implement stringWriter (missing WriteString method)
This is useful. For most types that satisfy both io.Writer
and stringWriter
,
if you eliminate the WriteString
method, everything will continue to work
as it did before, but with worse performance.
Rather than trying to write a fragile test for a performance regression using
testing.T.AllocsPerRun
,
you can simply protect your code with a compile-time assertion.
Here’s a real world example of this technique from package io.
OK, onward to obscurity!
Interface satisfaction checks are great.
But what if you wanted to check a plain old boolean expression, like 1+1==2
?
Consider this code (playground):
package main
import "crypto/md5"
type Hash [16]byte
func init() {
if len(Hash{}) < md5.Size {
panic("Hash is too small")
}
}
func main() {
// ...
}
Hash
is perhaps some kind of abstracted hash result.
The init
function ensures that it will work with crypto/md5.
If you change Hash
to be (say) [8]byte
, it’ll panic when the process starts.
However, this is a run-time check.
What if we wanted it to fail earlier?
Here’s how. (There’s no playground link, because this doesn’t work on the playground.)
package main
import "C"
import "crypto/md5"
type Hash [16]byte
func hashIsTooSmall()
func init() {
if len(Hash{}) < md5.Size {
hashIsTooSmall()
}
}
func main() {
// ...
}
Now if you change Hash
to be [8]byte
, it will fail during compilation.
(Actually, it fails during linking. Close enough for our purposes.)
$ go build .
# demo
main.hashIsTooSmall: call to external function
main.init.1: relocation target main.hashIsTooSmall not defined
main.init.1: undefined: "main.hashIsTooSmall"
What’s going on here?
hashIsTooSmall
is declared without a function body.
The compiler assumes that someone else will provide an implementation,
perhaps an assembly routine.
When the compiler can prove that len(Hash{}) < md5.Size
,
it eliminates the code inside the if statement.
As a result, no one uses the function hashIsTooSmall
,
so the linker eliminates it. No harm done.
As soon as the assertion fails, the code inside the if statement is preserved.
hashIsTooSmall
can’t be eliminated.
The linker then notices that no one else has provided an implementation
for the function and fails with an error, which was the goal.
One last oddity: Why import "C"
?
The go tool knows that in normal Go code, all functions must have bodies,
and instructs the compiler to enforce that.
By switching to cgo, we remove that check.
(If you run go build -x
on the code above, without the import "C"
line,
you will see that the compiler is invoked with the -complete
flag.)
An alternative to adding import "C"
is to add an empty file called foo.s
to the package.
I know of only one use of this technique, in the compiler test suite. There are other imaginable places to apply it, but no one has bothered.
And that’s probably how it should be. :)