- Jh123x: Blog, Code, Fun and everything in between./
- My Blog Posts and Stories/
- Unexpected behavior of Golang nils/
Unexpected behavior of Golang nils
Table of Contents
Introduction #
In this article, I will be talking about the unexpected behavior of Golang nils, especially with regards to interface
s and struct
s.
This is based on golang version 1.21
.
There are some other examples shown in the unexpected-go website and I do encourage you guys to take a look at some other unexpected behaviors of golang.
Not all nil
s are the same. #
In Golang, not all nil
s are created equal.
Let us take a look at an example below.
package main
import "fmt"
type TestInterface interface {
Test()
}
type TestA struct {}
func (t *TestA) Test() {}
type TestB struct {}
func (t *TestB) Test() {}
func main() {
nilVal := (TestInterface)(nil)
var nilVar TestInterface
var testA TestInterface = (*TestA)(nil)
var testB TestInterface = (*TestB)(nil)
var testA2 TestInterface = (*TestA)(nil)
fmt.Println("nilVal == nil is", nilVal == nil)
fmt.Println("nilVar == nil is", nilVar == nil)
fmt.Println("testA == nil is", testA == nil)
fmt.Println("testB == nil is",testB == nil)
fmt.Println("testA == testB is",testA == testB)
fmt.Println("testA == testA2 is", testA == testA2)
}
What do you think the output will be? Based on what we see in the code,
we would expect all of them to be true
as all of them are nil
.
However, the output is as follows:
nilVal == nil is true
nilVar == nil is true
testA == nil is false
testB == nil is false
testA == testB is false
testA == testA2 is true
Why are the last 3 results false
? #
The reason is because the last 3 nil
s are not the same.
They are all struct
pointers which are casted to an integer.
In this case, we are casting the nil
pointer to TestA
/TestB
first before we cast it to TestInterface
.
This is also the cause for Nil errors that are non-nil errors in the unexpected-go website.
Explaining the result in the code #
var testB TestInterface /* Casts TestB to TestInterface */ = (*TestB)(nil) // Casts nil to *TestB
Due to this casting, the nil value has a type value attached to it.
In this case the nil value store in testB
has a type of *TestB
which is different from nil
which does not contain any typing.
nilVal == nil is true
nilVal
is casted from type TestInterface
directly to nil
.
As it is casted from an interface, it does not have any struct
type value attached to it.
This this statement is true
.
nilVar == nil is true
nilVar
is declared as a TestInterface
type but not initialized.
Thus, similar to the above, this statement is true
.
testA == nil is false
testA
is casted from type *TestA
to TestInterface
.
As it is casted from a struct
pointer, it has a type value attached to it.
As untyped nil
is not the same as a typed nil
, this statement is false
.
testB == nil is false
testB
is casted from type *TestB
to TestInterface
.
Similar to the above statement, this statement is false
.
testA == testB is false
testA
and testB
are both casted from struct
pointers to TestInterface
.
However, they are casted from different struct
pointers.
Thus, this statement is false
as the types are different.
testA == testA2 is true
Last but not least, testA
and testA2
are both casted from struct
pointers to TestInterface
.
However, they are casted from the same struct
pointer.
Thus, this statement is true
as the types are the same.
Fixes #
To check for nil
correctly we will need to have a different way of checking nil.
There are a few ways to do so and I will be listing them below.
A benchmark for each of the solutions can be found in Appendix A
Use reflect
package #
Advantages #
- It is generic to any possible types.
Disadvantages #
- Slow runtime due to reflections.
Example #
package main
type TestInterface interface {
Test()
}
type Test struct {}
func (t *Test) Test() {}
func IsNil(val any) bool {
if val == nil {
return true
}
v := reflect.ValueOf(val)
switch k := v.Kind(); k {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer,
reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return v.IsNil()
}
return false
}
func main() {
var test TestInterface = (*Test)(nil)
fmt.Println("IsNil(test) =", IsNil(test)) // IsNil(test) = true
}
Use a switch statement #
Write a switch statement that loops through all the types that implements the interface to check for nil
s
Advantages #
- Fast runtime
- Easy to implement
- Warnings can be added during compile tile to check for missing types (If you have an IDE to do so)
Disadvantages #
- Need to add methods for all the
struct
s that implements the interface.
Example #
package main
type TestInterface interface {
Test()
}
type Test struct {}
func (t *Test) Test() {}
func IsTestNil(val TestInterface) bool{
if val == nil {
return true
}
switch v := val.(type){
case (*Test):
return nil == v
... // Other types
default:
return false
}
}
func main() {
var test TestInterface = (*Test)(nil)
fmt.Println("IsTestNil(test) =", IsTestNil(test)) // IsTestNil(test) = true
}
Implement a isNil
check in the interface #
For this solution, we can implement the method as a class method for all the struct
s that implements the interface.
Advantages #
- Fast runtime
- Easy to implement
Disadvantages #
- Need to implement the method for all the
struct
s that implements the interface.
Example #
package main
import "fmt"
type TestInterface interface {
Test()
IsNil() bool
}
type Test struct {}
func (t *Test) Test() {}
func (t *Test) IsNil() bool {
return t == nil
}
func main() {
var test TestInterface = (*Test)(nil)
fmt.Println("test.IsNil() =", test.IsNil()) // test.IsNil() = true
}
Conclusion #
In conclusion, I hope that this blog post has helped you to understand some of the unexpected behaviors of Golang. While programming in golang, we will have to take note of these unexpected behaviors to prevent any bugs from happening.
Note: If there are any mistakes in the blog post, please feel free to contact me to correct it.
Links #
Appendix A: Benchmarks of the solution #
In the benchmark, we will be pitting each of the solutions head to head to see which one is the fastest. This result is used in the Fixes section of the blog post as the advantage/disadvantage of each solution.
Methodology #
We will be using the testing
package to benchmark the solutions.
For each of the solutions, we will be implementing a minimal interface to check for nil
.
A separate interface is used for the class
method
solution as we require the interface itself to implement the IsNil
method.
The test is run on Windows 11 64 bit with an AMD Ryzen 5 7600 6-Core Processor.
Benchmark Code #
package test
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
type TestInterface interface{}
type TestStruct struct{}
type TestIsNilInterface interface {
IsNil() bool
}
type TestIsNilStruct struct{}
func (t *TestIsNilStruct) IsNil() bool {
return t == nil
}
func IsNilReflect(val any) bool {
if val == nil {
return true
}
v := reflect.ValueOf(val)
switch k := v.Kind(); k {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer,
reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return v.IsNil()
}
return false
}
func IsNilSwitch(val TestInterface) bool {
if val == nil {
return true
}
switch v := val.(type) {
case (*TestStruct):
return nil == v
default:
return false
}
}
func BenchmarkReflectNilCheck(b *testing.B) {
var val TestInterface
valid := TestStruct{}
for i := 0; i < b.N; i++ {
assert.True(b, IsNilReflect(val))
assert.False(b, IsNilReflect(valid))
}
}
func BenchmarkSwitchNilCheck(b *testing.B) {
var val TestInterface
valid := TestStruct{}
for i := 0; i < b.N; i++ {
assert.True(b, IsNilSwitch(val))
assert.False(b, IsNilSwitch(valid))
}
}
func BenchmarkMethodNilCheck(b *testing.B) {
var val TestIsNilInterface
valid := TestIsNilStruct{}
for i := 0; i < b.N; i++ {
assert.True(b, IsNilSwitch(val))
assert.False(b, IsNilSwitch(valid))
}
}
Results #
The results are in line with our expectations.
Solution | Benchmark Result |
---|---|
reflect package | 5.873 ns/op |
switch statement | 2.745 ns/op |
class method | 2.745 ns/op |
As we can see based on the above table, the switch
statement and class
method
solution is the fastest.
The reflect
package is the slowest due to the overhead of the reflection itself.
Raw Results #
goos: windows
goarch: amd64
cpu: AMD Ryzen 5 7600 6-Core Processor
BenchmarkReflectNilCheck-12 204747235 5.873 ns/op 0 B/op 0 allocs/op
BenchmarkSwitchNilCheck-12 438795841 2.745 ns/op 0 B/op 0 allocs/op
BenchmarkMethodNilCheck-12 438626950 2.745 ns/op 0 B/op 0 allocs/op
PASS