Photo by Jungwoo Hong / Unsplash

Understanding Generics in Go Programming Language

Programming Jan 13, 2022

The majority of programming languages have the concept of generic functions — code that can easily and efficiently accept one of a range of types without needing any specialization for each one – as long as those types all implement certain types of behaviors.

Generics are a massive time-saver. If we have a generic function for returning the sum of a collection of objects, you won't need to write a implementation for each type of object. This is as long as any of the types in question supports adding.

[ Read more! Continue to read more articles like this one: Getting started with Embedded C ]

When the Go programming language was first introduced to the public, it didn't already have the concept of generics – such as C++, Java, C#, Rust, and many other languages do. The closest thing Go Lang had to offer for generics was the concept of the interface. This allows different types to be treated the same as long as they support a certain set of behaviors.

At the same time, interfaces aren’t quite the same as true generics. They required a massive amount of checking at the runtime to operate in the same way as a generic function – they weren't made generic at compile time. And so pressure rose for the Go Lang Developers 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 the release of Go Lang 1.18, generics are now officially a part of the Go language, implemented by way of using interfaces to define groups of types. Not only do the Go Lang programmers have relatively little new syntax or behavior to learn, but the way generics work in Go is also  backward compatible. Older code without generics will still compile and work as intended.

The best 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 simple program (not a good one at all, but you should at least understand what is going on) 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 easy to understand. We’re duplicating a large amount of work across three functions. This means we have a way higher chance of making a mistake somewhere. What’s annoying is that 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.

In the new release, 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 big 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 the case of this code, it's int8, int64, float64.

The second big thing to note is the change to how 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 use the sumNumbers with a slice of int64s, then N in the context of this function is int64; if we use the function with a slice of float64s, then N is float64, and so on.

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 throw an error. However, some Go operations are supported by all types.

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 having to call three different functions, where each one specialized for a different type, we are just calling one function that is automatically specialized by the compiler for each permitted type.

This approach has several good advantages. The biggest is that there is just way less code to have to write and handle — 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.

Tags

Traven West

Traven loves to discuss web applications, security, software, and how to keep web servers up and running plus secured. He codes in PHP and Java in his free time.