Go Generics - A Quick Overview

A large part of this article is based on Ian Lance Taylor's great Using Generics in Go talk that was presented at Go Day 2021.

The generics topic polarized the Go community and still does, even though not as much as it did initially. In the meantime the acceptance for adding generics to the language has increased a lot and only a a small—but not negligible—part of the Go community is rejecting the idea. Rarely in the last five years of Go programming has there been a situation where generics were either required or where they would have dramatically improved the readability or maintainability of my code. Of course, there are certain things that are much easier to implement in a reusable fashion with generics. For me this has been writing custom data structures, mostly sets and once or twice a tree, and also for utility and library code that worked with numeric types or on string and byte slices.

Overall, I think they will be a great addition to the language and will enable the community to provide a series of powerful, new libraries. If you want to take a deep dive into the topic then take a look at type parameters proposal which explains how generics will look like in Go 1.18, planned to be released in February 2022.

Now that we've talked about generics, what are they actually?

Generics let you write data-structures and functions with types specified later.

Note that this feature is sometimes referred to as parametric polymorphism. Functions and types in Go 1.18+ may receive so called type parameters which help to generalize them over a set of applicable types, hence the name generics. This might sound confusing at first but I think the following example will clarify the concept.

func min[T constraints.Ordered](a, b T) T {
	if a < b {
		return a
	} else {
		return b
	}
}

// min(12,3) will return 3

You can try out the above example using gotip.1 What makes this function different from a regular Go function is the type parameter list [T constraints.Ordered]. It looks like a normal function parameter list, but is instead enclosed in square brackets. Each type parameter has a constraint that limits the set of actual types it can be instantiated with. The constraints package that ships with Go 1.18 will provide a set common constraints, but new ones can be defined as well (refer to the proposal if you want to know how to define them). There is also the special any constraint that allows all types without restriction.

Type parameters are not limited to functions, they can also be applied to types. Below you will see the implementation of a simple stack with type parameters. A stack is a primitive first-in first-out (FIFO) data structure.

type Stack[T any] struct {
    elements []T
}

func NewStack[T any]() *Stack[T] {
	return &Stack[T]{elements: []T{}}
}

func (s *Stack[T]) Push(val T) {
    s.elements = append(s.elements, val)
}

func (s *Stack[T]) Pop() T {
    val := s.elements[len(s.elements)-1]
    s.elements = s.elements[:len(s.elements)-1]
    return val
}

Following is short example that demonstrates how the stack works and how to call a function with type parameters but no arguments.

s := NewStack[int]()
s.Push(10)
s.Push(20)
fmt.Println(s.Pop(), s) // 20 &{[10]}
fmt.Println(s.Pop(), s) // 10 &{[]}

Type parameters lists are not limited to a single parameter of course, e.g. func F[A any, B constraints.Integer](a A, b B) {} is perfectly valid.

When to use Generics?

Ian Lance Taylor's talk also gave some guidelines on when to use generics.

Start with writing plain functions, type parameters can easily be added later.

Another important question that was addressed in the talk is When are type parameters useful?

  • [For] Functions that work on slices, maps and channels of any element type.
  • For general purpose data structures.

The next guideline is particularly interesting because it might prevent a design failure that could lead to an cumbersome to use API:

When operating on type parameters, prefer functions over methods!

Let's say your type constraint requires a method like isEqual(t T) bool to be defined on any applicable type. This means that any type T would need to implement this method. As you can imagine this will get inconvenient pretty fast, especially for types not defined in your package. To illustrate this approach a function will be implemented that only adds a value to a slice if it is not already contained, just like add to a set works. Any value T must be equatable, i.e. it must implement a isEqual(T) bool method.

func AppendIfNew[T interface{ isEqual(T) bool}](s []T, val T) []T {
    for _, x := range s {
        if x.isEqual(val) {
            return s // unchanged
        }
    }
    return append(s, val)
}

type myInt int

func (m myInt) isEqual(x myInt) bool {
	return m == x
}

// AppendIfNewBad([]myInt{1,2,3,4},5)
// will return []myInt{1,2,3,4,5}

Of course we could not just use an []int with AppendIfNewBad because int does not implement our custom "equatable" interface. So we needed to implement a custom int type that satisfies the interface. Luckily there's a better way. Instead of requiring a method on T let AppendIfNew accept a function that checks for equality.

func AppendIfNew[T any](s []T, val T, isEqual func(T,T) bool) []T {
    for _, x := range s {
        if isEqual(x, val) {
            return s // unchanged
        }
    }
    return append(s, val)
}

// AppendIfNew[int]([]int{1,2,3,4}, 5, func(a, b int) bool { return a == b })
// will return []int{1,2,3,4,5}

With the words of Ian Lance Taylor:

It's much simpler to turn a method into a function than it is to add a method to a type!

When not to use generics?

Generics are no panacea, so it is important to consider when not to use them.

Do not use generics when you are just calling a method on the type argument.

func Copy[R io.Reader, W io.Writer](r R, w W) (int64, error) {
    return io.Copy(w, r)
}

The type parameters above are redundant and can be replaced by just accepting an io.Reader and io.Writer as shown below:

func Copy(r io.Reader, w io.Writer) (int64, error) {
    return io.Copy(w, r)
}

Two additional rules given in the talk will mark the end of the article. I hope you learned something!

  • Do not use generics when the implementation of a common method is different for each type.
  • Do not use type parameters prematurely; wait until you're about to write boilerplate code.

Premature optimization is the root of all evil.


  1. it won't compile in the go2go because it is not compatible with the accepted generics proposal. ↩︎