The Perils of Pointers in the Land of the Zero-Sized Type
Imagine writing a translation function that transforms internal errors into public API errors. In the first iteration,
you return nil
when no translation takes place. You make a simple change — returning the original error instead of
nil
— and suddenly your program behaves differently:
translate1: unsupported operation
translate2: internal not implemented
This program prints two different results from the nearly identical functions (Go Playground). Can you spot why?
|
|
The key to this divergent behavior lies in NotImplementedError
being a zero-sized type (ZST). ZSTs are types that
occupy no memory. Common examples include empty structs struct{}
or a zero-length array [0]byte
. While highly useful
for tasks like signaling without data transfer (e.g., (chan struct{}
in
Context.Done()
) or implementing sets (map[string]struct{}
), their
interaction with pointers can be particularly tricky.
So, why does this simple change lead to different outcomes?
Pointergeist: The Zero Dimension
To understand this behavior, let’s see what the Go compiler does behind the scenes.
The Great Pointer Escape
When we run go build -gcflags='-m=1' .
, we get something like:
...
./main.go:14:17: err does not escape
./main.go:15:13: &NotImplementedError{} does not escape
./main.go:22:17: leaking param: err to result ~r0 level=0
./main.go:23:13: &NotImplementedError{} does not escape
./main.go:31:12: ... argument does not escape
./main.go:31:50: &NotImplementedError{} does not escape
./main.go:31:43: &NotImplementedError{} does not escape
./main.go:32:12: ... argument does not escape
./main.go:32:50: &NotImplementedError{} escapes to heap
./main.go:32:43: &NotImplementedError{} does not escape
./main.go:36:9: &NotImplementedError{} escapes to heap
The critical line is &NotImplementedError{} escapes to heap
- this refers to the pointer created in the call to
DoWork()
in line 32. It escapes because the parameter in line 22 is a potential return value (return err
), which is
fed into fmt.Errorf
.
Escape analysis determines whether variables can remain on the stack or must be allocated on the heap. When the compiler can’t prove a variable won’t outlive its function, it “escapes” to the heap.
Honey, I Shrunk the Alloc
When Go allocates memory for zero-sized types, it applies a clever optimization: the runtime returns a pointer to
runtime.zerobase
(see the
mallocgc implementation), a
single static variable shared by all heap-allocated ZSTs, instead of allocating new memory for each instance.
This means multiple &NotImplementedError{}
instances that escape to the heap typically point to the same address.
On the other hand, the &NotImplementedError{}
in the comparison in Translate2
does not escape. It gets the address
of a (zero-sized) variable on the stack.
So in Translate2
, the comparison err == &NotImplementedError{}
involves two pointers that, due to one being on the
stack and the other pointing to runtime.zerobase
, have different memory addresses, thus evaluating to false.
By Design: The Equality Paradox
In Translate1
, since the err
parameter (which is another instance of &NotImplementedError{}
) does not escape
within this function, and the &NotImplementedError{}
literal in the comparison also doesn’t escape, both are
effectively stack-scoped. For stack-allocated ZSTs, the compiler may optimize by assigning them the same memory
address, but this is not guaranteed. This contributes to the unreliability of direct pointer comparisons for ZSTs, as
the outcome can vary depending on compiler decisions and optimizations.
This is not a bug — the Go language specification explicitly states that the equality of pointers to distinct zero-size variables is unspecified:
“pointers to distinct zero-size variables may or may not be equal.”
Depending on the path the compiler takes, our program’s behavior changes, making our logic unsound. The bug is in the program above, not the compiler.
You can easily validate the issue by making the error type non-zero-sized:
|
|
The errors.Is
Illusion: Escaped Zero-Sized Types All Look the Same
I can hear you saying “Yes, but you should compare errors with errors.Is
.” You absolutely should. The example
becomes a little more complex (Go Playground):
|
|
It’s important to understand that internally errors.Is
first attempts a
direct pointer comparison
— the same pointer comparison as above when comparing concrete error values:
“An error is considered to match a target if it is equal to that target”
Arguments passed to functions that are not fully inlinable (like errors.Is
), or whose lifetime cannot be determined at
compile time to be confined to the caller’s stack, will usually escape to the heap. As mentioned earlier, the Go runtime
frequently optimizes them to point to a single static address.
This makes errors.Is
work more predictably for heap-allocated ZST pointers, but you’re still relying on an
implementation detail rather than guaranteed behavior.
A New Linter of Hope
zerolint
is a static analysis tool specifically designed to detect
problematic usage patterns involving zero-sized types in Go. For example, it will flag the following:
go install fillmore-labs.com/zerolint@latest
zerolint .
/path/to/your/project/main.go:10:7: error interface implemented on pointer to zero-sized type "example.com/project.NotImplementedError" (zl:err)
/path/to/your/project/main.go:27:6: comparison of pointer to zero-size type "example.com/project.NotImplementedError" with error interface (zl:cme)
/path/to/your/project/main.go:35:6: comparison of pointer to zero-size type "example.com/project.NotImplementedError" with error interface (zl:cme)
Preventing Pointer Pandemonium
This tool helps avoid the errors discussed by catching potential issues before they lead to runtime bugs, saving debugging time and preventing unexpected behavior in production.
Identifying Unreliable Comparisons: The linter flags comparisons of pointers to zero-sized types, such as
errors.Is(err, &NotImplementedError{})
. By highlighting these comparisons during development or in CI/CD pipelines,zerolint
prompts developers to reconsider their logic. Instead of relying on pointer equality (which is unspecified for distinct ZSTs), developers are encouraged to use sentinel errors or reconsider whether a value type is more appropriate.Discouraging Pointer Receivers for ZSTs: The linter warns when methods are defined on pointer receivers for zero-sized types (e.g.,
func (*NotImplementedError) Error() string
). While syntactically valid, using pointer receivers for ZSTs is often unnecessary and can lead to the kind of pointer-related confusion the article describes. Value receivers (func (NotImplementedError) Error() string
) are usually clearer and avoid any ambiguity about pointer identity for ZSTs, as comparisons of values of these types (not pointers to them) are straightforward.zerolint
encourages this safer practice.Promoting Idiomatic Go: By flagging these patterns,
zerolint
helps enforce idioms that lead to more robust and maintainable Go code. It steers developers away from relying on implementation details or unspecified behaviors of the compiler and runtime concerning ZST pointers.
The kinds of bugs arising from ZST pointer comparisons can be subtle and hard to reproduce, as they might depend on
compiler versions or specific build conditions. zerolint
catches these potential issues before they lead to runtime
bugs.
In essence, zerolint
helps developers adhere to best practices, such as those discussed next regarding struct
embedding and those summarized in this post’s conclusion.
Practical Application: The Art of Embedding
Let’s look at some common scenarios, like struct embedding, where these principles and a tool like zerolint
become
particularly relevant.
Value Embedding: Saving Private Memory (With Free Methods)
Zero-sized defined types can group a set of methods for a default behavior or interface implementation.
A method call on a zero-sized receiver has the same runtime behavior as a pure function call, since there’s no instance data to pass on the stack. Passing a ZST as a parameter is essentially free at runtime. This is different for pointer, though, as we will see below.
Assuming we want to have a variant of afero.OsFs
with the Remove
method disabled, we would do:
|
|
This prints 0, demonstrating that we get all the methods from afero.OsFs
with zero memory overhead.
Common Pitfall: The Eight-Byte Burden & The Nil Panic
Now let’s see what happens when we use pointers instead:
|
|
This approach has two problems:
First, adding a level of indirection to zero-sized types negates their primary memory benefit. A pointer to a ZST itself
consumes memory (typically 8 bytes on a 64-bit system for the pointer itself), whereas the ZST value consumes zero
bytes. Since the pointer could mostly be the address of runtime.zerobase
or nil
, we waste 8 bytes for very little
information.
Second, the embedded pointer OsFs
is nil
because it’s uninitialized. While Go allows calling methods on nil
receivers if the method is defined to handle nil (e.g., by checking if the receiver is nil), in this case,
a.Open("test.txt")
attempts to dereference the nil
pointer (as Open
has a value receiver), causing a runtime
panic.
Interface Evolution: gRPC-Go Case Study
ZSTs are excellent for providing default implementations for interfaces, especially when embedded by value. Since an embedded ZST occupies 0 bytes, it adds no size to the surrounding struct, so there’s no overhead.
A prominent example of this is found in gRPC for Go, specifically in server implementations. To ensure forward
compatibility, UnimplementedXXXServer
structs are provided:
// UnimplementedXXXServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedXXXServer struct{}
func (UnimplementedXXXServer) GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
}
type XXXServer struct {
UnimplementedXXXServer
}
func (XXXServer) GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) {
...
}
For some background see gRPC-Go’s issue 2318 and issue 3669.
The Tao of ZST: A Guide to Robust Go
Zero-sized types (ZSTs) are a Go feature offering memory efficiency and powerful design mechanisms. However, as we’ve
seen, their interaction with pointers can lead to subtle, hard-to-diagnose bugs if not handled with care. Relying on
specific compiler or runtime behaviors (like all escaped ZST pointers resolving to runtime.zerobase
) for pointer
comparisons is a path to fragile code.
To harness the benefits of ZSTs while avoiding the pitfalls, keep these core principles in mind:
Embrace Values, Eschew Pointers: Whenever possible, use ZSTs as direct values (e.g.,
myZST
) rather than pointers to them (&myZST
). This approach completely avoids the ambiguity of ZST pointer comparisons.Value Receivers are Preferred: Define methods on value receivers (e.g.,
func (z MyZST) Method()
) for ZSTs. Pointer receivers (e.g.,func (z *MyZST) Method()
) are rarely necessary for ZSTs and can reintroduce issues with pointer comparisons.Embed Values, Not Pointers: When composing structs, embed ZSTs directly (e.g., embed
MyZST
) to maintain their zero memory overhead benefit. Embedding pointers to ZSTs (embed*MyZST
) negates this by adding pointer overhead and the risk of nil pointers.Be Mindful of ZSTs When Designing API Contracts: For example, use functional options instead of pointer-based configuration for zero-sized option structs.
Mind API Boundaries: The primary legitimate use case for pointers to ZSTs arises when an external API you must interact with explicitly requires a pointer receiver or argument. In such instances, the choice is driven by compatibility.
Leverage Static Analysis: You don’t have to navigate these nuances alone. Tools like
zerolint
are invaluable for automatically flagging problematic ZST pointer usage in your codebase, guiding you towards safer and more idiomatic Go practices.
The key insight is that zero-sized types are powerful as values but risky and wasteful as pointers. Understanding this distinction will save you from subtle, hard-to-debug issues that can vary between compiler versions and build conditions.