Go Programming

Mastering Go Generics: Practical Examples That Actually Work

Go 1.18 introduced generics to the language, marking a significant milestone in Go's evolution. While the concept of generics might seem abstract at first, their practical applications can dramatically improve code reusability and type safety. In this comprehensive guide, we'll explore real-world scenarios where Go generics shine, moving beyond simple theoretical examples to actual implementation patterns you can use in production code.

Understanding the Power of Generic Functions

Generic functions in Go allow you to write code that works with multiple types while maintaining compile-time type safety. The key is to define functions that can operate on any type that satisfies certain constraints.

func Find[T comparable](slice []T, predicate func(T) bool) (T, bool) {
    var zero T
    for _, item := range slice {
        if predicate(item) {
            return item, true
        }
    }
    return zero, false
}

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    found, ok := Find(numbers, func(n int) bool { return n > 3 })
    if ok {
        fmt.Printf("Found: %d\n", found) // Output: Found: 4
    }
}

Working with Generic Slices and Collections

One of the most common use cases for generics is creating reusable collection utilities. Consider a function that filters slices based on conditions:

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() {
    numbers := []int{1, 2, 3, 4, 5, 6, 7, 8}
    evenNumbers := Filter(numbers, func(n int) bool { return n%2 == 0 })
    fmt.Println(evenNumbers) // Output: [2 4 6 8]
    
    strings := []string{"hello", "world", "go", "generics"}
    shortStrings := Filter(strings, func(s string) bool { return len(s) < 4 })
    fmt.Println(shortStrings) // Output: [go]
}

Generic Maps and Key-Value Operations

Maps in Go can also benefit from generics, especially when you need to perform operations across different key-value types:

func MapValues[K comparable, V any](m map[K]V, transform func(V) V) map[K]V {
    result := make(map[K]V)
    for k, v := range m {
        result[k] = transform(v)
    }
    return result
}

func main() {
    // Working with string keys and integer values
    scores := map[string]int{
        "Alice":  95,
        "Bob":    87,
        "Charlie": 92,
    }
    
    // Double all scores
    doubledScores := MapValues(scores, func(score int) int { return score * 2 })
    fmt.Println(doubledScores) // Output: map[Alice:190 Bob:174 Charlie:184]
    
    // Working with different types
    prices := map[string]float64{
        "laptop": 999.99,
        "mouse":  29.99,
    }
    
    withTax := MapValues(prices, func(price float64) float64 { return price * 1.08 })
    fmt.Println(withTax) // Output: map[laptop:1079.9892 mouse:32.3892]
}

Advanced Constraints and Interface-Based Generics

Go's constraint system allows you to define what types can be used with your generic functions. Here's a practical example using constraints to work with numeric types:

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

func Sum[T Number](numbers []T) T {
    var sum T
    for _, num := range numbers {
        sum += num
    }
    return sum
}

func Average[T Number](numbers []T) float64 {
    if len(numbers) == 0 {
        return 0
    }
    
    var sum T
    for _, num := range numbers {
        sum += num
    }
    return float64(sum) / float64(len(numbers))
}

func main() {
    intSlice := []int{1, 2, 3, 4, 5}
    fmt.Printf("Sum: %d\n", Sum(intSlice))     // Output: Sum: 15
    fmt.Printf("Average: %.2f\n", Average(intSlice)) // Output: Average: 3.00
    
    floatSlice := []float64{1.5, 2.5, 3.0}
    fmt.Printf("Sum: %.1f\n", Sum(floatSlice)) // Output: Sum: 7.0
    fmt.Printf("Average: %.2f\n", Average(floatSlice)) // Output: Average: 2.33
}

Building Generic Data Structures

Generics are particularly powerful when building reusable data structures. Consider implementing a generic stack:

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, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    
    index := len(s.items) - 1
    item := s.items[index]
    s.items = s.items[:index]
    return item, true
}

func (s *Stack[T]) Size() int {
    return len(s.items)
}

func main() {
    // Create a stack of integers
    intStack := &Stack[int]{}
    intStack.Push(1)
    intStack.Push(2)
    intStack.Push(3)
    
    item, ok := intStack.Pop()
    if ok {
        fmt.Printf("Popped: %d\n", item) // Output: Popped: 3
    }
    
    // Create a stack of strings
    stringStack := &Stack[string]{}
    stringStack.Push("hello")
    stringStack.Push("world")
    
    item2, ok2 := stringStack.Pop()
    if ok2 {
        fmt.Printf("Popped: %s\n", item2) // Output: Popped: world
    }
}

Real-World Application: Generic Repository Pattern

One of the most practical applications of generics is in implementing repository patterns that work with different entity types:

type Repository[T any] interface {
    Save(entity T) error
    FindByID(id string) (T, error)
    FindAll() []T
    Delete(id string) error
}

type InMemoryRepository[T any] struct {
    data map[string]T
}

func (r *InMemoryRepository[T]) Save(entity T) error {
    // Implementation would depend on your specific needs
    return nil
}

func (r *InMemoryRepository[T]) FindByID(id string) (T, error) {
    // Implementation would depend on your specific needs
    var zero T
    return zero, nil
}

func (r *InMemoryRepository[T]) FindAll() []T {
    // Implementation would return all entities
    var result []T
    return result
}

func (r *InMemoryRepository[T]) Delete(id string) error {
    // Implementation would delete entity by ID
    return nil
}

func main() {
    // Create repositories for different types
    userRepo := &InMemoryRepository[User]{}
    productRepo := &InMemoryRepository[Product]{}
    
    // Both have the same interface but work with different types
    // This eliminates code duplication while maintaining type safety
}

Conclusion

Go generics represent a powerful tool for writing more reusable, type-safe code. From simple utility functions like filtering and searching to complex data structures and repository patterns, generics enable you to write elegant solutions that work across multiple types without sacrificing performance or safety. As you continue working with Go, embrace generics as a way to reduce code duplication while maintaining clean, maintainable codebases.

The examples in this post demonstrate practical applications that you can immediately apply to your projects. Start with basic generic functions and gradually explore more complex patterns. Remember that while generics provide tremendous power, they should be used judiciously to maintain code readability. The key is to strike a balance between reusability and simplicity.

Share: