Get started with generics in Go

Newly introduced in Go 1.18, generics reduce the amount of boilerplate you need in your code, without sacrificing backwards compatibility

Get started with generics in Go
Thinkstock

Many programming languages have the concept of generic functions — code that can elegantly accept one of a range of types without needing to be specialized for each one, as long as those types all implement certain behaviors.

Generics are big time-savers. If you have a generic function for, say, returning the sum of a collection of objects, you don’t need to write a different implementation for each type of object, as long as any of the types in question supports adding.

When the Go language was first introduced, it did not have the concept of generics, as C++, Java, C#, Rust, and many other languages do. The closest thing Go had to generics was the concept of the interface, which allows different types to be treated the same as long as they support a certain set of behaviors.

Still, interfaces aren’t quite the same as true generics. They require a good deal of checking at runtime to operate in the same way as a generic function, as opposed to being made generic at compile time. And so pressure rose for the Go language to add generics in a manner similar to other languages, where the compiler automatically creates the code needed to handle different types in a generic function.

With Go 1.18, generics are now a part of the Go language, implemented by way of using interfaces to define groups of types. Not only do Go programmers have relatively little new syntax or behavior to learn, but the way generics work in Go is backward compatible. Older code without generics will still compile and work as intended.

Go generics in brief

A good way to understand the advantages of generics, and how to use them, is to start with a contrasting example. We’ll use one adapted from the Go documentation’s tutorial for getting started with generics.

Here is a program (not a good one, but you should get the idea) that sums three types of slices: a slice of int8s (bytes), a slice of int64s, and a slice of float64s. To do this the old, non-generic way, we have to write separate functions for each type:

package main

import ("fmt")

func sumNumbersInt8 (s []int8) int8 {
    var total int8
    for _, i := range s {
        total +=i
    }
    return total
}

func sumNumbersFloat64 (s []float64) float64 {
    var total float64
    for _, f := range s {
        total +=f
    }
    return total
}

func sumNumbersInt64 (s []int64) int64 {
    var total int64
    for _, i := range s {
        total += i
    }
    return total
}

func main() {
    ints := []int64{32, 64, 96, 128}    
    floats := []float64{32.0, 64.0, 96.1, 128.2}
    bytes := []int8{8, 16, 24, 32}  

    fmt.Println(sumNumbersInt64(ints))
    fmt.Println(sumNumbersFloat64(floats))    
    fmt.Println(sumNumbersInt8(bytes))
}

The problem with this approach is pretty clear. We’re duplicating a large amount of work across three functions, meaning we have a higher chance of making a mistake. What’s annoying is that the body of each of these functions is essentially the same. It’s only the input and output types that vary.

Because Go lacks the concept of a macro, commonly found in other languages, there is no way to elegantly re-use the same code short of copying and pasting. And Go’s other mechanisms, like interfaces and reflection, only make it possible to emulate generic behaviors with a lot of runtime checking.

Parameterized types for Go generics

In Go 1.18, the new generic syntax allows us to indicate what types a function can accept, and how items of those types are to be passed through the function. One general way to describe the types we want our function to accept is with the interface type. Here’s an example, based on our earlier code:

type Number interface {
    int8 | int64 | float64
}

func sumNumbers[N Number](s []N) N {
    var total N
    for _, num := range s {
        total += num
    }
    return total
}

The first thing to note is the interface declaration named Number. This holds the types we want to be able to pass to the function in question — in this case, int8, int64, float64.

The second thing to note is the slight change to the way our generic function is declared. Right after the function name, in square brackets, we describe the names used to indicate the types passed to the function — the type parameters. This declaration includes one or more name pairs:

  • The name we’ll use to refer to whatever type is passed along at any given time.
  • The name of the interface we will use for types accepted by the function under that name.

Here, we use N to refer to any of the types in Number. If we invoke sumNumbers with a slice of int64s, then N in the context of this function is int64; if we invoke the function with a slice of float64s, then N is float64, and so on.

Note that the operation we perform on N (in this case, +) needs to be one that all values of Number will support. If that’s not the case, the compiler will squawk. However, some Go operations are supported by all types.

We can also use the syntax shown within the interface to pass a list of types directly. For instance, we could use this:

func sumNumbers[N int8 | int64 | float64](s []N) N {
    var total N
    for _, num := range s {
        total += num
    }
    return total
}

However, if we would like to avoid constantly repeating int8 | int64 | float64 throughout our code, we could just define them as an interface and save ourselves a lot of typing.

Complete generic function example in Go

Here is what the entire program looks like with one generic function instead of three type-specialized ones:

package main

import ("fmt")

type Number interface {
    int8 | int64 | float64
}

func sumNumbers[N Number](s []N) N {
    var total N
    for _, num := range s {
        total += num
    }
    return total
}

func main() {
    ints := []int64{32, 64, 96, 128}    
    floats := []float64{32.0, 64.0, 96.1, 128.2}
    bytes := []int8{8, 16, 24, 32}  

    fmt.Println(sumNumbers(ints))
    fmt.Println(sumNumbers(floats))    
    fmt.Println(sumNumbers(bytes))
}

Instead of calling three different functions, each one specialized for a different type, we call one function that is automatically specialized by the compiler for each permitted type.

This approach has several advantages. The biggest is that there is just less code — it’s easier to make sense of what the program is doing, and easier to maintain it. Plus, this new functionality doesn’t come at the expense of existing code. Go programs that use the older one-function-for-a-type style will still work fine.

The any type constraint in Go

Another addition to the type syntax in Go 1.18 is the keyword any. It’s essentially an alias for interface{}, a less syntactically noisy way of specifying that any type can be used in the position in question. Note that any can be used in place of interface{} only in a type definition, though. You can’t use any anywhere else.

Here’s an example of using any, adapted from an example in the proposal document for Go generics:

func Print[T any] (s []T) {
	for _, v := range s {
		fmt.Println(v)
	}
}

This function takes in a slice where the elements are of any type, and formats and writes each one to standard output. Passing slices that contain any type to this Print function should work, provided the elements within are printable (and in Go, most every object has a printable representation).

Generic type definitions in Go

Another way generics can be used is to employ them in type parameters, as a way to create generic type definitions. An example:

type CustomSlice[T Number] []T

This would create a slice type whose members could be taken only from the Number interface. If we employed this in the above example:

type Number interface {
    int8 | int64 | float64
}

type CustomSlice[T Number] []T

func Print[N Number, T CustomSlice[N]] (s T) {
	for _, v := range s {
		fmt.Println(v)
	}
}

func main(){
    sl := CustomSlice[int64]{32, 32, 32}
    Print(sl)
}

The result is a Print function that will take slices of any Number type, but nothing else.

Note how we use CustomSlice here. Whenever we make use of CustomSlice, we have to instantiate it — we need to specify, in brackets, what type is used inside the slice. When we create the slice sl in main(), we specify that it is int64. But when we use CustomSlice in our type definition for Print, we must instantiate it in a way that can be used in a generic function definition. 

If we just said T CustomSlice[Number], the compiler would complain about the interface containing type constraints, which is too specific for a generic operation. We have to say T CustomSlice[N] to reflect that CustomSlice is meant to use a generic type.

Copyright © 2022 IDG Communications, Inc.