An interface is two words¶
Level: 301 · anyone who wants to know what an any costs and what == on two of them compares
One line: unsafe.Sizeof of an any and of an error is 16 on both CI machines: a type word — a type descriptor for any, an itab for an interface with methods — and a data word that points at a copy of the value, or is the value when the value is a pointer. So boxing allocates for most values, and not for a pointer, a zero-size or empty value, or an integer from 0 to 255.
The first program reads the two words of several interface values through unsafe and reports what they hold — whether a word is nil, what it points at, whether two values share one — never the numbers themselves, which change between runs. The second counts heap allocations per boxing with testing.AllocsPerRun.
Verified output of an_interface_is_two_words_go.go — regenerated by tools/run_examples.py, never hand-typed.
unsafe.Sizeof(any) = 16
unsafe.Sizeof(error) = 16
unsafe.Sizeof(uintptr) = 8 -- so an interface is two words
nil any: type word nil? true data word nil? true
any holding int 7: type word nil? false data word points at a 7? true at the variable quantity? false
after quantity = 8, the any still holds 7 -- the data word points at a copy
any holding *Order: data word == the pointer itself? true
two anys holding ints: same type word? true same data word? false
error and any holding the same *ValidationError: same data word? true same type word? false (an itab for error, a type for any)
any(7) == any(7) true
any(int64(7)) == any(7) false
any([]int{1}) == any([]int{1}) panics: runtime error: comparing uncomparable type []int
Reading the output¶
16 bytes, two pointers. unsafe.Sizeof(uintptr(0)) is 8, so an interface is two words, and the runtime's own definition is two pointers: runtime2.go ↗ declares eface (the empty interface) as a *_type and a data pointer, and iface (an interface with methods) as an *itab and a data pointer. Russ Cox's Go Data Structures: Interfaces ↗ is the walk-through, and the itab is the piece that makes a method call on an error a table lookup, not a search.
A nil interface has both words nil. That is the whole of x == nil — and why A nil pointer in an interface is not nil: a typed nil has the type word set.
The data word points at a copy. For any holding 7, the data word points at a 7 that is not the variable quantity; after quantity = 8 the interface still holds 7. An interface owns an immutable copy, which is why a value in an interface is not addressable and why a pointer method cannot be called on it (A pointer receiver changes the method set).
A pointer is stored as itself. For any holding *Order, the data word equals the pointer. Nothing was copied and, as the second program shows, nothing was allocated.
One type word per type; the itab is not the type. Two any values holding ints share the type word. An error and an any holding the same *ValidationError share the data word but not the type word: the error holds an itab, which records the type and the method table for error; the any holds the type descriptor alone. Converting one interface to the other swaps that word.
== compares the type word, then the values. any(7) == any(7) is true; any(int64(7)) == any(7) is false because the type words differ. And when the dynamic type is not comparable, the check happens at run time: runtime error: comparing uncomparable type []int. The spec ↗ says two interface values are equal if they have identical dynamic types and equal dynamic values, and that comparing values whose dynamic type is not comparable causes a run-time panic. A map[any]T keyed with a slice panics at insertion for the same reason.
What boxing costs¶
Verified output of an_interface_is_two_words_boxing_go.go — regenerated by tools/run_examples.py, never hand-typed.
value boxed into any allocations per boxing
int 7 0
int 255 0
int 256 1
int 300 1
int -1 1
byte 255 0
float64 0 0
float64 3.14 1
string "" 0
string "invoice" 1
Order{1, 2} 1
struct{}{} 0
*Order 0
nil 0
the constant 300 0
Most values: one allocation. int 300, float64 3.14, string "invoice" and the two-field Order each cost one heap allocation per boxing — the copy the data word will point at. The runtime's conversion functions are in iface.go ↗: convT, convT64, convTstring and their siblings.
Integers 0–255: none. int 7, int 255 and byte 255 allocated nothing; int 256 and int -1 allocated once. convT64 in iface.go checks whether the value is below the length of staticuint64s, a 256-entry table of the values 0 to 255 that lives in the runtime, and points the data word into that table instead. -1 as a uint64 is not small. float64 0 shares the trick, because its bit pattern is zero; 3.14's is not.
Empty and zero-size values: none. string "" and struct{}{} point at a shared zero location; there is nothing to copy. nil is two nil words.
Pointers: none. The *Order row is the one to remember: a pointer in an interface costs no allocation at the boxing, because the data word is the pointer.
The constant 300: none — a compile-time effect. sink = 300 allocated nothing where sink = v300 allocated once, because the compiler builds the boxed constant as static data. That is why every value in the table comes through a //go:noinline function: without that, the first version of this program measured 0 for int 300 and string "invoice" as well, since the compiler had folded the variables into constants. go build -gcflags=-m shows the other side of this — the vet page's -gcflags=-m entry prints quantity escapes to heap for the boxing and leaking param: order for the pointer. When the interface value does not escape the function, the copy can live on the stack and the count is 0 there too; Escape analysis decides stack or heap is that story, and AllocsPerRun counts allocations the tool.
an_interface_is_two_words_go.go and an_interface_is_two_words_boxing_go.go
an_interface_is_two_words_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// An interface value is two machine words: a type word and a data word. For
// the empty interface the type word points at the dynamic type's descriptor;
// for an interface with methods it points at an itab that carries the type
// and the method table. The data word is a pointer to the value -- or, for
// a value that is itself a pointer, the pointer itself. This program reads
// the two words through unsafe and reports what they hold, never the numbers.
//
// go build an_interface_is_two_words_go.go && ./an_interface_is_two_words_go
package main
import (
"fmt"
"unsafe"
)
type Order struct{ ID, Qty int }
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return "invalid " + e.Field }
// words is the layout of every interface value in the runtime: two pointers.
type words struct {
typ unsafe.Pointer
data unsafe.Pointer
}
func wordsOf(p unsafe.Pointer) words { return *(*words)(p) }
func panicOf(f func()) (value any) {
defer func() { value = recover() }()
f()
return "none"
}
func main() {
var empty any
var err error
fmt.Println("unsafe.Sizeof(any) =", unsafe.Sizeof(empty))
fmt.Println("unsafe.Sizeof(error) =", unsafe.Sizeof(err))
fmt.Println("unsafe.Sizeof(uintptr) =", unsafe.Sizeof(uintptr(0)), "-- so an interface is two words")
w := wordsOf(unsafe.Pointer(&empty))
fmt.Println("nil any: type word nil?", w.typ == nil, " data word nil?", w.data == nil)
quantity := 7
var boxed any = quantity
w = wordsOf(unsafe.Pointer(&boxed))
fmt.Println("any holding int 7: type word nil?", w.typ == nil, " data word points at a 7?", *(*int)(w.data) == 7, " at the variable quantity?", w.data == unsafe.Pointer(&quantity))
quantity = 8
fmt.Println(" after quantity = 8, the any still holds", boxed, "-- the data word points at a copy")
order := &Order{ID: 1, Qty: 2}
var boxedPointer any = order
w = wordsOf(unsafe.Pointer(&boxedPointer))
fmt.Println("any holding *Order: data word == the pointer itself?", w.data == unsafe.Pointer(order))
var a, b any = 1, 2
wa, wb := wordsOf(unsafe.Pointer(&a)), wordsOf(unsafe.Pointer(&b))
fmt.Println("two anys holding ints: same type word?", wa.typ == wb.typ, " same data word?", wa.data == wb.data)
verr := &ValidationError{Field: "amount"}
err = verr
var asAny any = verr
we, wn := wordsOf(unsafe.Pointer(&err)), wordsOf(unsafe.Pointer(&asAny))
fmt.Println("error and any holding the same *ValidationError: same data word?", we.data == wn.data, " same type word?", we.typ == wn.typ, "(an itab for error, a type for any)")
// == compares both words: the type first, then the values they point at.
fmt.Println("any(7) == any(7) ", any(7) == any(7))
fmt.Println("any(int64(7)) == any(7) ", any(int64(7)) == any(7))
fmt.Println("any([]int{1}) == any([]int{1}) panics:", panicOf(func() { _ = any([]int{1}) == any([]int{1}) }))
}
an_interface_is_two_words_boxing_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// Putting a value into an interface -- boxing -- copies it to the heap so the
// data word has something to point at, unless the runtime already has that
// value: integers 0 to 255 come from a static table, zero-size values and
// empty strings need no storage, and a pointer is stored in the data word
// itself. testing.AllocsPerRun counts the heap allocations per boxing. Each
// value comes through a //go:noinline function so that the compiler cannot
// fold it into a constant, which it turns into static data at compile time
// (the last row).
//
// go build an_interface_is_two_words_boxing_go.go && ./an_interface_is_two_words_boxing_go
package main
import (
"fmt"
"testing"
)
type Order struct{ ID, Qty int }
var sink any
//go:noinline
func intValue(n int) int { return n }
//go:noinline
func byteValue(b byte) byte { return b }
//go:noinline
func floatValue(f float64) float64 { return f }
//go:noinline
func stringValue(s string) string { return s }
//go:noinline
func orderValue(o Order) Order { return o }
func row(what string, f func()) {
fmt.Printf("%-24s %v\n", what, testing.AllocsPerRun(1000, f))
}
func main() {
seven, v255, v256, v300, minusOne := intValue(7), intValue(255), intValue(256), intValue(300), intValue(-1)
b255 := byteValue(255)
zero, pi := floatValue(0), floatValue(3.14)
empty, invoice := stringValue(""), stringValue("invoice")
order := orderValue(Order{ID: 1, Qty: 2})
pointer := &order
var nothing struct{}
fmt.Printf("%-24s %s\n", "value boxed into any", "allocations per boxing")
row("int 7", func() { sink = seven })
row("int 255", func() { sink = v255 })
row("int 256", func() { sink = v256 })
row("int 300", func() { sink = v300 })
row("int -1", func() { sink = minusOne })
row("byte 255", func() { sink = b255 })
row("float64 0", func() { sink = zero })
row("float64 3.14", func() { sink = pi })
row("string \"\"", func() { sink = empty })
row("string \"invoice\"", func() { sink = invoice })
row("Order{1, 2}", func() { sink = order })
row("struct{}{}", func() { sink = nothing })
row("*Order", func() { sink = pointer })
row("nil", func() { sink = nil })
row("the constant 300", func() { sink = 300 })
}
What to do¶
- Hold pointers in interfaces that are made often. A
[]anyof structs allocates once per element on the way in; a[]anyof pointers to them allocates nothing at the boxing. The same goes for anerrorbuilt from a struct: return&ValidationError{...}, the pointer. - Do not design around the 0–255 table. It is an implementation detail of
iface.goat this version, and a program that depends on it will not say so. Measure a hot boxing withtesting.AllocsPerRunorb.ReportAllocs()and decide from the number —AllocsPerOpis a key,ns/opis not. - Compare interface values, or key a map with them, only when every dynamic type is comparable. The panic is at run time and only on the run that meets the slice.
- Read the two words through
unsafeonly to learn. The layout is the runtime's, not the language's;unsafe.Pointerreinterprets memory has the rules for the one tool that can see it.
Practice¶
A pipeline keeps sensor readings in a []any. With testing.AllocsPerRun, measure how many heap allocations storing one Reading value into a slot costs, and how many storing a *Reading costs. Take the reading from a //go:noinline function so the compiler cannot fold it. Print unsafe.Sizeof of a Reading and of an any, the two counts, and a sentence saying which of the two the pipeline should store and why.
Solution
an_interface_is_two_words_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// Kata: a pipeline keeps sensor readings in a []any. Measure with
// testing.AllocsPerRun how many heap allocations storing one Reading value
// costs against storing a *Reading, print both counts with the sizes of a
// Reading and of an any, and print which of the two you would store.
//
// go build an_interface_is_two_words_kata_go.go && ./an_interface_is_two_words_kata_go
package main
import (
"fmt"
"testing"
"unsafe"
)
type Reading struct {
Sensor int
Celsius float64
Tick int
}
//go:noinline
func readingValue(r Reading) Reading { return r }
func main() {
slot := make([]any, 1)
reading := readingValue(Reading{Sensor: 3, Celsius: 21.5, Tick: 40})
pointer := &reading
byValue := testing.AllocsPerRun(1000, func() { slot[0] = reading })
byPointer := testing.AllocsPerRun(1000, func() { slot[0] = pointer })
fmt.Println("unsafe.Sizeof(Reading) =", unsafe.Sizeof(reading), " unsafe.Sizeof(any) =", unsafe.Sizeof(slot[0]))
fmt.Println("allocations per store of a Reading: ", byValue)
fmt.Println("allocations per store of a *Reading:", byPointer)
switch {
case byPointer < byValue:
fmt.Println("store *Reading: the data word holds the pointer, so nothing is copied to the heap per store")
default:
fmt.Println("store Reading: no difference measured")
}
}
Verified output of an_interface_is_two_words_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.
In other languages¶
- The Rust library's Wide pointers ↗ and Static vs dynamic dispatch ↗ — a
&dyn TraitorBox<dyn Trait>is also two words, a data pointer and a vtable pointer, so Rust's trait object is Go'sifacewith the itab called a vtable. The difference is where the copy comes from: Rust never boxes a value for you —Box::new(v)is written by the programmer, and&vborrows without any allocation — while Go's conversion to an interface copies the value to the heap unless a table already holds it. - A C++ object with virtual functions carries its vtable pointer inside the object, so a
Base*is one word (cppreference ↗); Java likewise keeps the class pointer in the object header and an interface reference is one word. Go's design keeps the type outside the value, which is what lets a type satisfy an interface it never heard of.
Sources¶
- Russ Cox, Go Data Structures: Interfaces ↗, 1 December 2009 — the two-word layout, the itab, and why method calls are one indirection.
- The runtime:
src/runtime/runtime2.goat go1.25.5 ↗ (iface,eface) andsrc/runtime/iface.goat go1.25.5 ↗ (convT,convT64,convTstring,staticuint64s). - The Go specification: Comparison operators ↗ (interface equality and the run-time panic) and Interface types ↗.
unsafe.Sizeof↗ andtesting.AllocsPerRun↗ — the latter's source,src/testing/allocs.go↗, shows it countingMemStats.Mallocswith integer division, which is why the table holds whole numbers.- Alan A. A. Donovan and Brian W. Kernighan, The Go Programming Language (Addison-Wesley, 2015), chapter 7, "Interfaces" — section 7.5, "Interface Values" (the type and value pair, and comparing interface values).
- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 7, "Types, Methods, and Interfaces" — "Interfaces Are Comparable" and "The Empty Interface Says Nothing".
- Adam Freeman, Pro Go (Apress, 2022), chapter 11, "Using Methods and Interfaces" — "Comparing Interface Values" and "Using the Empty Interface".