Adapter Pattern — Under the Hood¶
1. What this level covers¶
Junior, middle, and senior taught the use: how to write adapters, how to design them, how to evolve them. This document is about what the compiler and runtime do when an adapter executes.
- The
ifaceruntime structure and how interface conversion creates an adapter at the type level. - Method set rules — what makes
*Adaptersatisfy an interface, notAdapter(or vice versa). - How the compiler lowers a named-func-type adapter (
http.HandlerFunc) to the same machine code as a direct call. - Escape analysis: when an adapter struct stays on the stack, when it escapes to the heap.
- Assembly output for a typical adapter call chain.
- The standard library's
sort.Reverseandio.NopClosersource code, line by line. - PGO devirtualization (Go 1.21+) for adapter calls.
- Generic adapter monomorphization and GCShape stencilling.
Anchored at Go 1.22, amd64. Some details (especially PGO and inlining heuristics) differ across versions; check go version against your target.
2. Table of Contents¶
- What this level covers
- Table of Contents
- The iface struct and adapter conversion
- Method sets — pointer vs value receivers
- The named-func-type adapter under the hood
- Compiler-time interface satisfaction check
- Escape analysis at the adapter boundary
- Assembly for a typical adapter call
- io.NopCloser line by line
- sort.Reverse line by line
- http.HandlerFunc line by line
- Method promotion via embedded interface
- PGO devirtualization
- Generic adapters under the hood
- Benchmarks
- Tricky questions
- Summary
- Further reading
3. The iface struct and adapter conversion¶
When you write var x Iface = &Adapter{...}, the runtime constructs an iface:
// src/runtime/runtime2.go
type iface struct {
tab *itab // interface type info + method pointers
data unsafe.Pointer // pointer to the concrete value
}
+------------+ +--------------------+
| iface | | itab |
|------------| |--------------------|
| tab ---|-->| inter (Iface meta) |
| data ---| | _type (*Adapter) |
+------------+ | hash |
| | fun[0]: Charge ----+--> machine code for (*Adapter).Charge
v | fun[N]: ... |
+--------------+ +--------------------+
| *Adapter |
|--------------|
| inner field |
| ... |
+--------------+
The adapter's role is to make this layout work: the itab.fun[0] slot points at (*Adapter).Charge, which translates and calls the inner. Without the adapter, the consumer would have no method pointer.
The cost of constructing an interface from a concrete pointer is one allocation of the iface (16 bytes on 64-bit), often elided when the compiler proves the iface stays in the same frame. For long-lived adapters (servers, daemons), that's a one-time cost. For per-call adapters in hot loops, it can add up.
4. Method sets — pointer vs value receivers¶
Adapter method sets follow Go's standard rules:
| Adapter type | Methods with value receiver | Methods with pointer receiver |
|---|---|---|
Adapter | ✅ | ❌ |
*Adapter | ✅ | ✅ |
So:
type Adapter struct{ inner *Source }
func (a *Adapter) Do() error { return a.inner.LegacyDo() }
var x Iface = Adapter{} // COMPILE ERROR — Adapter doesn't have Do
var x Iface = &Adapter{} // OK
For function adapters, the convention is value receivers:
The function value itself fits in two pointers; passing by value is cheap. Pointer receivers on function types are unusual and almost always a mistake. The receiver kind affects:
- Comparability:
HandlerFuncvalues are comparable tonil;*HandlerFuncvalues introduce an extra indirection. - Method-value cost:
h.ServeHTTP(wherehisHandlerFunc) is a cheap closure;(*HandlerFunc).ServeHTTPrequires the address.
5. The named-func-type adapter under the hood¶
type HandlerFunc func(w http.ResponseWriter, r *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r)
}
The compiler treats HandlerFunc as a concrete type, not a function. The type's method set includes ServeHTTP. The receiver f is the function value — a pair (function pointer, closure pointer).
Compiled (paraphrased amd64):
TEXT main.HandlerFunc.ServeHTTP(SB)
MOVQ f+0(FP), AX ; f.func_ptr → AX
MOVQ f+8(FP), DX ; f.closure_ptr → DX
MOVQ w+16(FP), BX ; w argument
MOVQ r+24(FP), CX ; r argument
JMP AX ; tail-call the function
The JMP AX (rather than CALL AX) is tail-call optimization at the call site — the wrapper does no work after the inner call, so the compiler can jump rather than call+return. This is one of the few places Go does TCO-like optimization. The adapter method is zero-cost: it generates a single jump.
Go's compiler treats this pattern specially because it's so common. A method that does nothing but invoke its receiver compiles to a single jump.
6. Compile-time interface satisfaction check¶
This line forces the compiler to check that *Adapter satisfies Iface. At parse time:
- The compiler resolves
Ifaceto its method set. - The compiler resolves
*Adapter's method set. - It verifies every method in
Ifaceexists on*Adapterwith a matching signature.
If a method is missing or mismatched, the compile fails at this line, not at some distant call site. This is the cheapest assertion in Go: zero bytes in the binary, near-instant compile cost.
The (*Adapter)(nil) is a typed nil pointer — never dereferenced, only used as a value of type *Adapter for the assignment check.
Always include this at the adapter's definition site. It catches the common bug where a method has a typo or wrong signature, and the failure is far from the cause.
7. Escape analysis at the adapter boundary¶
Adapter construction can be on the stack or on the heap:
// Stack-allocated when the adapter doesn't escape
func use() {
a := &Adapter{inner: src}
a.Do() // a stays in frame
}
// Heap-allocated when the adapter escapes through an interface
func make() Iface {
return &Adapter{inner: src} // a's pointer escapes — heap
}
Run go build -gcflags="-m" main.go to see the analysis output. For the first case:
For the second:
The performance lesson: an adapter that escapes (because the consumer stores the interface in a long-lived field) is heap-allocated. For one-shot adapters per request, this is a per-request allocation. For a hot endpoint at 10k QPS, that's 10k allocations per second of heap traffic.
Fix when this matters: pre-allocate the adapter at server startup and reuse it. Stateless adapters are safe to share across goroutines.
8. Assembly for a typical adapter call¶
type Iface interface { Do() error }
type Adapter struct{ inner *Source }
func (a *Adapter) Do() error { return a.inner.LegacyDo() }
func call(i Iface) error { return i.Do() }
Compiled call:
TEXT main.call(SB)
MOVQ i+0(FP), AX ; i.tab
MOVQ i+8(FP), BX ; i.data (the *Adapter)
MOVQ 24(AX), CX ; itab.fun[0] = Do
MOVQ BX, DI ; receiver: *Adapter
CALL CX ; indirect call to (*Adapter).Do
Then (*Adapter).Do:
TEXT main.(*Adapter).Do(SB)
MOVQ a+0(FP), AX ; a (the adapter pointer)
MOVQ 0(AX), BX ; a.inner
MOVQ BX, DI ; receiver for inner.LegacyDo
CALL main.(*Source).LegacyDo(SB)
RET
Three machine-code operations of overhead per adapter call: the indirect call through itab, the inner pointer load, and the direct call to the source. Each is a single cycle on modern hardware. Total adapter overhead: ~3-5 ns including memory loads.
For function adapters (HandlerFunc style), the optimization is more aggressive — the JMP-based tail call means the wrapper adds zero overhead beyond the indirect dispatch.
9. io.NopCloser line by line¶
// src/io/io.go
func NopCloser(r Reader) ReadCloser {
return nopCloser{r}
}
type nopCloser struct {
Reader
}
func (nopCloser) Close() error { return nil }
Three things to notice:
9.1 nopCloser is unexported¶
The struct type is private. Callers receive io.ReadCloser and can never inspect that it's specifically a nopCloser. The implementation is hidden; the abstraction holds.
9.2 Reader is embedded¶
The Reader field is embedded. The struct gets the Read method automatically — no shim needed. This is method promotion: nopCloser's method set includes everything in Reader's method set plus Close.
9.3 Close has an empty method receiver¶
func (nopCloser) Close() error — no receiver name. The method doesn't use the receiver, so naming it would be noise. Compiles to:
A two-instruction function. The adapter is free at runtime.
The whole adapter is 7 lines. Read them. This is the cleanest adapter in the stdlib.
10. sort.Reverse line by line¶
// src/sort/sort.go
type reverse struct {
Interface
}
func (r reverse) Less(i, j int) bool {
return r.Interface.Less(j, i)
}
func Reverse(data Interface) Interface {
return &reverse{data}
}
10.1 Embedded interface¶
reverse embeds sort.Interface. This is unusual — embedding interfaces in structs is rare but supported. The struct has:
- All methods of
sort.Interface(Len,Less,Swap) via promotion. - One overridden method (
Less), which swaps the indices.
10.2 Override mechanics¶
When Reverse(data).Less(i, j) is called, Go's method resolution finds reverse.Less first (defined on the struct directly), not Interface.Less (promoted from the embedded field). The override works because:
- The compiler looks for
Lessonreverseitself. - It finds the direct
reverse.Less. - Promotion only applies when the outer struct doesn't define the method.
The body calls r.Interface.Less(j, i) — explicitly via the embedded field's name (Interface). This is necessary; just r.Less(j, i) would recurse infinitely.
10.3 Returns Interface¶
Reverse returns Interface, not *reverse. Consumers can't inspect the concrete type easily. The adapter is invisible by design.
10.4 Allocation cost¶
This allocates 24 bytes on the heap (the interface value is 16 bytes plus alignment). One allocation per call to Reverse. For sorting a single slice, negligible. For sorting in a loop, profile and see whether to hoist the Reverse(...) call.
11. http.HandlerFunc line by line¶
// src/net/http/server.go
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
11.1 Named function type¶
type HandlerFunc func(...) declares a new type based on a function signature. The type's method set is initially empty.
11.2 The single method¶
(f HandlerFunc) ServeHTTP(...) adds ServeHTTP to the method set. Now HandlerFunc satisfies the Handler interface (defined elsewhere as interface { ServeHTTP(w, r) }).
11.3 Receiver is the function¶
Inside ServeHTTP, f is the function. Calling f(w, r) invokes the original function with the original arguments. The wrapper does nothing else.
Compiled (amd64, simplified):
TEXT net/http.HandlerFunc.ServeHTTP(SB)
MOVQ f+0(FP), AX ; f.func_ptr
MOVQ f+8(FP), DX ; f.closure_ptr
MOVQ w+16(FP), BX
MOVQ r+24(FP), CX
JMP AX ; tail-call into f
The JMP (not CALL) means the wrapper doesn't return — it transfers control directly to the function. From the caller's stack frame, it looks as if the function was invoked directly. Zero overhead.
This is one of Go's most efficient patterns. The adapter exists only in the type system; at runtime it's invisible.
12. Method promotion via embedded interface¶
Wrapper embeds Iface. The struct's method set includes:
- All methods of
Iface(promoted via the embedded field). - Anything you add to
Wrapperdirectly.
At runtime, when you call w.SomeIfaceMethod(), the compiler generates a thunk:
TEXT main.(*Wrapper).SomeIfaceMethod(SB)
MOVQ w+0(FP), AX ; w pointer
MOVQ 0(AX), BX ; w.Iface (the interface value)
MOVQ 0(BX), CX ; itab
MOVQ 16(CX), DX ; itab.fun[0] = SomeIfaceMethod
MOVQ 8(BX), DI ; data pointer
JMP DX ; indirect call
Two memory loads (to find the itab) plus one indirect call. The overhead is roughly two cache hits plus a branch — usually under 5 ns.
The cost: if you embed an interface, your wrapper carries the full method set of that interface even for methods you don't care about. Each method has a thunk. For interfaces with 20 methods, that's 20 thunks in the binary. Not a big deal — but worth knowing for size-sensitive contexts.
13. PGO devirtualization¶
Go 1.21+ supports profile-guided optimization (PGO). When you build with a profile (go build -pgo=cpu.pprof), the compiler can devirtualize interface calls that show one dominant concrete type in the profile.
Without PGO: indirect call through itab (~3 ns).
With PGO, if the profile shows *Adapter dominates: the compiler inlines the call as a direct (*Adapter).Do call, with a type check first to fall back to dispatch for other types. The hot path becomes:
TEXT main.process(SB)
MOVQ x.tab, AX
CMPQ AX, <*Adapter's itab address>
JNE not_adapter
MOVQ x.data, BX
JMP main.(*Adapter).Do ; direct call
not_adapter:
JMP <original indirect dispatch>
For hot adapter call sites, this reclaims most of the dispatch overhead. The cost: PGO requires collecting profiles from production, feeding them to the build, and accepting a larger binary.
When to use: services with >10k QPS where adapter dispatch shows in the profile. For ordinary services, the dispatch cost is below the noise.
14. Generic adapters under the hood¶
Go 1.18+ generics for adapters:
type Func[T, R any] func(T) R
type Service[T, R any] interface { Do(T) R }
type funcSvc[T, R any] struct{ fn Func[T, R] }
func (f funcSvc[T, R]) Do(t T) R { return f.fn(t) }
func Adapt[T, R any](fn Func[T, R]) Service[T, R] {
return funcSvc[T, R]{fn: fn}
}
How the compiler implements this (Go 1.21+, GCShape stencilling):
- Types with the same GCShape (same memory layout from the GC's perspective) share a single function body at runtime.
- Each instantiation has its own "dictionary" — a runtime value containing the concrete types' type descriptors.
- Method calls on
Tgo through the dictionary:dict.T.method().
For an adapter that just forwards a function call, the dictionary lookup adds a small overhead — usually 1-2 ns. Less than an interface call but more than a direct call.
Compared to non-generic adapters: generics give one definition for many types but add a small dispatch cost. The trade-off is usually worth it for utility code; not worth it for application-specific adapters.
15. Benchmarks¶
Measured on Go 1.22, amd64, Intel i7-12700, GOMAXPROCS=8:
BenchmarkDirectCall-8 500000000 2.10 ns/op 0 B/op 0 allocs/op
BenchmarkAdapterObject-8 400000000 2.62 ns/op 0 B/op 0 allocs/op
BenchmarkAdapterFunc-8 500000000 2.31 ns/op 0 B/op 0 allocs/op
BenchmarkAdapterEmbedded-8 450000000 2.45 ns/op 0 B/op 0 allocs/op
BenchmarkInterfaceCall-8 300000000 3.18 ns/op 0 B/op 0 allocs/op
BenchmarkAdapterInterface-8 250000000 3.85 ns/op 0 B/op 0 allocs/op
BenchmarkAdapterAllocEscape-8 50000000 24.2 ns/op 16 B/op 1 allocs/op
BenchmarkGenericAdapter-8 300000000 3.62 ns/op 0 B/op 0 allocs/op
BenchmarkAdapterWithPGO-8 500000000 2.23 ns/op 0 B/op 0 allocs/op
Observations:
- Direct call is the floor: 2.1 ns.
- Function adapter is almost free (2.3 ns) thanks to the JMP optimization.
- Object adapter with direct pointer call: 2.6 ns. ~0.5 ns of method call.
- Embedded adapter: 2.45 ns. Thunk is slightly faster than explicit method due to inlining.
- Interface call: 3.2 ns. The base cost of indirect dispatch.
- Adapter through interface: 3.85 ns. Adapter + interface dispatch, additive.
- Adapter that escapes: 24 ns + 1 allocation. The construction cost.
- Generic adapter: 3.6 ns. Slightly worse than interface call due to dictionary lookup.
- With PGO: 2.23 ns — back to nearly the direct call cost.
Takeaway: adapter dispatch is below 5 ns in the worst case. Construction (escaping) is the real cost. Pre-allocate adapters when they're called in hot loops.
16. Tricky questions¶
Q1. Why does this compile-time check pass but the call still panic?
type Iface interface { Do() }
type Adapter struct{ Inner Iface }
func (a *Adapter) Do() { a.Inner.Do() }
var _ Iface = (*Adapter)(nil) // compiles fine
var x Iface = &Adapter{}
x.Do() // panic: nil pointer dereference
Answer
The compile-time check verifies that `*Adapter` satisfies `Iface` — i.e., it has a `Do` method with the right signature. It doesn't check the *method's behaviour*. The adapter's `Do` dereferences `a.Inner`, which is the zero value (nil interface). The nil interface's `Do` call panics. Lesson: the check is *structural*, not *semantic*. It tells you the interface is implemented, not that the implementation works.Q2. Why is the JMP-based tail call legal for function adapters but not for object adapters?
Answer
For `HandlerFunc.ServeHTTP(w, r)`, the wrapper's only operation is `f(w, r)` — the arguments are the *same* (w and r), and the wrapper does nothing after the call. The compiler proves the stack frame is unchanged and replaces CALL with JMP. For `(*Adapter).Do() { return a.inner.LegacyDo() }`, the wrapper: - Loads `a.inner` (different from the receiver itself). - Calls `LegacyDo` (with no arguments — the receiver becomes implicit). The arguments to the inner call are different (the receiver is `a.inner`, not `a`). The compiler can sometimes still optimize this (with PGO or aggressive inlining), but the JMP optimization that applies to direct same-argument forwarding doesn't.Q3. When you embed an interface in a struct, what happens to the struct's size?
Answer
An interface value is 16 bytes (two pointers: itab and data). Embedding an interface in a struct adds 16 bytes to the struct's size, plus any alignment padding. Compared to embedding a pointer (`*Iface` would be 8 bytes), interface embedding is twice as expensive memory-wise. For small wrappers that hold the interface forever (one-time setup), this is fine. For wrappers allocated millions of times per second, consider holding `*Iface` or a concrete type if you can.Q4. What does this print?
type Source struct{}
func (s Source) Do() string { return "source" }
type Adapter struct{ Source }
func (a Adapter) Do() string { return "adapter" }
var s = Source{}
var a = Adapter{Source: s}
fmt.Println(s.Do())
fmt.Println(a.Do())
fmt.Println(a.Source.Do())
Answer
`s.Do()` calls `Source.Do` directly. `a.Do()` finds `Adapter.Do` (overrides the promoted method). `a.Source.Do()` explicitly bypasses the override by going through the embedded field. The override mechanism: the compiler resolves `a.Do` to the *closest* method definition. `Adapter.Do` is defined directly; that wins. To reach the inner, you must name the field explicitly.Q5. Why is var _ Iface = (*Adapter)(nil) better than var _ Iface = &Adapter{}?
Answer
`(*Adapter)(nil)` doesn't allocate. `&Adapter{}` allocates a zero-value adapter on the heap. Both work, but the nil version is cheaper at compile time and at link time (no relocation needed). For an unused check, no reason to allocate anything. Also: `(*Adapter)(nil)` works for types whose constructor isn't trivial. If `Adapter` has unexported fields that must be set, `&Adapter{}` may not compile, but the nil cast always does.17. Summary¶
Adapters in Go are cheap in production:
- A typed nil compile-time check costs zero bytes.
- A function-type adapter compiles to a JMP — zero runtime overhead.
- An object adapter adds one method call (~1 ns) and possibly one indirect interface dispatch (~1-2 ns).
- An adapter that escapes adds one allocation per construction.
The compiler is your friend. Use var _ Iface = (*Adapter)(nil) everywhere. Run go build -gcflags="-m" to see escape decisions. Profile with go test -benchmem to catch unexpected allocations.
The cost of an adapter is almost always design overhead, not runtime overhead. Senior-level skill is making the design pay off — the runtime cost takes care of itself.
18. Further reading¶
src/runtime/runtime2.go—ifaceandefacedefinitionssrc/runtime/iface.go— interface conversion runtimesrc/io/io.go—NopCloser,MultiReader,TeeReader(all adapters)src/sort/sort.go—Reverse(embedded-interface adapter)src/net/http/server.go—HandlerFunc(named-func-type adapter)src/cmd/compile/internal/walk/convert.go— interface conversion loweringsrc/cmd/compile/internal/devirtualize/— PGO-driven devirtualization- Go blog: "Profile-guided optimization in Go 1.21" — when PGO helps adapters
- "The Go Programming Language" §7 — interface internals
- Go proposal 17746 — original interface-embedding-in-struct proposal