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.
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.
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
}
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.
Typical use cases for generics are listed in When To Use Generics by Ian Lance Taylor. It comes down to three main scenarios:
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)
.
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: