I write code in multiple languages for my day job, but 90% of it is in Go. That said, I don't like Go. There are better blog posts that I agree with explaining the problems with the language. I want off Mr. Golang's Wild Ride is the first that comes to mind. I'm not going to provide my general thoughts on Go, nor am I going to talk about its design philosophy. Today I want to discuss a particular problem I've encountered in production Go code. I can demonstrate the problem in less then 20 lines of Go:
1 package main
2
3 type Interface interface
4
5 type Struct struct
6
7 func ()
8
9 func GetInterface() Interface 10 11 12
13
14 func main() 15 16 17 18 19
I believe that this code should not panic. If you run the code, it does panic. Specifically, it panics on line 17 when p.Method()
is called.
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x457bb4]
goroutine 1 [running]:
main.main()
/tmp/sandbox2726629010/prog.go:17 +0x14
Even worse, it panics with a nil
pointer error dispite the nil
check on line 16.
what is happening
This panic emerges at the intersection of method auto-dereferencing and dynamic dispatch. I'll explain what each of these terms means for the Go language, then why combining them causes the panic we saw earlier.
method auto-dereferencing
Go has two types of methods: by-value methods and by-reference methods. They are called the same way, but they are declared differently. By-value methods are methods that take their reciever by-value:
func ()
This is in contrast to by-reference methods, which are declared with a *T
as their reciever:
func ()
When you call a by-value method on a reference, Go automatically dereferences the reciever to get the value. I'm calling this feature method auto-dereferencing. Method auto-dereferencing is why the following code compiles.
var s *Struct = &Struct
s.Method()
We can think of this as Go implicitly converting s.Method
into (*s).Method()
when Struct.Method
has a by-value reciever but is called by-reference. This is a nice quality of life improvement for Go programmers. It is important to our motivating example that Method
is by-value.
dynamic dispatch
To understand the problem, we also need a more in-depth understanding of dynamic dispatch. Dynamic dispatch is how Go selects which implementing method is called for a method called through an interface. This requires us to look at implementation details. In Go, an interface is represented at runtime in two parts: the type and the value. Conceptually, we can think a runtime interface value as a pointer to the type and a pointer to the value:
type InterfaceValue struct
InterfaceValue
is what is known as a "fat pointer": a pointer consisting of a pointer to the type and a pointer to the value. p
from our example would look like this:
ivalue := InterfaceValue
Calling a method on ivalue
conceptually looks like this:
getMethod(ivalue.typ.methods, methodName).Call(ivalue.val)
The last important detail here is how Go performs nil
checks on fat pointers: field by field. That means that InterfaceValue { typ: runtimeType(*Struct), val: nil }
is considered non-nil because the typ
field is non-nil, even though the value field is nil
.
putting it all together
Now that we understand by-value methods and Go's dynamic dispatch, we can understand the problem. Recall the main
function from our motivating example:
14 func main() 15 16 17 18 19
Applying what we know about fat pointers and by-value methods to the example, we can translate2 main
into the following:
14 func main() 15 16 17 18 19
We can now see clearly why the panic occurs. p
is obviously different then a nil
interface, since p.typ != nil
. That is why the if
check succeeds and Method
is called. We can clearly see that p.val
is nil
so *p.val
will fail with panic: runtime error: invalid memory address or nil pointer derefernece
, which is exactly what happens.
why this is bad language design
Programming languages should allow for local reasoning. That means by looking at a piece of code in isolation, you should be able to tell what it does without needing global context. Local reasoning is what allows us to write programs more complicated then what one person can hold in their head. In Go, it is possible to panic when calling a non-panicking method on a non-nil receiver. The only way to guarantee3 that you won't panic is to understand the providence of the receiver. This makes it difficult to scale Go codebases. As someone who works in Go daily, I wish Go did better here.
I hope this article tought you something useful about Go or dynamic dispatch. I'm always happy to receive feedback at @iwahbe@hachyderm.io. If you think I did something wrong (or right), please let me know.
I'm being very hand-wavy here. The strict details of Go's dynamic dispatch system are not important here, just its representation.
This is a mental approximation, not runable code. The getMethod
and .Call
don't actually exist by those names and signatures.
Without using the reflect
package for runtime introspection.