Go has a unique rule when it comes to method receiver types (value vs. pointer) and how they affect interface compliance. Coming from a PHP background, I initially struggled to wrap my head around the concept. So, in this article, I share my insights to help others navigate this learning curve more smoothly.
Let's first clear a few things out:
In Go, when we call a method, the receiver is treated as the first argument. Behind the scenes, Go effectively converts a method call into a regular function call, passing the receiver as an explicit parameter:
person := Person{}
// This
person.Greet("Hello, ")
// Is converted to this
Person_Greet(person, "Hello, ")
In Go, an interface type is defined as follows:
type iface struct {
tab *itab
data unsafe.Pointer
}
It's a struct with two pointer fields:
-
tab
points to a table that stores information about the interface type & the concrete type being passed to the interface. -
data
points to the value being passed to the interface.
When we pass a concrete type to an interface type in Go, the compiler first verifies that the concrete type implements all the methods required by the interface. Once validated, Go automatically generates code to convert the concrete type into an interface at runtime. This wrapper stores both the concrete value and its type information, allowing dynamic method calls through the interface.
type Speaker interface {
Speak()
}
type Person struct {
Name string
}
func (p Person) Speak() {
fmt.Println("Hello, my name is", p.Name)
}
func main() {
conc := Person{Name: "Alice"}
var iface Speaker = conc
}
When assigning the concrete type conc
to the interface iface
, the compiler verifies that conc
implements all the methods of iface
and prepares the runtime for converting the value in conc
to the Speaker
type by filling the tab
and data
fields.
When calling a method on an interface, the call is dynamically dispatched to the corresponding method of the concrete type. Based on what we've discussed so far, the process essentially unfolds like this:
- Extract the value of the concrete type from the
data
field. - Perform type assertion to convert the value to the concrete type based on the information stored in the
tab
field of the interface type. - Pass the final result as the first argument to the converted function.
So, when we have a call like this:
func main() {
conc := Person{Name: "Alice"}
var iface Speaker = conc
iface.Speak()
}
The pseudocode for the operation would look like this:
dataCopy := *iface.data // dereference (extract the value)
typedData := dataCopy.(Person) // assert
Person_SPEAK(
typedData
)
If the method expects a value receiver and we pass a value concrete type, Go will:
- Extract the value of the concrete type from the
data
field. - Convert it to the concrete type.
- Pass the result to the converted function.
dataCopy := *iface.data // dereference (extract the value)
typedData := dataCopy.(Person) // assert
Person_SPEAK(
typedData
)
If the method expects a value receiver and we pass a pointer concrete type, Go will:
- Extract the value of the concrete type from the
data
field. - Dereference the pointer.
- Convert the result to the concrete type.
- Pass it to the converted function.
dataCopy := *iface.data // dereference iface.data
dereferenced := *dataCopy // dereference the pointer at iface.data
typedData := dereferenced.(Person) // assert
Person_SPEAK(
typedData
)
If the method expects a pointer receiver and we pass a pointer concrete type, Go will:
- Extract the value of the concrete type from the
data
field. - Convert the result to the concrete type.
- Pass the result to the converted function.
dataCopy := *iface.data // dereference iface.data
typedData := dataCopy.(*Person) // assert
Person_SPEAK(
typedData
)
Now, if the method expects a pointer receiver and we pass a concrete value type, you might expect that Go would:
- Extract the value of the concrete type from the
data
field. - Obtain the address of the value.
- Convert the result to the concrete type.
- Pass it to the converted function.
dataCopy := *iface.data // dereference iface.data
dataAddress := &dataCopy // obtain address
typedData := dataAddress.(*Person) // assert
Person_SPEAK(
typedData
)
However, it doesn’t work that way for one simple reason: the data inside an interface has no direct memory address, making step 2 (highlighted above) impossible.
dataCopy := *iface.data
dataAddress := &dataCopy // Unaddressable
This is similar to map values, which are also unaddressable:
m := map[string]int{
"apple": 5,
"banana": 7,
}
ptr := &m["apple"] // Unaddressable
Similarly, literals are unaddressable:
p := &"Hello" // Unaddressable
Since the value stored in the interface type is unaddressable, Go doesn’t consider methods with a pointer receiver when checking interface compliance if the concrete type is a non-pointer (value):
type Speaker interface {
Speak()
}
type Person struct {
Name string
}
func (p *Person) Speak() { // pointer receiver
fmt.Println("Hello, my name is", p.Name)
}
func main() {
conc := Person{Name: "Alice"} // value
var iface Speaker = conc
iface.Speak() // error
}
cannot use conc (variable of type Person) as Speaker:
Person does not implement Speaker (method Speak has pointer receiver)
Go implements interfaces using a mechanism that can be described as fat pointers, which is a type of pointer that carries additional metadata beyond just a memory address. For interfaces, it carries:
- A pointer to the actual data (the concrete type).
- A pointer to the type information (the type of the concrete type).
Inside an interface, the concrete type is unaddressable, meaning the interface cannot obtain the memory address of the stored value. As a result, it cannot pass the value to a method that requires a pointer receiver.