The idea in one minute#
An interface value is two words: one identifies the concrete type stored inside (and, for interfaces with methods, a table of that type’s methods), the other is a pointer to the data. Calling a method through an interface means: load the function address from the table, then make an indirect call.
Three things follow. An interface is nil only if both words are nil. Putting a value into an interface often allocates, because the data word must point at something. And a call through an interface cannot be inlined unless the compiler can work out the concrete type.
An analogy#
A luggage tag and a claim ticket. The tag says what kind of item it is and lists what staff may do with it (the type and its methods); the claim ticket says where the item is stored (the data pointer). A blank form with no tag and no ticket is “nothing”. A tag reading “umbrella” with a ticket for an empty hook is not nothing — it is an umbrella that happens to be absent.
A picture#
flowchart LR
subgraph IFACE["var e Embedder = &LocalModel{...} (16 bytes)"]
TAB["tab"]
DATA["data"]
end
TAB --> ITAB["itab for (Embedder, *LocalModel)<br/>type descriptor<br/>fun[0] = (*LocalModel).Embed"]
DATA --> OBJ[("LocalModel struct<br/>on the heap")]
ITAB -->|"e.Embed(x): load fun[0], indirect call"| CODE["machine code of<br/>(*LocalModel).Embed"]
subgraph NILS["Two different 'empty' values"]
N1["tab = nil, data = nil<br/>e == nil is TRUE"]
N2["tab = *LocalModel, data = nil<br/>e == nil is FALSE"]
end
class TAB,DATA queue
class ITAB,OBJ memory
class CODE compute
class N1 neutral
class N2 warnHow it really works#
Two representations#
| Interface | First word | Second word |
|---|---|---|
any (no methods) — an eface | Pointer to the type descriptor | Pointer to the data |
| With methods — an iface | Pointer to an itab: the interface type, the concrete type, and one function pointer per interface method | Pointer to the data |
The itab for a (interface, concrete type) pair is built once — at compile time when the compiler can see the conversion, otherwise on first use — and cached.
Dynamic dispatch and its cost#
e.Embed(x) compiles to roughly: load tab, load fun[0] from it, call that address with
data as the receiver. The call itself costs a couple of nanoseconds. The larger cost is what
it prevents:
- The compiler does not know which function runs, so it cannot inline it.
- Arguments passed to an unknown function are assumed to escape to the heap (III.02).
The compiler removes the indirection when it can prove the concrete type (devirtualization), and with profile-guided optimization (V.02) it will guess the common type from a profile and insert a fast path for it. In a hot inner loop — per element, per token — prefer a concrete type or a generic function; at the boundaries of a system, interfaces cost nothing that matters.
Boxing: when conversion allocates#
The data word is a pointer, so storing a non-pointer value in an interface needs somewhere to point:
| Stored value | Allocation? |
|---|---|
A pointer (*T), map, channel, func | No: the value is a pointer and goes straight in the data word |
| Small integers 0–255, zero values, constants | No: the runtime points at shared read-only data |
| A struct, a large integer, a string, a slice | Usually yes: the value is copied to the heap |
| Anything, if the compiler proves the interface does not escape | No: the copy lives on the stack |
This is why fmt.Println(x), log.Printf("%d", n) and []any{...} allocate, and why hot-path
logging libraries (log/slog with typed slog.Int, slog.String) avoid any arguments.
The nil interface trap#
func find() error {
var e *NotFound = nil // a nil pointer of a concrete type
return e // converted to error: tab = *NotFound, data = nil
}
find() == nil // falseThe returned interface has a type word, so it is not the nil interface. Calling .Error() on
it will run the method with a nil receiver.
Rules that avoid it:
- Return a literal
nilfor “no error”:return nil. - Declare error variables as
error, not as a concrete pointer type. - Never return a typed nil pointer through an interface-typed result.
Type assertions and switches#
v, ok := e.(*LocalModel) compares the type word with the descriptor of *LocalModel — one
pointer comparison. Asserting to another interface (e.(io.Closer)) must check that the
concrete type has the methods, which is a cached lookup. A type switch compiles to a sequence
(or a hash-based jump) of these.
Comparing interfaces#
a == b is true when the types are identical and the values are equal. If the concrete type is
not comparable (a slice, a map), the comparison panics at run time — the reason map keys of
type any are risky.
Method values#
f := obj.Method creates a method value: a closure binding the receiver. It allocates if
it escapes. T.Method (a method expression) is a plain function taking the receiver as its
first argument and never allocates.
Code#
// iface.go — the two words, the nil trap, boxing allocations, and dispatch cost.
package main
import (
"fmt"
"os"
"strings"
"testing"
"unsafe"
)
type NotFound struct{ Key string }
func (e *NotFound) Error() string { return "not found: " + e.Key }
func lookupBad(ok bool) error {
var e *NotFound // nil pointer
if !ok {
e = &NotFound{"k"}
}
return e // BUG: always a non-nil interface
}
func lookupGood(ok bool) error {
if !ok {
return &NotFound{"k"}
}
return nil
}
// words exposes the two machine words of an interface value.
func words(i any) [2]uintptr { return *(*[2]uintptr)(unsafe.Pointer(&i)) }
type Op interface{ Apply(x uint64) uint64 }
type Add struct{ K uint64 }
func (a Add) Apply(x uint64) uint64 { return x + a.K }
type Pair struct{ A, B float64 }
var (
sink any
viaIface Op = Add{3} // package-level, so the compiler cannot see the concrete type
concrete = Add{3}
)
func main() {
fmt.Println("interface size:", unsafe.Sizeof(sink), "bytes")
e1, e2 := lookupGood(true), lookupBad(true)
fmt.Printf("lookupGood: words=%v == nil? %v\n", words(e1), e1 == nil)
fmt.Printf("lookupBad: words=[%#x %v] == nil? %v ← the type word is set\n", words(e2)[0], words(e2)[1], e2 == nil)
// Boxing: what allocates when a value is stored in an interface that escapes?
n := len(os.Args) // not known at compile time, so nothing below is a constant
ptr := &Pair{float64(n), 2}
large := n + 1<<40
pair := Pair{float64(n), 2}
str := strings.Repeat("x", n+10)
fmt.Println("\nallocations when storing into an interface:")
for _, c := range []struct {
name string
f func()
}{
{"pointer", func() { sink = ptr }},
{"small int (0-255)", func() { sink = n }},
{"large int", func() { sink = large }},
{"struct value", func() { sink = pair }},
{"string", func() { sink = str }},
} {
fmt.Printf(" %-18s %.0f\n", c.name, testing.AllocsPerRun(100, c.f))
}
// Dispatch: an indirect call that cannot be inlined, vs a direct one that is.
acc := uint64(0)
ri := testing.Benchmark(func(b *testing.B) {
op, x := viaIface, uint64(0)
for i := 0; i < b.N; i++ {
x = op.Apply(x)
}
acc += x
})
rc := testing.Benchmark(func(b *testing.B) {
op, x := concrete, uint64(0)
for i := 0; i < b.N; i++ {
x = op.Apply(x)
}
acc += x
})
fmt.Printf("\nmethod via interface: %.2f ns/call concrete (inlined): %.2f ns/call\n",
float64(ri.T.Nanoseconds())/float64(ri.N), float64(rc.T.Nanoseconds())/float64(rc.N))
_ = acc
}Remember this#
- An interface is two words: type (or itab) and data pointer.
- It is nil only when both are nil. Return a literal
nil, never a typed nil pointer. - Converting a non-pointer value to an interface usually allocates.
- Interface calls are indirect and block inlining; keep them out of per-element inner loops.
Try it#
- Run
iface.go. Which boxing cases allocated? Explain each from the table. - Fix
lookupBadin two different ways. - Benchmark summing areas over
[]Shapeholding two different concrete types in random order versus one type. Why might the mixed case be slower? (Think about the branch predictor.)
Check yourself#
- What are the two words of an interface value?
- Why is a nil
*Tstored in an interface not equal tonil? - Why can the compiler usually not inline a call through an interface?