Go Generics: A Practical Introduction
Generics, introduced in Go 1.18, represent a significant evolution in the language’s type system. They allow developers to write functions and data structures that can work with multiple types while maintaining type safety. This article provides a practical introduction to Go generics, demonstrating how they can make your code more reusable and type-safe.
Understanding Type Parameters
At the core of Go’s generics are type parameters. They allow you to write functions and types that can work with different types without sacrificing type safety. Here’s a simple example:
func PrintSlice[T any](s []T) {
for _, v := range s {
fmt.Println(v)
}
}
In this function, T
is a type parameter that can represent any type. The [T any]
syntax declares the type parameter, and any
is a constraint that allows T
to be any type.
Constraints
Constraints specify what types can be used as type arguments. Go provides several predefined constraints in the constraints
package, such as Ordered
for types that can be ordered. You can also define custom constraints:
type Number interface {
int | float64
}
func Sum[T Number](numbers []T) T {
var sum T
for _, n := range numbers {
sum += n
}
return sum
}
This Sum
function works with slices of either int
or float64
.
Generic Data Structures
Generics are particularly useful for creating flexible data structures. Here’s an example of 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
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
This stack can be used with any type:
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
stringStack := &Stack[string]{}
stringStack.Push("hello")
stringStack.Push("world")
Type Inference
Go’s type inference often allows you to omit type arguments when calling generic functions:
numbers := []int{1, 2, 3, 4, 5}
sum := Sum(numbers) // No need to write Sum[int](numbers)
The compiler infers that T
should be int
based on the argument type.
Practical Use Case: Generic Sorting
Let’s look at a more practical example using generics to create a flexible sorting function:
import "golang.org/x/exp/constraints"
func SortSlice[T constraints.Ordered](s []T) {
for i := 0; i < len(s)-1; i++ {
for j := i + 1; j < len(s); j++ {
if s[i] > s[j] {
s[i], s[j] = s[j], s[i]
}
}
}
}
This function can sort slices of any type that satisfies the Ordered
constraint:
intSlice := []int{3, 1, 4, 1, 5, 9}
SortSlice(intSlice)
fmt.Println(intSlice) // Output: [1 1 3 4 5 9]
stringSlice := []string{"banana", "apple", "cherry"}
SortSlice(stringSlice)
fmt.Println(stringSlice) // Output: [apple banana cherry]
Best Practices
- Use generics when you need to write functions or data structures that work with multiple types in a type-safe manner.
- Avoid overusing generics. If a function only needs to work with a specific type, using generics may add unnecessary complexity.
- Choose appropriate constraints to ensure type safety and to communicate the requirements of your generic code.
- Leverage type inference to make your code more readable.
Conclusion
Generics in Go provide a powerful tool for writing flexible, reusable, and type-safe code. While they add some complexity, judicious use of generics can lead to more maintainable and robust Go programs. As you explore generics, remember that they’re a tool to solve specific problems – use them when they genuinely simplify your code and improve its reusability.