Generic Functions and Types in Go
Introduction
Generic functions and types allow you to write code that works with any type while maintaining compile-time type safety. This guide covers practical patterns for building reusable generic components.
Core Concepts
Generic Functions
Generic functions use type parameters to work with multiple types:
func Process[T any](items []T) []T {
// Works with any type T
}
Generic Types
Generic types (structs, interfaces) can hold or work with any type:
type Box[T any] struct {
value T
}
Good: Generic Functions
Utility Functions
package main
import (
"fmt"
"sort"
)
// โ
GOOD: Generic slice operations
func Reverse[T any](slice []T) []T {
result := make([]T, len(slice))
for i, v := range slice {
result[len(slice)-1-i] = v
}
return result
}
// โ
GOOD: Generic find function
func Find[T any](slice []T, predicate func(T) bool) (T, bool) {
for _, item := range slice {
if predicate(item) {
return item, true
}
}
var zero T
return zero, false
}
// โ
GOOD: Generic map function
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, item := range slice {
result[i] = fn(item)
}
return result
}
// โ
GOOD: Generic reduce function
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
result := initial
for _, item := range slice {
result = fn(result, item)
}
return result
}
// โ
GOOD: Generic filter function
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, item := range slice {
if predicate(item) {
result = append(result, item)
}
}
return result
}
func main() {
// Reverse
nums := []int{1, 2, 3, 4, 5}
fmt.Println(Reverse(nums))
// Find
found, ok := Find(nums, func(n int) bool { return n > 3 })
fmt.Printf("Found: %d (%v)\n", found, ok)
// Map
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println(doubled)
// Reduce
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
fmt.Printf("Sum: %d\n", sum)
// Filter
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(evens)
}
Generic Sorting
package main
import (
"fmt"
"sort"
)
// โ
GOOD: Generic sort function
type Ordered interface {
~int | ~int32 | ~int64 | ~float32 | ~float64 | ~string
}
func SortSlice[T Ordered](slice []T) {
sort.Slice(slice, func(i, j int) bool {
return slice[i] < slice[j]
})
}
// โ
GOOD: Generic sort with custom comparator
func SortWith[T any](slice []T, less func(T, T) bool) {
sort.Slice(slice, func(i, j int) bool {
return less(slice[i], slice[j])
})
}
func main() {
nums := []int{3, 1, 4, 1, 5, 9}
SortSlice(nums)
fmt.Println(nums)
strs := []string{"banana", "apple", "cherry"}
SortSlice(strs)
fmt.Println(strs)
// Custom sort
people := []struct {
name string
age int
}{
{"Alice", 30},
{"Bob", 25},
{"Charlie", 35},
}
SortWith(people, func(a, b struct {
name string
age int
}) bool {
return a.age < b.age
})
fmt.Println(people)
}
Good: Generic Types
Generic Data Structures
package main
import (
"fmt"
"sync"
)
// โ
GOOD: Generic queue
type Queue[T any] struct {
mu sync.Mutex
items []T
}
func (q *Queue[T]) Enqueue(item T) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
}
func (q *Queue[T]) Dequeue() (T, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.items) == 0 {
var zero T
return zero, false
}
item := q.items[0]
q.items = q.items[1:]
return item, true
}
// โ
GOOD: Generic cache
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{
items: make(map[K]V),
}
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.items[key]
return val, ok
}
// โ
GOOD: Generic tree node
type TreeNode[T any] struct {
Value T
Left *TreeNode[T]
Right *TreeNode[T]
}
func (n *TreeNode[T]) InOrder(fn func(T)) {
if n == nil {
return
}
n.Left.InOrder(fn)
fn(n.Value)
n.Right.InOrder(fn)
}
func main() {
// Queue
q := &Queue[int]{}
q.Enqueue(1)
q.Enqueue(2)
q.Enqueue(3)
for {
val, ok := q.Dequeue()
if !ok {
break
}
fmt.Println(val)
}
// Cache
cache := NewCache[string, int]()
cache.Set("answer", 42)
cache.Set("pi", 3)
if val, ok := cache.Get("answer"); ok {
fmt.Printf("answer = %d\n", val)
}
// Tree
root := &TreeNode[int]{
Value: 2,
Left: &TreeNode[int]{Value: 1},
Right: &TreeNode[int]{Value: 3},
}
root.InOrder(func(val int) {
fmt.Printf("%d ", val)
})
}
Generic Interfaces
package main
import (
"fmt"
)
// โ
GOOD: Generic repository interface
type Repository[T any] interface {
Create(item T) error
Read(id string) (T, error)
Update(item T) error
Delete(id string) error
List() ([]T, error)
}
// โ
GOOD: Generic service using repository
type Service[T any] struct {
repo Repository[T]
}
func (s *Service[T]) GetAll() ([]T, error) {
return s.repo.List()
}
func (s *Service[T]) GetByID(id string) (T, error) {
return s.repo.Read(id)
}
// โ
GOOD: Generic handler
type Handler[T any] struct {
service *Service[T]
}
func (h *Handler[T]) HandleList() ([]T, error) {
return h.service.GetAll()
}
func main() {
// Example usage with concrete type
type User struct {
ID string
Name string
}
// Would implement Repository[User]
// Then create Service[User]
// Then create Handler[User]
fmt.Println("Generic interfaces enable flexible architectures")
}
Advanced Patterns
Generic Middleware
package main
import (
"fmt"
"time"
)
// โ
GOOD: Generic middleware pattern
type Handler[T any] func(T) (T, error)
func WithLogging[T any](handler Handler[T]) Handler[T] {
return func(input T) (T, error) {
fmt.Printf("Processing: %v\n", input)
start := time.Now()
result, err := handler(input)
fmt.Printf("Completed in %v\n", time.Since(start))
return result, err
}
}
func WithRetry[T any](handler Handler[T], maxRetries int) Handler[T] {
return func(input T) (T, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
result, err := handler(input)
if err == nil {
return result, nil
}
lastErr = err
}
var zero T
return zero, lastErr
}
}
func main() {
// Create a handler
handler := func(input int) (int, error) {
return input * 2, nil
}
// Wrap with middleware
wrapped := WithLogging(WithRetry(handler, 3))
result, _ := wrapped(21)
fmt.Printf("Result: %d\n", result)
}
Generic Builder Pattern
package main
import (
"fmt"
)
// โ
GOOD: Generic builder
type Builder[T any] struct {
value T
steps []func(*T) error
}
func NewBuilder[T any](initial T) *Builder[T] {
return &Builder[T]{value: initial}
}
func (b *Builder[T]) Add(step func(*T) error) *Builder[T] {
b.steps = append(b.steps, step)
return b
}
func (b *Builder[T]) Build() (T, error) {
for _, step := range b.steps {
if err := step(&b.value); err != nil {
return b.value, err
}
}
return b.value, nil
}
func main() {
type Config struct {
Name string
Value int
}
config, _ := NewBuilder(Config{}).
Add(func(c *Config) error {
c.Name = "test"
return nil
}).
Add(func(c *Config) error {
c.Value = 42
return nil
}).
Build()
fmt.Printf("Config: %v\n", config)
}
Best Practices
1. Name Type Parameters Clearly
// โ
GOOD: Clear names
func Process[Item any, Result any](items []Item, fn func(Item) Result) []Result {
// Clear what each type parameter represents
}
// โ BAD: Unclear names
func Process[T, U any](items []T, fn func(T) U) []U {
// What are T and U?
}
2. Use Constraints Appropriately
// โ
GOOD: Specific constraint
func Sum[T interface{ ~int | ~float64 }](values []T) T {
// Clear what types are allowed
}
// โ BAD: Too broad
func Sum[T any](values []T) T {
// Can't use + operator on any type
}
3. Avoid Over-Generalization
// โ
GOOD: Specific when needed
func GetUser(id string) (*User, error) {
// Specific type is clearer
}
// โ BAD: Unnecessary generics
func Get[T any](id string) (T, error) {
// Adds complexity without benefit
}
Resources
- Go Generics: https://go.dev/doc/tutorial/generics
- Type Parameters: https://go.dev/ref/spec#Type_parameters
- Constraints: https://go.dev/ref/spec#Constraints
Summary
Generic functions and types enable you to write reusable, type-safe code. Use them for utility functions, data structures, and interfaces that work with multiple types. Keep constraints specific and avoid over-generalization.
Comments