iWahbe / Go Pointers Considered Confusing

Go Pointers Considered Confusing

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:

1package main
2
3type Interface interface{ Method() }
4
5type Struct struct{}
6
7func (Struct) Method() {}
8
9func GetInterface() Interface {
10 var s *Struct
11 return s
12}
13
14func main() {
15 p := GetInterface()
16 if p != nil {
17 p.Method()
18 }
19}

(Go Playground Line Here)

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 (Struct) ByValue() {}

This is in contrast to by-reference methods, which are declared with a *T as their reciever:

func (*Struct) ByReference() {}

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 {
    typ interfacetype // The type part
    val any           // The value part
}

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 { runtimeType(*Struct), nil }

Calling a method on ivalue conceptually looks like this:

getMethod(ivalue.typ.methods, methodName).Call(ivalue.val)

1

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:

14func main() {
15 p := GetInterface()
16 if p != nil {
17 p.Method()
18 }
19}

Applying what we know about fat pointers and by-value methods to the example, we can translate2 main into the following:

14func main() {
15 p := InterfaceValue { typ: runtimeType(*Struct), val: nil } // The value of `GetInterface()`
16 if p != InterfaceValue { typ: nil, val: nil } { // `p.typ != nil` so the `!=` check will return `true`
17 getMethod(p.typ.mhdr, "Method").Call(*p.val) // `getMethod(p.typ.mhdr, "Method")` is `Struct.Method`
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.

1

I'm being very hand-wavy here. The strict details of Go's dynamic dispatch system are not important here, just its representation.

2

This is a mental approximation, not runable code. The getMethod and .Call don't actually exist by those names and signatures.

3

Without using the reflect package for runtime introspection.