Rez Moss

Rez Moss

Personal Musings: A Blog for the Tech-Savvy and Curious Mind

Go Generics: A Practical Introduction

Oct 2023

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

  1. Use generics when you need to write functions or data structures that work with multiple types in a type-safe manner.
  2. Avoid overusing generics. If a function only needs to work with a specific type, using generics may add unnecessary complexity.
  3. Choose appropriate constraints to ensure type safety and to communicate the requirements of your generic code.
  4. 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.