PidokuInfra

Interfaces Under the Hood

Basic Intermediate 50 min Difficulty 3/5 Topic 05 of 06

Prerequisites I.04, 04

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 warn

How it really works#

Two representations#

InterfaceFirst wordSecond word
any (no methods) — an efacePointer to the type descriptorPointer to the data
With methods — an ifacePointer to an itab: the interface type, the concrete type, and one function pointer per interface methodPointer 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 valueAllocation?
A pointer (*T), map, channel, funcNo: the value is a pointer and goes straight in the data word
Small integers 0–255, zero values, constantsNo: the runtime points at shared read-only data
A struct, a large integer, a string, a sliceUsually yes: the value is copied to the heap
Anything, if the compiler proves the interface does not escapeNo: 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#

Go
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                 // false

The 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:

  1. Return a literal nil for “no error”: return nil.
  2. Declare error variables as error, not as a concrete pointer type.
  3. 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#

Go
// 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#

  1. Run iface.go. Which boxing cases allocated? Explain each from the table.
  2. Fix lookupBad in two different ways.
  3. Benchmark summing areas over []Shape holding two different concrete types in random order versus one type. Why might the mixed case be slower? (Think about the branch predictor.)

Check yourself#

  1. What are the two words of an interface value?
  2. Why is a nil *T stored in an interface not equal to nil?
  3. Why can the compiler usually not inline a call through an interface?

↑↓ navigate↵ openesc close