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.