Interfaces in Golang: A Deeper Look
Interfaces in Golang are fairly different from other languages. In Go, the interface is a custom type
that is used to specify a set of one or more method signatures.
A value of interface type can hold any value that implements those methods. The interface is abstract, meaning we can’t create instances of an interface. What we do is create instances of concrete values that implement that interface.
A very popular example is the Stringer interface in Golang:
type Stringer interface {
String() string
}
Now, any types we create that have a String
method which returns a string
will satisfy the Stringer
interface.
type StringerStruct struct {
}
func (ss StringerStruct) String() string { return "stringer_struct" }
Interfaces play a significant role in achieving polymorphism and abstraction in Golang. They decouple the definition of objects from their implementation, promoting flexibility and reusability in code. This article however, won’t talk about how these are achieved using interfaces. There’s many other places where you can read about it.
Interfaces, Static or Dynamic?
One, Go’s static type system ensures that the types involved in interface implementation are compatible, at compile time. This means that the compiler checks if the methods required by an interface are present in the concrete type.
However, the actual assignment between the interface variable and the value of the concrete type happens at runtime. This allows for flexibility in choosing the appropriate implementation of an interface dynamically during program execution.
Other languages:
For languages like Java and C++, there are method call tables and virtual method tables (vtables). These tables are used to implement dynamic method dispatch, which allows the correct method to be called for an object at runtime based on its actual type.
Java
In Java, method resolution in the context of interfaces is achieved through a mechanism called dynamic dispatch or runtime polymorphism.
When a method is called on an object through an interface reference, the JVM dynamically resolves the method invocation at runtime. It first determines the actual type of the object being referred to (known as the runtime type). Then, it looks for the appropriate method implementation in the class hierarchy of the runtime type.
If the method is not directly implemented by the runtime type, Java looks for it in the superclass chain.
C++
In C++ runtime polymorphism is achieved using virtual functions. When a class contains at least one virtual function, the compiler creates a vtable
for that class. The vtable
is an array of function pointers, where each entry corresponds to a virtual function declared in the class. Each object of a class with virtual functions contains a hidden pointer called the vptr
, which points to the vtable associated with the class.
When a virtual function is called through a base class pointer or reference, the compiler resolves the function call dynamically at runtime. It uses the vptr
associated with the object to access the vtable. The vptr
helps in identifying the correct vtable for the actual derived class type of the object.
Once the correct vtable
is identified, the compiler uses the index of the virtual function to retrieve the corresponding function pointer from the vtable. Finally, it invokes the function through the function pointer.
Finally, Golang
Now, let’s consider an example of how interfaces and structs are typically used.
type Vehicle interface {
Accelerate()
Brake()
}
type Car struct {
name string
color string
age int
}
func (c Car) Accelerate() {}
func (c Car) Brake() {}
func (c Car) Airbag() {}
func main() {
var v Vehicle
c := Car{"Mazda", "Red", 5}
v = c
}
Go compiler has type description structures. Check the Type
at https://github.com/golang/go/blob/master/src/internal/abi/type.go.
For every single type created in Golang and for functions, a type description structure is created. These are InterfaceType
for interfaces, StructType
for structs, and so on. These have Type
embedded in them to reuse metadata common to all type descriptions. Check the type.go
file linked above.
An interface is represented using two “words” in memory.
A word is a collection of bits that can be transferred to the CPU as a single unit. For 32-bit systems the word is of length 32 bits, for 64-bit systems the length is, 64.
Let’s assume we’re working with a 64-bit architecture. The interface thus, use two words, each 64 bits, and uses these words to store the pointer of 2 things.
- The interface table(called itab)
- The copy of the concrete struct the interface has the value of(called data)
Hence, when we assigned v
as the Vehicle
we initialized both of these values.
Here’s how the interface looks like in code:
type iface struct {
tab *itab
data unsafe.Pointer
}
itab
points to the interface table and data
points to the copy of the struct.
Let’s focus specifially on the first point, the itab
. Here’s what itab
looks like:
type ITab struct {
Inter *InterfaceType
Type *Type
Hash uint32 // copy of Type.Hash. Used for type switches.
Fun [1]uintptr // variable sized. fun[0]==0 means Type does not implement Inter.
}
Notice how it has a Type
and InterfaceType
which is the same type description structure we were talking about.
type InterfaceType struct {
Type
PkgPath Name // import path
Methods []Imethod // sorted by hash
}
type Imethod struct {
Name NameOff // name of method
Typ TypeOff // .(*FuncType) underneath
}
The InterfaceType
has the Methods
which is all the methods the interface has.
Notice that in the ITab
, along with the InterfaceType
there’s the last field Fun
. This is the function pointer. This stores the methods of the concrete type which implement the interface. These will only be the methods that the interface has, meaning methods of the Car
struct that are not in Vehicle
will not be included here, like Airbag()
. The Fun
array is declared with a fixed size to optimize memory usage and improve performance. Since most interfaces have a small number of methods, allocating space for only a few method pointers in the Fun
array reduces memory overhead. However, it’s conceptually variable-sized because it can hold multiple method pointers corresponding to the methods defined by the interface. This allows interfaces with multiple methods to be efficiently represented in memory.
Keep in mind that the type description structures for each interface and struct are generated all at compile time. These are loaded into memory in a global table by the Go compiler.
The interface table is not created at compile time. Notice how a seperate itable is created for each interface-struct pair, hence it might be inefficient to create ITab
for each pair at compile time, alot of them won’t be needed!
When the ITab
is created for a specific interface-struct pair, it is computed once and than cached for future use. This prevents redundancy and duplicate computation of the same itables for the same pairs, and also makes access to methods of the interface quick.
One might think that this might make things slow, which is true. If a concrete type has n
methods and the interface has m
, than to compute all itables, it might take O(n*m)
time. However, observe that the Methods []Imethod
in ITab
was sorted by the hash of the method, which makes the computation O(n+m)
, this is an optimization done by the folks who maintain Go.
When we do a type switch, the go compiler generates code which accesses the type of the interface and checks if it matches the type of the concrete value at runtime. Similarly, at points where the method of an interface is accessed like this:
func accelerateCar(v Vehicle) {
v.Accelerate()
}
accelerateCar(c)
The statement v.Accelerate
, will also be replaced by the method in the method list in Fun
.
Optimizations
There’s some more optimizations done for the itab
. If an empty interface{}
or any
is used, than it has no methods, and creating a full itab
would be inefficient, hence, in that case instead of the itab
, the type
is directly pointed to.
var v any = Car{"Mazda", "Red", 5"}
There’s a seperate struct for this in Golang’s runtime eface
.
type eface struct {
_type *_type
data unsafe.Pointer
}
Also, if the concrete value is small in size, that is if it fits in a single word than the data in iface
isn’t a pointer to the data but instead holds the entire data directly. Example if the concrete struct has size less than 64 bits than data
holds the entire value directly. This is also managed by the go runtime and not something we have to manager on our own.
That’s it for now.
Stackademic 🎓
Thank you for reading until the end. Before you go:
- Please consider clapping and following the writer! 👏
- Follow us X | LinkedIn | YouTube | Discord
- Visit our other platforms: In Plain English | CoFeed | Venture | Cubed
- More content at Stackademic.com