Go generics are more powerful than you might think, enabling truly reusable code without sacrificing performance or type safety.
Let’s look at a common scenario: processing a slice of data. Imagine you have functions that operate on []int and []string, and you want to consolidate them.
package main
import "fmt"
// ProcessInts doubles each integer in a slice.
func ProcessInts(nums []int) []int {
result := make([]int, len(nums))
for i, n := range nums {
result[i] = n * 2
}
return result
}
// ProcessStrings uppercases each string in a slice.
func ProcessStrings(strs []string) []string {
result := make([]string, len(strs))
for i, s := range strs {
result[i] = s + " processed"
}
return result
}
func main() {
ints := []int{1, 2, 3}
processedInts := ProcessInts(ints)
fmt.Println(processedInts) // Output: [2 4 6]
strings := []string{"a", "b", "c"}
processedStrings := ProcessStrings(strings)
fmt.Println(processedStrings) // Output: [a processed b processed c processed]
}
This works, but it’s repetitive. If you add a new type, say []float64, you’d write another similar function. Generics let us write a single function that handles any type.
Here’s the generic version using a type parameter T:
package main
import "fmt"
// ProcessSlice applies a function to each element of a slice and returns a new slice.
func ProcessSlice[T any](items []T, processFunc func(T) T) []T {
result := make([]T, len(items))
for i, item := range items {
result[i] = processFunc(item)
}
return result
}
func main() {
ints := []int{1, 2, 3}
processedInts := ProcessSlice(ints, func(n int) int { return n * 2 })
fmt.Println(processedInts) // Output: [2 4 6]
strings := []string{"a", "b", "c"}
processedStrings := ProcessSlice(strings, func(s string) string { return s + " processed" })
fmt.Println(processedStrings) // Output: [a processed b processed c processed]
floats := []float64{1.1, 2.2, 3.3}
processedFloats := ProcessSlice(floats, func(f float64) float64 { return f * 1.5 })
fmt.Println(processedFloats) // Output: [1.65 3.3 4.95]
}
The [T any] part declares a type parameter T. any is an alias for interface{}, meaning T can be any type. The processFunc takes an element of type T and returns an element of type T. The ProcessSlice function then uses this processFunc on each element.
The real power comes when you constrain your types. Instead of any, you can specify interfaces that your generic type T must satisfy.
Consider a scenario where you need to sum numeric types. You can’t just use any because you can’t add arbitrary types.
package main
import "fmt"
// SumSlice sums elements of a slice.
// This will NOT compile because '+' is not defined for 'any'.
// func SumSlice[T any](items []T) T {
// var sum T
// for _, item := range items {
// sum += item // Error: invalid operation: sum + item (operator + not defined on T)
// }
// return sum
// }
To fix this, you define a constraint. Go’s standard library provides useful ones in the golang.org/x/exp/constraints package (though these are often integrated into the language or commonly defined by users). A common set of numeric types can be represented.
package main
import "fmt"
// Number is a constraint that matches any integer or floating-point type.
type Number interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 | uintptr |
float32 | float64
}
// SumSlice sums elements of a slice of numbers.
func SumSlice[T Number](items []T) T {
var sum T // Initializes to zero value for T (e.g., 0 for int, 0.0 for float64)
for _, item := range items {
sum += item
}
return sum
}
func main() {
ints := []int{1, 2, 3, 4, 5}
fmt.Println("Sum of ints:", SumSlice(ints)) // Output: Sum of ints: 15
floats := []float64{1.1, 2.2, 3.3, 4.4, 5.5}
fmt.Println("Sum of floats:", SumSlice(floats)) // Output: Sum of floats: 16.5
}
Here, Number is an interface that uses a union of types (|). The SumSlice function now only accepts slices where T is one of those numeric types, and the += operation is valid.
A crucial pattern is using generics with maps. You can create generic map types or functions that operate on maps.
package main
import "fmt"
// GetKeys returns a slice of keys from a map.
func GetKeys[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
}
func main() {
stringIntMap := map[string]int{"apple": 1, "banana": 2, "cherry": 3}
fmt.Println("String keys:", GetKeys(stringIntMap)) // Output: String keys: [apple banana cherry] (order may vary)
intStringMap := map[int]string{10: "ten", 20: "twenty"}
fmt.Println("Int keys:", GetKeys(intStringMap)) // Output: Int keys: [10 20] (order may vary)
}
Notice the comparable constraint on K. Map keys in Go must be comparable (support == and !=). The comparable built-in constraint enforces this.
One pitfall to watch out for is performance. While Go generics are designed to be as performant as non-generic code, certain patterns can introduce overhead. Generics are typically implemented using monomorphization (where the compiler generates specialized code for each type) or boxing (where values are put into interfaces). Go’s strategy is closer to monomorphization, aiming for zero-cost abstractions. However, if your generic function internally boxes and unboxes values repeatedly, you might see performance degradation.
For instance, using any (which is interface{}) within a generic function can lead to this if not careful, as the compiler might not always be able to infer concrete types for operations.
package main
import "fmt"
// This version of ProcessSlice uses 'any' and might be less performant
// if 'processFunc' itself involves type assertions or reflection.
func ProcessSliceAny[T any](items []T, processFunc func(T) T) []T {
result := make([]T, len(items))
for i, item := range items {
// If processFunc is not optimized by the compiler to handle T directly,
// there could be implicit interface conversions.
result[i] = processFunc(item)
}
return result
}
The key is that the compiler tries to optimize. If your processFunc is simple and directly operates on T, it’s usually fine. If processFunc itself is generic or uses interface{}, the overhead can creep in. Always benchmark if performance is critical.
Another common mistake is forgetting that generic functions don’t automatically implement methods on types. You can’t define a method on []T directly. You need to define a new generic type.
package main
import "fmt"
// MySlice is a generic type that wraps a slice.
type MySlice[T any] []T
// Append adds an element to the MySlice.
func (ms *MySlice[T]) Append(item T) {
*ms = append(*ms, item)
}
func main() {
var intSlice MySlice[int]
intSlice.Append(10)
intSlice.Append(20)
fmt.Println(intSlice) // Output: [10 20]
var stringSlice MySlice[string]
stringSlice.Append("hello")
stringSlice.Append("world")
fmt.Println(stringSlice) // Output: [hello world]
}
Here, MySlice[T] is a new generic type. T can be any type, and MySlice has an Append method that operates on elements of type T. This is how you build generic data structures with methods.
The most surprising thing about Go generics is how they interact with the type inference system. The compiler is often smart enough to figure out the type parameters for you. You don’t always need to specify them explicitly, as seen in the ProcessSlice and GetKeys examples. However, in ambiguous cases or when defining types, explicit type arguments might be necessary.
The next frontier is often exploring more complex constraints, perhaps involving recursive types or unions of interfaces, and understanding how the Go compiler optimizes these patterns for production readiness.