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.
Key contrast
3) Example: Generic Min
Generic definition (Go 1.24)
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:
// 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 }Performance
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
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
}
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, andcmp.Orderedfor 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,Reduceover slices. - Sorting/search utilities: generic binary search, min/max.
Example 1
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
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
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
}