Generics in Go
Generics in Go: From Basics to Advanced
Generics were introduced in Go 1.18, bringing parametric polymorphism to the language. Let's explore them comprehensively.
Table of Contents
Basic Generic Concepts
Generics allow writing code that works with multiple types while maintaining type safety:
func PrintSlice[T any](s []T) {
for _, v := range s {
fmt.Println(v)
}
}
Key concepts:
-
Type parameters (
[T any]) -
Type constraints (
any,comparable, custom constraints) -
Instantiation (compile-time generation of concrete functions)
Type Parameters
Declared in square brackets before function parameters:
// Single type parameter
func Identity[T any](t T) T {
return t
}
// Multiple type parameters
func MapKeys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
Generic Functions
Functions that work with multiple types:
// Basic generic function
func Swap[T any](a, b T) (T, T) {
return b, a
}
// Using with different types
a, b := Swap(1, 2) // int
x, y := Swap("hello", "world") // string
Generic Types
Types that can be parameterized:
// Generic stack type
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() T {
if len(s.items) == 0 {
panic("empty stack")
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
// Usage
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
fmt.Println(intStack.Pop()) // 2
Type Constraints
Define what operations are available on type parameters:
Built-in Constraints
-
any- any type (equivalent tointerface{}) -
comparable- types that can be compared with == and !=
Custom Constraints
type Number interface {
~int | ~float64 | ~uint // Using type approximation (~)
}
func Sum[T Number](numbers []T) T {
var total T
for _, n := range numbers {
total += n
}
return total
}
Method Constraints
type Stringer interface {
String() string
}
func PrintString[T Stringer](t T) {
fmt.Println(t.String())
}
Type Inference
Go can often infer type parameters:
// Type inferred from arguments
fmt.Println(Sum([]int{1, 2, 3})) // T inferred as int
fmt.Println(Sum([]float64{1.1, 2.2})) // T inferred as float64
// Explicit type parameters
fmt.Println(Sum[float64]([]int{1, 2, 3})) // Convert int to float64
Advanced Patterns
Generic Interfaces
type Container[T any] interface {
Add(item T)
Get(index int) T
}
type GenericSlice[T any] []T
func (g *GenericSlice[T]) Add(item T) {
*g = append(*g, item)
}
func (g GenericSlice[T]) Get(index int) T {
return g[index]
}
Recursive Generic Types
type TreeNode[T any] struct {
Value T
Left *TreeNode[T]
Right *TreeNode[T]
}
Higher-Order Generic Functions
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
// Usage
numbers := []int{1, 2, 3, 4, 5}
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
Generic Methods (with type parameters on receivers)
type Pair[A, B any] struct {
First A
Second B
}
func (p Pair[A, B]) Swap() Pair[B, A] {
return Pair[B, A]{p.Second, p.First}
}
Performance Considerations
-
No runtime overhead - code is generated at compile time
-
Binary size increase - due to multiple instantiations
-
Compile time impact - generics can slow down compilation
-
Memory usage - each instantiation creates specialized code
Best Practices
-
Start simple - don't overuse generics prematurely
-
Use descriptive type parameter names (T for simple cases, K/V for maps, etc.)
-
Prefer constraints over
anywhen possible -
Document generic functions thoroughly
-
Consider performance implications for hot code paths
-
Avoid complex type hierarchies - keep it Go-like
-
Test with different types to ensure flexibility
-
Use type inference where it improves readability
-
Be cautious with method sets - generic methods have limitations
-
Watch for error messages - they can be complex with generics
Complete Example
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// Basic generic function
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
// Generic type
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() T {
if len(s.items) == 0 {
panic("stack is empty")
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
// Custom constraint
type Number interface {
constraints.Integer | constraints.Float
}
func Average[T Number](numbers []T) T {
if len(numbers) == 0 {
return 0
}
var sum T
for _, n := range numbers {
sum += n
}
return sum / T(len(numbers))
}
// Higher-order generic function
func Map[T, U any](slice []T, f func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = f(v)
}
return result
}
func main() {
// Using Max function
fmt.Println("Max int:", Max(3, 7))
fmt.Println("Max float:", Max(3.14, 2.71))
// Using Stack type
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
fmt.Println("Popped:", intStack.Pop())
// Using Average function
fmt.Println("Average:", Average([]float64{1.0, 2.0, 3.0}))
// Using Map function
numbers := []int{1, 2, 3}
squares := Map(numbers, func(n int) int { return n * n })
fmt.Println("Squares:", squares)
// Type inference in action
fmt.Println("Max inferred:", Max(3.5, 7.2)) // float64
}