Generics Implementation Deep Dive

Updated

September 8, 2026

Generics Implementation Deep Dive

Overview

Go generics (1.18+) are implemented with a hybrid strategy often described as dictionaries + partial stenciling (GC shape based). You do not need every compiler paper — you need the cost model: when generics are zero-cost abstraction vs when they introduce runtime dictionary calls or more code size.

Usage: Generics intro through Idiomatic generics.

Diagram: Generic call shapes

  generic source
       │
       v
  typecheck + constraints
       │
       v
  GC shape groups + dictionaries
       │
       v
  shared/specialized machine code

What the Compiler Sees

func Max[T cmp.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Calls with different type arguments may share generated code when the GC shape matches (same underlying representation for pointers, etc.), parameterized by a dictionary describing operations (comparison, conversion, …).

source generic
    |
    v
type check + capture constraints
    |
    v
shape grouping (pointer-ish shapes share)
    |
    v
code + dictionary for operations on T

Cost Dimensions

Dimension Effect
Code size Too many distinct shapes / specialized methods → binary growth
Call cost Dictionary-indirect calls can inhibit inlining
Allocation Generic APIs that box to any lose the point
Readability Complex constraint graphs hurt more than they help

Generics excel for:

  • Containers and algorithms over ~[]T, maps, comparables
  • Removing interface{} while keeping type checks

Generics struggle when:

  • Every method needs unique runtime behavior per type without structure
  • You force abstraction that a plain function + concrete types would inline better

Constraints Are Interfaces

type Number interface {
    ~int | ~int64 | ~float64
}

Constraint satisfaction is compile-time. Method sets on type parameters follow the constraint’s method set.

Instantiation

f := Max[int] // function value of specialized shape
v := Max(1, 2) // inference

Type inference fails when the compiler cannot unify arguments — add explicit type args rather than fighting it.

Interaction With Interfaces

func Print[T fmt.Stringer](v T) {
    fmt.Println(v.String())
}

Prefer type parameters when callers should keep static types through the call graph; prefer interfaces when you need heterogeneous collections or runtime plugin style.

Avoid These Footguns

  1. Generic + reflect by default — pays both complexity taxes.
  2. Over-abstracting one-use helpers — a function with int is fine.
  3. Exporting huge generic surface — every instantiation pressure multiplies for users.

Experiment

go mod init example
go run .
go build -gcflags='-m' . 2>&1 | head -40
package main

import (
    "cmp"
    "fmt"
)

func Max[T cmp.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

type Pair[K comparable, V any] struct {
    K K
    V V
}

func (p Pair[K, V]) String() string {
    return fmt.Sprintf("%v=%v", p.K, p.V)
}

func main() {
    fmt.Println(Max(3, 5))
    fmt.Println(Max(2.5, 1.2))
    fmt.Println(Pair[string, int]{K: "n", V: 1})
}

What to notice: One source definition serves multiple shapes. -gcflags=-m may show inlining decisions around simple generic functions.

Try next: Compare assembly of Max[int] vs a non-generic maxInt with go build -gcflags=-S and see if dictionary calls appear in your version.