07 - Generics

1) What are Generics?

Definition

Generics (parametric polymorphism) let you write a function or type once and use it with many concrete types. Instead of duplicating MinInt, MinFloat64, and MinString, you write Min[T] once. The compiler specializes it for each type used.

The compiler resolves type parameters at compile time. It substitutes actual types for parameters (e.g. T=int) and generates optimized code. This yields zero runtime dispatch overhead-performance is identical to handwritten versions.

2) Two forms of polymorphism

Runtime vs compile-time

  • Runtime polymorphism - adaptation happens at runtime. Example: Go interfaces. They enable flexible substitution but use dynamic dispatch (small runtime cost).
  • Compile-time polymorphism - adaptation is resolved at compile time. Example: Go generics, C++ templates, Rust generics. The compiler emits type-specific machine code, so there’s no dispatch overhead.
Note

Key contrast

Interfaces => decoupling via contracts (runtime flexibility). Generics => code reuse via type parameters (compile-time specialization).

3) Example: Generic Min

Generic definition (Go 1.24)

min.go
package main

import (
	"cmp"
	"fmt"
)

// Min works for any ordered type.
func Min[T cmp.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Min(3, 7))     // 3
	fmt.Println(Min(3.5, 2.1)) // 2.1
	fmt.Println(Min("a", "z")) // a
}

Compiler expansion (conceptual)

When you call Min with ints, floats, or strings, the compiler instantiates type-specific versions. Conceptually, it becomes:

compiler-expansion.go
// Illustration only - the compiler specializes Min at compile time,
// emitting machine code, not these named Go functions.
func Min_int(a, b int) int { if a < b { return a }; return b }
func Min_float64(a, b float64) float64 { if a < b { return a }; return b }
func Min_string(a, b string) string { if a < b { return a }; return b }
Note

Performance

After compilation, generics behave like normal type-specific functions. There is no runtime indirection or reflection.

4) Constraints & Type Sets

Concept

Constraints describe what operations a type parameter must support. In Go, constraints are written as interfaces that define a type set: the set of types allowed for the parameter.

Examples

constraints.go
package main

import "fmt"

// Custom numeric constraint with underlying-type (~) support.
type Number interface {
	~int | ~int32 | ~int64 |
	~uint | ~uint64 |
	~float32 | ~float64
}

func Sum[T Number](vals []T) T {
	var total T
	for _, v := range vals {
		total += v
	}
	return total
}

func main() {
	fmt.Println(Sum([]int{1, 2, 3}))      // 6
	fmt.Println(Sum([]float64{1.5, 2.5})) // 4.0
}
Note

Underlying types (~)

~int means: accept int and any custom type defined on top of int (e.g., type MyInt int). Without ~, the constraint matches only the builtin int (and aliases like type X = int), so a defined type (type MyInt int) would not match.
  • any = no constraint (all types).
  • comparable = all strictly comparable types (safe for ==, !=).
  • cmp.Ordered (std as of 1.21+) = all built-in ordered types supporting <, >.

5) Compiler internals

Instantiation & specialization

  • The compiler represents functions with type parameters in its IR.
  • On each call, it substitutes the concrete types (instantiation).
  • Constraints are checked; compilation fails if unmet.
  • Specialized machine code is generated (monomorphization).

Optimization

Instantiation occurs before optimization, so the compiler can inline and specialize aggressively. Generic code often compiles to identical assembly as handwritten functions.

6) Advanced

Type inference

Go infers type arguments from usage, so callers rarely specify them explicitly. Example: slices.Sort([]int{3,1,2}) infers T=int. Passing generic function values benefits from improved inference in recent releases.

Design guidance

  • Keep constraints as narrow as correctness requires.
  • Prefer any, comparable, and cmp.Ordered for ergonomic APIs.
  • Leverage inference so callers rarely write type args explicitly.
  • Prefer standard helpers (slices, maps, cmp) before rolling your own.

Real-world patterns

  • Generic containers: priority queues, sets, ring buffers.
  • Functional helpers: Map, Filter, Reduce over slices.
  • Sorting/search utilities: generic binary search, min/max.

Example 1

main.go
package main

import "fmt"

func Map[T any, U any](in []T, f func(T) U) []U {
	res := make([]U, len(in))
	for i, v := range in {
		res[i] = f(v)
	}
	return res
}

