Go 1.18 finally brought generics. In our NAS project, we refactored several common modules using generics. This post documents real-world benefits, pitfalls, and best practices we learned.
1. Why Do We Need Generics?
1.1 The Pain of Duplicate Code
Before generics, we had tons of code like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| func ContainsInt(slice []int, target int) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
func ContainsString(slice []string, target string) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
func ContainsInt64(slice []int64, target int64) bool {
// Same exact logic again...
}
|
1.2 The Cost of interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| func Contains(slice interface{}, target interface{}) bool {
// Requires reflection
s := reflect.ValueOf(slice)
for i := 0; i < s.Len(); i++ {
if reflect.DeepEqual(s.Index(i).Interface(), target) {
return true
}
}
return false
}
// Problems:
// 1. Poor performance (reflection)
// 2. Not type-safe (no compile-time checks)
// 3. Poor readability
|
2. Basic Generic Refactoring
2.1 Generic Functions
1
2
3
4
5
6
7
8
9
10
11
12
13
| // Go 1.18+ generic version
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
// Usage
Contains([]int{1, 2, 3}, 2) // true
Contains([]string{"a", "b"}, "c") // false
|
2.2 Generic Data Structures
Generic Set:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| type Set[T comparable] map[T]struct{}
func NewSet[T comparable](items ...T) Set[T] {
s := make(Set[T])
for _, item := range items {
s.Add(item)
}
return s
}
func (s Set[T]) Add(item T) {
s[item] = struct{}{}
}
func (s Set[T]) Contains(item T) bool {
_, ok := s[item]
return ok
}
func (s Set[T]) Remove(item T) {
delete(s, item)
}
// Usage
ids := NewSet(1, 2, 3)
ids.Contains(2) // true
|
Generic Result:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // Rust-inspired Result type
type Result[T any] struct {
value T
err error
}
func Ok[T any](value T) Result[T] {
return Result[T]{value: value}
}
func Err[T any](err error) Result[T] {
return Result[T]{err: err}
}
func (r Result[T]) Unwrap() T {
if r.err != nil {
panic(r.err)
}
return r.value
}
func (r Result[T]) UnwrapOr(defaultValue T) T {
if r.err != nil {
return defaultValue
}
return r.value
}
|
3. Real-World Refactoring Cases
3.1 Cache Module Refactoring
Before:
1
2
3
4
5
6
7
8
9
10
11
| type IntCache struct {
data map[string]int
mu sync.RWMutex
}
type StringCache struct {
data map[string]string
mu sync.RWMutex
}
// Had to write Get/Set/Delete for each type...
|
After:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| type Cache[K comparable, V any] struct {
data map[K]V
mu sync.RWMutex
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{
data: make(map[K]V),
}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *Cache[K, V]) Delete(key K) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
// Usage
userCache := NewCache[int64, *User]()
userCache.Set(1001, &User{Name: "Paul"})
|
3.2 Slice Utility Functions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| // Map: like map in other languages
func Map[T, R any](slice []T, fn func(T) R) []R {
result := make([]R, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// Filter: filter elements
func Filter[T any](slice []T, fn func(T) bool) []T {
result := make([]T, 0)
for _, v := range slice {
if fn(v) {
result = append(result, v)
}
}
return result
}
// Reduce: aggregate
func Reduce[T, R any](slice []T, initial R, fn func(R, T) R) R {
result := initial
for _, v := range slice {
result = fn(result, v)
}
return result
}
// Usage examples
users := []User{{Age: 20}, {Age: 30}, {Age: 25}}
ages := Map(users, func(u User) int { return u.Age }) // [20, 30, 25]
adults := Filter(users, func(u User) bool { return u.Age >= 25 }) // [Age:30, Age:25]
totalAge := Reduce(users, 0, func(sum int, u User) int { return sum + u.Age }) // 75
|
4. Pitfalls We Hit
4.1 Methods Can’t Have Type Parameters
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Wrong: methods can't have their own type parameters
type Container[T any] struct {
data []T
}
func (c *Container[T]) Map[R any](fn func(T) R) []R {
// Compile error: method must have no type parameters
}
// Correct: use a function instead
func Map[T, R any](c *Container[T], fn func(T) R) []R {
result := make([]R, len(c.data))
for i, v := range c.data {
result[i] = fn(v)
}
return result
}
|
4.2 Can’t Use Type Parameters in Type Assertions
1
2
3
4
5
6
7
8
9
10
| // Wrong
func Convert[T any](v interface{}) T {
return v.(T) // Compile error
}
// Correct: use any as intermediate type
func Convert[T any](v interface{}) (T, bool) {
result, ok := v.(T)
return result, ok
}
|
4.3 Zero Value Issues
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| func GetOrDefault[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero // Returns type's zero value
}
return *ptr
}
// But what if you need an explicit default?
func GetOrDefault[T any](ptr *T, defaultVal T) T {
if ptr == nil {
return defaultVal
}
return *ptr
}
|
4.4 Constraint Design
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Custom constraint
type Number interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
// Use golang.org/x/exp/constraints
import "golang.org/x/exp/constraints"
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
|
5.1 Generics vs Reflection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // Benchmark test
func BenchmarkGeneric(b *testing.B) {
slice := make([]int, 1000)
for i := range slice {
slice[i] = i
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
Contains(slice, 500)
}
}
func BenchmarkReflect(b *testing.B) {
slice := make([]int, 1000)
for i := range slice {
slice[i] = i
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ContainsReflect(slice, 500)
}
}
|
Results:
1
2
| BenchmarkGeneric-8 5000000 250 ns/op 0 B/op 0 allocs/op
BenchmarkReflect-8 500000 3200 ns/op 100 B/op 5 allocs/op
|
Generics are 12x faster with zero allocations!
5.2 Generics vs Hand-Written
1
2
3
4
5
6
7
| func BenchmarkGenericContains(b *testing.B) {
// Generic version
}
func BenchmarkHandwrittenContains(b *testing.B) {
// func ContainsInt([]int, int) bool version
}
|
Results:
1
2
| BenchmarkGeneric-8 5000000 250 ns/op
BenchmarkHandwritten-8 5000000 248 ns/op
|
Nearly identical! Go compiler generates specialized code for each type (monomorphization).
6. Best Practices
6.1 When to Use Generics
Good fit:
- Container types (List, Set, Map, Stack, Queue)
- Common algorithms (sort, search, filter)
- Utility functions (Contains, Map, Reduce)
Not a good fit:
- Business logic code (don’t over-abstract)
- Only one or two types (not worth generalizing)
- Need runtime type info (generics are compile-time)
6.2 Constraint Selection
1
2
3
4
5
6
7
8
9
10
| // Prefer built-in constraints
any // Any type
comparable // Types supporting == !=
// Use x/exp/constraints
constraints.Ordered // Supports < > <= >=
constraints.Integer // Integers
constraints.Float // Floats
constraints.Signed // Signed numbers
constraints.Unsigned // Unsigned numbers
|
6.3 Naming Conventions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // Type parameter naming
T // Single type
K, V // Key-value pair
T, R // Input-output
E // Element
S // Slice
// Constraint naming
type Stringer interface {
String() string
}
type Comparable[T any] interface {
Compare(T) int
}
|
7. Project Benefits
7.1 Code Reduction
| Module | Before | After | Reduction |
|---|
| Cache | 450 lines | 120 lines | -73% |
| Slice utils | 280 lines | 80 lines | -71% |
| Data structures | 600 lines | 200 lines | -67% |
7.2 Maintenance Cost
- Bug fixes: Used to fix N versions, now fix once
- New types: Used to copy code, now just use
- Type safety: Errors caught at compile time
8. Summary
| Point | Recommendation |
|---|
| Use cases | Containers, utilities, algorithms |
| Performance | Equal to hand-written, far better than reflection |
| Constraints | Prefer any/comparable/constraints |
| Methods | Methods can’t have type params, use functions |
| Zero values | Use var zero T to get them |
Key takeaway: Generics aren’t a silver bullet, but in the right scenarios, they significantly reduce duplicate code while maintaining type safety and performance.