The Long Wait for Go Generics Nears Its End

Go's generics proposal has finally been accepted after more than a decade of discussion—the first attempt dates back to 2010, before Go 1.0 even shipped. The accepted design, slated for Go 1.18 (with a beta expected in December 2021), aims to balance expressiveness with readability, covering the vast majority of use cases without enabling the kind of convoluted code that gives generics a bad reputation in other languages.

A recent post explored why writing generic functions on slices in Go has historically been painful. With the proposal now accepted, that pain point largely disappears. Here's a look at what changes.

Why Interfaces Were Not Enough

Consider the simple task of reversing a slice. A concrete implementation for []int looks like this:

func ReverseInts(s []int) {
  first := 0
  last := len(s) - 1
  for first < last {
    s[first], s[last] = s[last], s[first]
    first++
    last--
  }
}

Reversing a slice of strings requires a nearly identical function with only the element type changed:

func ReverseStrings(s []string) {
  first := 0
  last := len(s) - 1
  for first < last {
    s[first], s[last] = s[last], s[first]
    first++
    last--
  }
}

Go's interfaces offer one form of polymorphism, so it is tempting to write a "generic" reverse over []interface{}:

func ReverseAnything(s []interface{}) {
  first := 0
  last := len(s) - 1
  for first < last {
    s[first], s[last] = s[last], s[first]
    first++
    last--
  }
}

This works when called with a slice of empty interfaces:

iints := []interface{}{2, 3, 4, 5}
ReverseAnything(iints)

istrings := []interface{}{"joe", "mike", "hello"}
ReverseAnything(istrings)

But real Go code rarely stores data in []interface{}. Most slices are of concrete types like []int or []string, and those cannot be passed directly to a function expecting []interface{}—for good reasons related to memory layout and type safety. The workaround of copying data into an interface slice and back carries real costs:

  • Far more code to write and maintain.
  • Unnecessary copying and allocations, turning a single tight loop into a much more expensive operation.

Code generation can help, but it brings its own set of maintenance burdens.

Type Parameters in Action

The type parameters proposal solves this cleanly. A generic reverse function looks almost identical to the concrete version:

func ReverseSlice[T any](s []T) {
  first := 0
  last := len(s) - 1
  for first < last {
    s[first], s[last] = s[last], s[first]
    first++
    last--
  }
}

The bracket notation after the function name declares T as a type parameter constrained to any. The function body itself is unchanged from the non-generic version. Thanks to type inference, call sites require no explicit type arguments:

s := []int{2, 4, 8, 11}
ReverseSlice(s)

ss := []string{"joe", "mike", "hello"}
ReverseSlice(ss)

One important detail: values of type parameters are not boxed. This means the overhead of generic functions is a constant factor, not something that grows with slice length—a significant efficiency win compared to interface-based approaches.

Map, Filter, Reduce

Type parameters finally make higher-order functions like map, filter, and reduce practical to write in Go. They are useful demonstrations of the feature, even if opinions differ on whether they fit Go's style.

A generic Map needs two type parameters—one for the source element type and one for the result:

func Map[T, U any](s []T, f func(T) U) []U {
  r := make([]U, len(s))
  for i, v := range s {
    r[i] = f(v)
  }
  return r
}

Type inference handles both parameters from the function argument and return type:

s := []int{2, 4, 8, 11}
ds := Map(s, func(i int) string {return strconv.Itoa(2*i)})

The compiler infers T as int and U as string from the closure, making ds a []string. Existing library functions work just as well:

names := []string{"joe", "mike", "sue"}
namesUpper := Map(names, strings.ToUpper)

Filter follows the same pattern:

func Filter[T any](s []T, f func(T) bool) []T {
  var r []T
  for _, v := range s {
    if f(v) {
      r = append(r, v)
    }
  }
  return r
}
evens := Filter(s, func(i int) bool {return i % 2 == 0})

And so does Reduce:

func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
  r := init
  for _, v := range s {
    r = f(r, v)
  }
  return r
}
product := Reduce(s, 1, func(a, b int) int {return a*b})

Experimenting Before 1.18

Even before the official release, the go2go playground at go2goplay.golang.org provides an accessible way to test generic code snippets. For larger experiments, the development branch has been the place to go: clone the Go repository, check out the dev.go2go branch, build the toolchain, and run code with go tool go2go. The src/cmd/go2go/testdata/go2path/src directory in that branch is full of interesting generic code examples.

Since late October 2021, gotip has offered a simpler path to run generic code. And with the release of Go 1.18 in March 2022, generics are finally part of the stable language—no special tooling required.