A Gentle Introduction to Generics in Go

May 1, 2022
6 Cards

The Problem

Let’s say we want to implement a simple tree data structure, consisting of nodes. Each node holds a value. Prior to Go 1.18, the typical way to implement this looked as follows:

type Node struct {
  value interface{}
}

This works fine in most cases, but it comes with a few drawbacks.

First, interface{} can be anything. If we wanted to restrict the possible types value can hold, for example integers and floats, we would be forced to check this at runtime:

func (n Node) IsValid() bool {
  switch n.value.(type) {
    case int, float32, float64:
      return true
    default:
      return false
  }
}

There was no possibility to restrict the allowed types at compile time. Type switches like the one above were common practice in many Go libraries. Here’s a real-world example.

Second, working with the value in Node is verbose and error-prone. Doing anything with the value always involves some kind of type assertion, even if you can safely assume that value holds an integer:

number, ok := node.value.(int)
if !ok {
  // ...
}

double := number * 2

These are only some of the inconveniences of working with interface{}, which provides no type safety and bears the risk of critical runtime errors.

The Solution

Instead of accepting every data type or a concrete type, we define a “placeholder type” called T as the type of value. Note that the code won’t compile yet.

type Node[T] struct {
  value T
}

The generic type T needs to be declared first, which is done using square brackets after the struct or function name. At this time, T could be any type. Only when instantiating a Node with an explicit type, T will be that type.

n := Node[int]{
  value: 5,
}

The generic Node is instantiated as a Node[int] (speak: node of integer), so T is an integer.

Type Constraints

In fact, the declaration of T is lacking a required information: A type constraint.

Type constraints are used to further restrict the possible types that can be used as T. Go itself provides some pre-defined type constraints, but self-defined types can be used as well.

type Node[T any] struct {
  value T
}

The any type constraint allows T to be literally any type. In case node values need to be compared at some point, there’s a comparable type constraint. Types that fulfill this pre-defined constraint can be compared using ==.

type Node[T comparable] struct {
  value T
}

Any type can be used as a type constraint. Go 1.18 introduced a new interface syntax, where other data types can be embedded:

type Numeric interface {
  int | float32 | float64
}

This means an interface may not only define a set of methods, but also a set of types. Using the Numeric interface as a type constraint implies that value can either be an integer or a float:

type Node[T Numeric] struct {
  value T
}

Regaining Type Safety

The huge advantage of generic type parameters, as opposed to using something like interface{}, is that the eventual type of T will be known at compile time. Defining a type constraint for T entirely removes the need for runtime checks. In case the type used as T doesn’t satisfy the type constraint, the code simply won’t compile.

When writing generic code, you can act as if you already knew the final type of T:

func (n Node[T]) Value() T {
  return n.value
}

The function above returns n.Value, which is of type T. Therefore, the return value is T – and if T is an integer, the return type is known to be int. The return value can thus directly be used as an integer, without any type assertions.

n := Node[int]{
  value: 5,
}

double := n.Value() * 2

Regaining type safety at compile time makes Go code more reliable and less error-prone.

Use Cases for Generics

Typical use cases for generics are listed in When To Use Generics by Ian Lance Taylor. It comes down to three main scenarios:

  • Working with built-in container types such as slices, maps, and channels.
  • Implementing general-purpose data structures such as linked lists or tree structures.
  • Writing a function whose implementation looks the same for many types, such as a sorting function.

In general, consider using generics when you don’t want to make assumptions about the contents of the values you’re operating upon. The Node from our example doesn’t care too much about the value it holds.

A scenario where generics wouldn’t be an appropriate choice is when there are different implementations for different types. Also, don’t change function signatures with interfaces like Read(r io.Reader) to generic signatures like Read[T io.Reader](r T).

Implications on the Standard Library

It was a deliberate decision to not change the standard library as part of Go 1.18. The current plan is to gather experience with generics, learn how to use them appropriately, and figure out reasonable use cases within the standard library.

There are some proposals for generic packages, functions, and data structures:

  • constraints, providing type constraints (#47319)
  • maps, providing generic map functions (#47330)
  • slices, providing generic slice functions (#47203)
  • sort.SliceOf, a generic sort implementation (#47619)
  • sync.PoolOf and other generic concurrent data structures (#47657)
Thanks for reading!
Follow me on GitHub