func Filter[T any](in []T, pred func(T) bool) []T {
	res := make([]T, 0, len(in))
	for _, v := range in {
		if pred(v) {
			res = append(res, v)
		}
	}
	return res
}

func Reduce[T any, R any](in []T, init R, f func(R, T) R) R {
	acc := init
	for _, v := range in {
		acc = f(acc, v)
	}
	return acc
}

type Number interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
	~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
	~float32 | ~float64
}

func Sum[T Number](xs []T) T {
	return Reduce(xs, T(0), func(acc, v T) T { return acc + v })
}

func Any[T any](xs []T, pred func(T) bool) bool {
	return Reduce(xs, false, func(acc bool, v T) bool { return acc || pred(v) })
}

func All[T any](xs []T, pred func(T) bool) bool {
	return Reduce(xs, true, func(acc bool, v T) bool { return acc && pred(v) })
}

func main() {
	nums := []int{1, 2, 3, 4, 5, 6}
	evens := Filter(nums, func(n int) bool { return n%2 == 0 })
	squares := Map(evens, func(n int) int { return n * n })
	total := Reduce(squares, 0, func(acc, v int) int { return acc + v })

	fmt.Println("even squares:", squares, "sum:", total)
	fmt.Println("sum(nums):", Sum(nums))
	fmt.Println("any > 4?:", Any(nums, func(n int) bool { return n > 4 }))
	fmt.Println("all even?:", All(nums, func(n int) bool { return n%2 == 0 }))
}

Example 2

main.go
package main

import (
	"fmt"
)

type Animal interface {
	Speak() string
}

type Dog struct{ Name string }

func (d Dog) Speak() string { return "woof: " + d.Name }

type Cat struct{ Name string }

func (c Cat) Speak() string { return "meow: " + c.Name }

// Generic polymorphic collection constrained by the interface.
type Collection[T Animal] struct {
	items []T
}

func (c *Collection[T]) Add(v T) { c.items = append(c.items, v) }

func (c *Collection[T]) ForEach(fn func(T)) {
	for _, v := range c.items {
		fn(v)
	}
}

func Map[T Animal, U Animal](in Collection[T], f func(T) U) Collection[U] {
	res := Collection[U]{items: make([]U, len(in.items))}
	for i, v := range in.items {
		res.items[i] = f(v)
	}
	return res
}

func Filter[T Animal](in Collection[T], pred func(T) bool) Collection[T] {
	res := Collection[T]{items: make([]T, 0, len(in.items))}
	for _, v := range in.items {
		if pred(v) {
			res.items = append(res.items, v)
		}
	}
	return res
}

func main() {
	// Heterogeneous collection
	zoo := Collection[Animal]{}
	zoo.Add(Dog{"Bau bau"})
	zoo.Add(Cat{"Myau myau"})

	fmt.Println("all:")
	zoo.ForEach(func(a Animal) { fmt.Println(a.Speak()) })

	// Use Filter: keep only Dogs
	dogsOnly := Filter(zoo, func(a Animal) bool {
		_, ok := a.(Dog)
		return ok
	})

	fmt.Println("dogs only:")
	dogsOnly.ForEach(func(a Animal) { fmt.Println(a.Speak()) })
}

7) Generic type aliases (Go 1.24)

Why

Generic type aliases allow API evolution without breaking users. You can move or rename a generic type and keep the old name as a true alias.

Example

main.go
package main

import "fmt"

// Imagine the real implementation is "NewStack[T]".
type NewStack[T any] struct {
	items []T
}

func (s *NewStack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *NewStack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}
	v := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return v, true
}

// Old name kept as an alias (Go 1.24+ generic type alias).
// Existing users of OldStack[T] keep working without changes.
type OldStack[T any] = NewStack[T]

func main() {
	var a OldStack[int] // old name
	a.Push(10)
	a.Push(20)
	v, _ := a.Pop()
	fmt.Println("pop:", v) // 20

	var b NewStack[string] // new name
	b.Push("x")
	b.Push("y")
	w, _ := b.Pop()
	fmt.Println("pop:", w) // y

	// Interchangeability: alias and real type assign freely
	var c NewStack[int]
	c = a
	c.Push(99)
	u, _ := c.Pop()
	fmt.Println("pop:", u) // 99
}