Interface Internals and Method Dispatch
Deep dive into Go's interface implementation, eface vs iface structures, method dispatch overhead, and optimization techniques.
Introduction
Interfaces are one of Go's most powerful features, enabling flexible and composable code. Yet many developers don't understand their internal representation and performance characteristics. In this article, we'll explore how interfaces work at the runtime level, including the data structures, caching mechanisms, method dispatch costs, and practical optimization strategies.
Two Kinds of Interfaces: eface and iface
Go has two internal interface representations: the empty interface (any or interface{}) and non-empty interfaces with methods.
The Empty Interface: eface
The empty interface is represented internally as eface:
// runtime/runtime2.go (simplified)
type eface struct {
_type *_type
data unsafe.Pointer
}This is just 16 bytes on 64-bit systems:
_type: 8 bytes pointing to runtime type metadatadata: 8 bytes pointing to the actual value
The _type contains information about the concrete type: size, alignment, method pointers, etc.
Non-Empty Interfaces: iface
Non-empty interfaces are represented as iface:
// runtime/runtime2.go (simplified)
type iface struct {
tab *itab
data unsafe.Pointer
}Also 16 bytes, but the implementation is more sophisticated:
tab: 8 bytes pointing to an interface tabledata: 8 bytes pointing to the value (same as eface)
The itab Structure
The real complexity lies in itab:
// runtime/runtime2.go (simplified)
type itab struct {
inter *interfacetype // the interface type
_type *_type // the concrete type
hash uint32 // cached hash of _type
_ [4]byte // padding
fun [1]uintptr // function pointers for methods (variable length array)
}The fun array contains pointers to the concrete type's methods, in the order defined by the interface. This array is variable-length; the actual size depends on the number of methods.
Code Example: Understanding eface vs iface
package main
import (
"fmt"
"reflect"
"unsafe"
)
type Reader interface {
Read([]byte) (int, error)
}
func main() {
var empty interface{} = 42
var rd Reader = nil
// eface size
efaceSize := unsafe.Sizeof(empty)
fmt.Printf("eface size: %d bytes\n", efaceSize) // 16 bytes
// iface size
ifaceSize := unsafe.Sizeof(rd)
fmt.Printf("iface size: %d bytes\n", ifaceSize) // 16 bytes
// Both are 16 bytes, but iface has additional metadata in itab
v := reflect.ValueOf(empty)
fmt.Printf("Type of empty: %v\n", v.Type())
}Output:
eface size: 16 bytes
iface size: 16 bytes
Type of empty: intitab Caching and First-Time Cost
The runtime maintains a global hash table of itab entries. When you first assign a concrete type to an interface (or perform a type assertion), Go must:
- Hash the (interface type, concrete type) pair
- Check if an
itabalready exists - If not, allocate a new
itab - Resolve all methods from the concrete type
- Fill in the
funarray
This first assignment is slow. Subsequent uses of the same (interface, concrete) pair use the cached itab, making them fast.
Importantly, the itab cache never shrinks. Once cached, an itab remains in memory for the program's lifetime.
Demonstration: First vs Subsequent Assignment
package main
import (
"fmt"
"testing"
)
type Writer interface {
Write([]byte) (int, error)
}
type MyWriter struct{}
func (mw *MyWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func BenchmarkFirstAssignment(b *testing.B) {
b.Run("FirstAssignment", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var w Writer = &MyWriter{}
_ = w
}
})
b.Run("SubsequentAssignment", func(b *testing.B) {
mw := &MyWriter{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var w Writer = mw
_ = w
}
})
}
func main() {
fmt.Println("Run with: go test -bench=. -benchmem")
}The first run (across different concrete types) shows measurable allocation and latency. The cache makes subsequent conversions essentially free.
Method Dispatch Cost
Calling a method through an interface involves:
- Load the
itabfrom theifacestruct - Index into the
funarray to get the method pointer - Perform an indirect function call (through a register/pointer)
This is 2-5 nanoseconds slower than a direct method call on a concrete type.
Benchmark: Direct Call vs Interface Call
package main
import (
"testing"
)
type Shape interface {
Area() float64
}
type Circle struct {
radius float64
}
func (c Circle) Area() float64 {
return 3.14159 * c.radius * c.radius
}
func BenchmarkDirectCall(b *testing.B) {
c := Circle{radius: 5.0}
b.ResetTimer()
var sum float64
for i := 0; i < b.N; i++ {
sum += c.Area()
}
_ = sum
}
func BenchmarkInterfaceCall(b *testing.B) {
var s Shape = Circle{radius: 5.0}
b.ResetTimer()
var sum float64
for i := 0; i < b.N; i++ {
sum += s.Area()
}
_ = sum
}
func BenchmarkReflectCall(b *testing.B) {
c := Circle{radius: 5.0}
s := interface{}(c)
b.ResetTimer()
var sum float64
for i := 0; i < b.N; i++ {
sum += s.(Circle).Area()
}
_ = sum
}Typical benchmark results:
BenchmarkDirectCall 1000000000 0.50 ns/op
BenchmarkInterfaceCall 500000000 2.50 ns/op
BenchmarkReflectCall 500000000 2.50 ns/opThe interface call adds about 2 nanoseconds of overhead.
Type Assertions and Type Switches
Type Assertion: O(1) Operation
A type assertion like v, ok := i.(ConcreteType) performs a pointer comparison:
// Simplified runtime code
v, ok := i.(ConcreteType)
// Becomes approximately:
// ok = (i.tab._type == &ConcreteType's type info)This is O(1) — just a pointer comparison. It's very fast.
Type Assertion Code Example
package main
import (
"fmt"
"testing"
)
func typeAssert(i interface{}) string {
if v, ok := i.(int); ok {
return fmt.Sprintf("int: %d", v)
}
if v, ok := i.(string); ok {
return fmt.Sprintf("string: %s", v)
}
return "unknown"
}
func typeSwitch(i interface{}) string {
switch v := i.(type) {
case int:
return fmt.Sprintf("int: %d", v)
case string:
return fmt.Sprintf("string: %s", v)
default:
return "unknown"
}
}
func BenchmarkTypeAssert(b *testing.B) {
i := interface{}(42)
b.ResetTimer()
for n := 0; n < b.N; n++ {
typeAssert(i)
}
}
func BenchmarkTypeSwitch(b *testing.B) {
i := interface{}(42)
b.ResetTimer()
for n := 0; n < b.N; n++ {
typeSwitch(i)
}
}Type Switch Compilation
For small type switches, Go generates a sequence of pointer comparisons. For large switches (typically 5+ cases), the compiler generates a jump table using a hash of the type pointer. The jump table is more efficient for large switches.
Interface Boxing and Allocation
When you store a value in an interface, Go must "box" it. This has allocation implications:
Small Values (≤ pointer size)
Values that fit in a pointer (8 bytes on 64-bit) are stored inline in the data field with no allocation:
var i interface{} = 42 // no allocation, stored in data pointer
var i interface{} = int64(42) // no allocation
var i interface{} = "hello" // no allocation (string is 16 bytes, data points to string header)Large Values
Values larger than a pointer must be allocated on the heap:
type LargeStruct struct {
a, b, c int64
}
var i interface{} = LargeStruct{1, 2, 3} // allocation! data points to heapDemonstration: Boxing Allocations
package main
import (
"fmt"
"testing"
)
func BenchmarkBoxing(b *testing.B) {
b.Run("SmallValue", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var x interface{} = 42
_ = x
}
})
b.Run("LargeValue", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
v := struct {
a, b, c, d int64
}{1, 2, 3, 4}
var x interface{} = v
_ = x
}
})
b.Run("StringValue", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var x interface{} = "hello world"
_ = x
}
})
}
func main() {
testing.Main(
func(pat, str string) (bool, error) { return true, nil },
nil, nil, nil,
[]testing.Benchmark{},
)
}Tip: Small values (≤ 8 bytes) don't allocate when boxed into
interface{}. Larger values do. Strings are special: the string header itself (16 bytes) is stored without allocation, but only the header—the string data pointer is already part of the string header.
Zero-Value Optimization
Many types have a zero value that doesn't require allocation. The runtime has static zero values for common types:
var i interface{} = 0 // points to static zero value
var i interface{} = "" // points to static zero value
var i interface{} = false // points to static zero value
var i interface{} = (*int)(nil) // points to static nil valueThis optimization saves allocations in many real-world scenarios.
Convt Functions: Specialized Boxing
The runtime provides specialized conversion functions for common types to avoid allocation:
// runtime/iface.go (simplified)
func convT64(val uint64) (x unsafe.Pointer) { ... }
func convTstring(val string) (x unsafe.Pointer) { ... }
func convTslice(val []int) (x unsafe.Pointer) { ... }These functions handle zero-value caching and inline storage intelligently.
Devirtualization and Optimization
The Go compiler and runtime have techniques to eliminate interface overhead:
Compiler Devirtualization
If the compiler can determine the concrete type at compile time, it may devirtualize the call:
func process(w Writer) {
w.Write([]byte("hello")) // Interface call
}
func caller() {
w := &MyWriter{}
process(w) // Compiler might devirtualize if it can inline process
}With inlining and escape analysis, the compiler might transform the interface call into a direct call.
Profile-Guided Optimization (PGO)
Go 1.21+ supports PGO. The runtime can use profile data to devirtualize calls more aggressively. If a particular (interface, concrete) pair is dominant, the generated code might special-case it.
Performance Patterns and Best Practices
1. Use Concrete Types in Hot Paths
// Slow: interface call in tight loop
func sumValues(values []interface{}) float64 {
var sum float64
for _, v := range values {
i := v.(float64)
sum += i
}
return sum
}
// Fast: concrete type
func sumFloats(values []float64) float64 {
var sum float64
for _, v := range values {
sum += v
}
return sum
}2. Accept Interfaces at API Boundaries
// Good: accept interface at boundary
func ReadFile(r Reader) ([]byte, error) {
// Read from r
}
// Within implementation: use concrete types
func process() {
file := os.Open("data.txt")
defer file.Close()
data, err := ReadFile(file)
_ = data
}3. Avoid interface{} in Tight Loops
// Slow
func Process(items []interface{}) {
for _, item := range items {
v := item.(string)
fmt.Println(v)
}
}
// Fast
func Process(items []string) {
for _, item := range items {
fmt.Println(item)
}
}Comprehensive Benchmark: All Dispatch Methods
package main
import (
"testing"
)
type Calculator interface {
Calculate(int) int
}
type Direct struct{}
func (d Direct) Calculate(x int) int {
return x * 2
}
func BenchmarkDispatchMethods(b *testing.B) {
d := Direct{}
b.Run("DirectCall", func(b *testing.B) {
b.ReportAllocs()
var sum int
for i := 0; i < b.N; i++ {
sum += d.Calculate(42)
}
_ = sum
})
b.Run("InterfaceCall", func(b *testing.B) {
var c Calculator = Direct{}
b.ReportAllocs()
var sum int
for i := 0; i < b.N; i++ {
sum += c.Calculate(42)
}
_ = sum
})
b.Run("TypeAssertion", func(b *testing.B) {
var i interface{} = Direct{}
b.ReportAllocs()
var sum int
for n := 0; n < b.N; n++ {
d := i.(Direct)
sum += d.Calculate(42)
}
_ = sum
})
b.Run("TypeSwitch", func(b *testing.B) {
var i interface{} = Direct{}
b.ReportAllocs()
var sum int
for n := 0; n < b.N; n++ {
switch v := i.(type) {
case Direct:
sum += v.Calculate(42)
}
}
_ = sum
})
}
func main() {
testing.Main(
func(pat, str string) (bool, error) { return true, nil },
nil, nil, nil,
[]testing.Benchmark{},
)
}Typical output:
BenchmarkDispatchMethods/DirectCall-8 2000000000 0.45 ns/op 0 B/op
BenchmarkDispatchMethods/InterfaceCall-8 1000000000 2.10 ns/op 0 B/op
BenchmarkDispatchMethods/TypeAssertion-8 1000000000 2.10 ns/op 0 B/op
BenchmarkDispatchMethods/TypeSwitch-8 1000000000 2.10 ns/op 0 B/opSummary
Interfaces in Go are elegantly implemented with minimal overhead:
- eface (empty interface): just a type pointer and data pointer
- iface (non-empty interface): a type/method table pointer and data pointer
- Method dispatch adds 2-5ns of overhead compared to direct calls
- The first assignment to a (interface, concrete) pair is cached globally
- Boxing small values doesn't allocate; larger values do
- Devirtualization can eliminate interface overhead in many cases
- In hot paths, prefer concrete types over interfaces
Understanding these internals helps you write faster, more efficient Go code.