Methods on Defined Types — Optimize¶
This file focuses on the runtime cost of methods declared on non-struct defined types: aliases over int, string, slices, maps, and function types. The headline is that defined types are a compile-time fiction — at runtime they are indistinguishable from their underlying type, and methods on them generate the same machine code as free functions.
1. Method on int (Counter) vs free function — same machine code¶
These two compile to the same instructions. The receiver is just a first parameter under a different syntactic dress. After the front-end of the compiler resolves the method, both go through identical SSA passes.
Verification with -gcflags='-S'¶
The disassembly for (*Counter).Inc and IncFree differs only in symbol name:
"".(*Counter).Inc STEXT nosplit size=...
MOVQ "".c+8(SP), AX
INCQ (AX)
RET
"".IncFree STEXT nosplit size=...
MOVQ "".c+8(SP), AX
INCQ (AX)
RET
Benchmark¶
func BenchmarkMethodOnInt(b *testing.B) {
var c Counter
for i := 0; i < b.N; i++ {
c.Inc()
}
_ = c
}
func BenchmarkFreeFunction(b *testing.B) {
var c int
for i := 0; i < b.N; i++ {
IncFree(&c)
}
_ = c
}
Expected output on a typical x86-64 machine:
The method form is not slower. Choose whichever reads better at the call site.
2. Defined type as a zero-cost wrapper¶
type UserID int64
type OrderID int64
func GetUser(id UserID) *User { /* ... */ }
func GetOrder(id OrderID) *Order { /* ... */ }
UserID and OrderID carry no runtime tag, no boxing, no extra header. A UserID value is exactly 8 bytes — the same as the underlying int64. Mixing them is rejected by the compiler:
That mistake is caught at typecheck time. In a compiled binary, a UserID field uses the same memory layout as int64:
Allocation profile¶
func BenchmarkUserIDAlloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var id UserID = UserID(i)
_ = id
}
}
-benchmem reports 0 allocs/op. The wrapper exists only in the type system.
3. sort.IntSlice vs sort.Slice — closure cost¶
sort.IntSlice is a defined type over []int with three methods:
type IntSlice []int
func (p IntSlice) Len() int { return len(p) }
func (p IntSlice) Less(i, j int) bool { return p[i] < p[j] }
func (p IntSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
sort.Slice takes a closure:
The closure form must allocate a function value that captures xs, and every comparison goes through an indirect call. The defined-type form goes through an interface, but the receiver is shared and Less is a static method body that the inliner can sometimes shrink.
Benchmark¶
func BenchmarkSortIntSlice(b *testing.B) {
base := randomInts(10_000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
xs := append([]int(nil), base...)
sort.Sort(sort.IntSlice(xs))
}
}
func BenchmarkSortSliceClosure(b *testing.B) {
base := randomInts(10_000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
xs := append([]int(nil), base...)
sort.Slice(xs, func(i, j int) bool { return xs[i] < xs[j] })
}
}
Typical numbers:
BenchmarkSortIntSlice-8 3000 430 µs/op 0 allocs/op
BenchmarkSortSliceClosure-8 2000 720 µs/op 2 allocs/op
sort.Slice is roughly 1.5x slower because of closure allocation and indirect calls. For ints specifically, prefer sort.Ints or slices.Sort (Go 1.21+), which beat both.
4. type Handler func vs interface with one method¶
Two ways to express "a thing that handles requests":
// Defined function type
type Handler func(req Request) Response
// Interface
type IHandler interface {
Handle(req Request) Response
}
Calling h(req) on a Handler is an indirect function call. Calling h.Handle(req) on an IHandler is a method dispatch through an itab — also an indirect call. Both compile to one indirect jump.
Benchmark¶
var sink Response
func BenchmarkHandlerFunc(b *testing.B) {
var h Handler = func(r Request) Response { return Response{Code: r.ID} }
var r Request = Request{ID: 7}
for i := 0; i < b.N; i++ {
sink = h(r)
}
}
func BenchmarkHandlerInterface(b *testing.B) {
var h IHandler = concreteHandler{}
var r Request = Request{ID: 7}
for i := 0; i < b.N; i++ {
sink = h.Handle(r)
}
}
Both report ~2 ns/op on modern hardware. The function-type form is preferable when the contract is one method and you want callers to pass plain functions without a wrapper struct — it is the pattern http.HandlerFunc uses.
5. Generic methods: monomorphization vs interface dispatch¶
Go does not support type parameters on methods of a defined type, but it supports them on the type itself:
type Set[T comparable] map[T]struct{}
func (s Set[T]) Add(x T) { s[x] = struct{}{} }
func (s Set[T]) Has(x T) bool { _, ok := s[x]; return ok }
Under GCShape stenciling, the compiler emits one copy of Add per shape: - pointer/interface shape (one body, dictionary-based dispatch), - each scalar shape (one body per int/float/struct shape).
For Set[int] and Set[int32] you may get two bodies; for Set[*User] and Set[*Order] you get one shared body that goes through a dictionary.
Comparison with an interface alternative¶
type AnySet map[any]struct{}
func (s AnySet) Add(x any) { s[x] = struct{}{} }
func (s AnySet) Has(x any) bool { _, ok := s[x]; return ok }
Benchmark:
func BenchmarkGenericSetInt(b *testing.B) {
s := make(Set[int])
for i := 0; i < b.N; i++ { s.Add(i & 0xff) }
}
func BenchmarkAnySetInt(b *testing.B) {
s := make(AnySet)
for i := 0; i < b.N; i++ { s.Add(i & 0xff) }
}
Typical:
BenchmarkGenericSetInt-8 200000000 8 ns/op 0 allocs/op
BenchmarkAnySetInt-8 50000000 30 ns/op 1 alloc/op
The any version boxes every int into an interface, which forces an allocation on the hot path. The generic version stores the raw scalar.
6. Type alias vs defined type — runtime footprint¶
A type alias introduces zero new identity:
Bytes and []byte are the same type — methods declared on one are visible on the other (you cannot declare new methods on []byte from outside its package, but the alias case is academic). Buf is a distinct type with its own method set.
At runtime:
| Form | Method set | Memory layout | Method dispatch cost |
|---|---|---|---|
type Bytes = []byte | identical to []byte | identical | n/a |
type Buf []byte (no methods) | empty | identical | n/a |
type Buf []byte (with methods) | new | identical | identical to free fn |
In other words, introducing a defined type costs nothing at runtime unless you call its methods, and even then the cost equals a free function call. The only cost is in the type system — and that cost is what you wanted to pay for.
7. Embedded defined-type methods — same dispatch as struct embedding¶
type Bits uint64
func (b Bits) Has(flag uint64) bool { return uint64(b)&flag != 0 }
type Permissions struct {
Bits // embedded defined type
Owner string
}
Permissions{}.Has(0x4) resolves at compile time to a call on the Bits field. The compiler generates the same code as if you wrote:
There is no method table lookup, no interface conversion, no indirect call. Embedding a defined type is a syntactic convenience over writing the forwarder.
Inlining¶
Because Has is one expression, the inliner usually folds the entire chain — Permissions.Has -> Bits.Has -> bitwise AND — into a single AND instruction at the call site. Verify with:
8. Domain primitives — type safety with no perf cost¶
A common pattern: wrap raw scalars in named types to encode invariants.
type Cents int64
type Email string
type DurationMs int64
func (c Cents) Add(o Cents) Cents { return c + o }
func (c Cents) Format() string { return fmt.Sprintf("$%d.%02d", c/100, c%100) }
func (e Email) Domain() string { i := strings.IndexByte(string(e), '@'); return string(e)[i+1:] }
Each value occupies exactly the same bytes as its underlying type. There is no header, no vtable pointer, no padding. A []Cents of length 1000 is a contiguous 8000-byte buffer — a CPU prefetcher loves it.
Cache-friendliness benchmark¶
func BenchmarkSumCents(b *testing.B) {
xs := make([]Cents, 1<<20)
for i := range xs { xs[i] = Cents(i) }
b.ResetTimer()
for i := 0; i < b.N; i++ {
var total Cents
for _, c := range xs { total += c }
_ = total
}
}
Performs identically to the same loop over []int64. The wrapper is invisible to the cache hierarchy.
9. Value-receiver method on a small primitive — fits in a register¶
Temperature is 8 bytes — fits in a single XMM register on x86-64 (or a general register if the calling convention treats it as scalar). The method receiver is passed in that register; there is no stack spill.
Disassembly snippet¶
"".Temperature.Celsius STEXT nosplit
MOVSD "".t+8(SP), X0
MOVSD $f64.4071198000000000(SB), X1
SUBSD X1, X0
MOVSD X0, "".~r0+16(SP)
RET
No allocation, no escape, no GC barrier. The inliner can usually drop even the call.
Benchmark¶
var sinkF float64
func BenchmarkValueReceiverPrimitive(b *testing.B) {
var t Temperature = 300
for i := 0; i < b.N; i++ {
sinkF = t.Celsius()
}
}
Result: ~0.3 ns/op, indistinguishable from a free func toCelsius(t float64) float64.
Pointer receiver counter-example¶
For an 8-byte primitive, a pointer receiver is slower in tight loops — the pointer must be loaded and dereferenced. Use value receivers for primitives unless you must mutate.
10. When a defined-type method does cost more¶
The earlier sections all said "same as a free function." Two cases break that promise:
10.1 Method called through an interface¶
type Stringer interface { String() string }
type Hex int64
func (h Hex) String() string { return strconv.FormatInt(int64(h), 16) }
var s Stringer = Hex(42)
_ = s.String() // itab lookup, indirect call, not inlinable
The cost is identical to any other interface call (1–3 ns) and applies regardless of whether Hex is a struct or a primitive.
10.2 Method value taken in a hot loop¶
type Counter int
func (c *Counter) Inc() { *c++ }
for i := 0; i < n; i++ {
fn := (*Counter).Inc // method expression — fine
fn(c)
}
Method expressions like (*Counter).Inc are static and free. Method values like c.Inc allocate a closure that captures the receiver:
-gcflags='-m=2' will report:
Hoist the method value out of the loop, switch to a direct call, or use a method expression.
11. Cheat Sheet¶
DEFINED TYPES — RUNTIME COST
─────────────────────────────
type Foo int → zero runtime cost
type Foo = int → alias, zero cost, same identity
method on int → same code as free function
method on slice → same code, header copied
method on map → same code, map is a pointer
method on func type → indirect call, like fn()
ZERO-COST PATTERNS
─────────────────────────────
domain primitives (UserID, Cents, Email)
embedded defined-type forwarding
generic type with scalar shape
value-receiver method on primitive (≤16 B)
WATCH OUT
─────────────────────────────
interface dispatch → +1–3 ns per call
method value in hot loop → heap alloc per iteration
sort.Slice closure → vs sort.IntSlice ~1.5x slower
generic with `any` → boxes scalars, allocates
VERIFICATION
─────────────────────────────
go build -gcflags='-S' # disassembly
go build -gcflags='-m=2' # escape + inline analysis
go test -bench=. -benchmem # alloc/op
go tool pprof cpu.prof # hot paths
Summary¶
- A method on a defined non-struct type compiles to the same machine code as the equivalent free function.
- The defined type itself is a compile-time construct: same size, same layout, no header, no boxing.
sort.IntSlicebeatssort.Slicebecause it avoids closure allocation and the indirect call per comparison.type Handler func(...)and a one-method interface have identical dispatch cost; pick by ergonomics.- Generic types monomorphize per shape; scalar shapes avoid the boxing tax that
anyimposes. - Type aliases have zero identity and zero runtime; defined types have zero runtime when no methods are called.
- Embedded defined-type methods dispatch like struct embedding — direct, inlinable.
- Domain primitives (
UserID,Cents,Email) buy type safety for free at runtime. - Value-receiver methods on small primitives fit in registers and beat pointer receivers in tight loops.
- The two real cost sources are interface dispatch and method values in hot loops — both are independent of whether the underlying type is a struct.
The rule of thumb: reach for defined types whenever they make the code clearer. They are one of the few abstractions in Go that genuinely cost nothing — measure if you must, but trust the compiler here.