Getting Starting With Go Generics - Tutorial

Elliot Forbes Elliot Forbes ⏰ 9 Minutes 📅 Jan 26, 2022

Generics.

The addition of generics to the Go language has to be one of the most controversial topics for the Go community.

Since the start, I’ve enjoyed the explicitness of Go and the simplicity that provides for me as a developer. I know, looking at a function signature, exactly what type I’ll be expecting to work with in the body of that function, and I’ll generally know what to watch out for.

With generics being added, our codebases get a little more complex. We no longer have that simplistic explicitness and we have to do a little inference and digging in order to truly know what is getting passed into our new functions.

Overview

Now, the goal of this article isn’t to argue over the finer points of the newest addition to the language, it’s instead going to attempt to provide you with everything you need in order to get up and running with generics in your own Go applications.

Getting Started

Before we can begin, you’ll need to install go1.18beta1 on your local machine. If you already have go installed, you can achieve this by running:

$ go install golang.org/dl/go1.18beta1@latest
$ go1.18beta1 download

After you have successfully run these two commands, you should be able to run go1.18beta1 within your terminal:

$ go1.18beta1 
go version go1.18beta1 darwin/amd64

Perfect, you are now able to compile and run generic Go code!

Writing Generic Functions

Let’s start off by looking at how we can build our own generic functions in Go. Traditionally you would start with a function signature that would explicitly spell out the types that this function expects as parameters:

func oldNonGenericFunc(myAge int64) {
    fmt.Println(myAge)
}

In the new world, if we wanted to create a function that would take in say int64 or float64 types, we can modify our function signature like so:

main.go
package main

import "fmt"

func newGenericFunc[age int64 | float64](myAge age) {
	fmt.Println(myAge)
}

func main() {
	fmt.Println("Go Generics Tutorial")
	var testAge int64 = 23
	var testAge2 float64 = 24.5

	newGenericFunc(testAge)
	newGenericFunc(testAge2)
}

Let’s attempt to run this now and see what happens:

$ go1.18beta1 run main.go
Go Generics Tutorial
23
24.5

So, let’s break down what we’ve done here. We’ve effectively created a generic function called newGenericFunc.

After the name of our func we’ve then opened square brackets [] and specified the types that we can reasonably expect our function to be called with:

[age int64 | float64]

When we’ve defined our parameters for the function in our parenthesis, we have then said that the variable myAge could be of type age which subsequently could be either int64 or float64 type.

Note - If we wanted to add more types, we can list our more types using the | separator between the different types.

Using any type

In the above example, we’ve specified a generic function that could take a number of type int64 or float64, however, what if we want to define a function that can take literally any type?

Well, in order to achieve this, we can use the new built-in any type like so:

any.go
package main

import "fmt"

func newGenericFunc[age any](myAge age) {
	fmt.Println(myAge)
}

func main() {
	fmt.Println("Go Generics Tutorial")
	var testAge int64 = 23
	var testAge2 float64 = 24.5

    var testString string = "Elliot"

	newGenericFunc(testAge)
	newGenericFunc(testAge2)
	newGenericFunc(testString)
}

With these changes in place, let’s attempt to run this now:

$ go1.18beta1 run any.go 
Go Generics Tutorial
23
24.5
Elliot

As you can see, the go compiler successfully compiles and runs our new code. We’ve been able to pass in any type we please without any compiler errors.

Now, you may notice an issue with this code. In the above sample, we’ve created a new generic function that will take in an Age value and print it out. We’ve then been a little cheeky and passed in a string value to this generic function and, luckily this time, our function has processed this input without any issues.

Let’s take a look at a case where this could throw an issue though. Let’s update our code to do some additional computation on the myAge parameter. We’ll attempt to cast it to int and then add 1 to the value:

generic_issue.go
package main

import "fmt"

func newGenericFunc[age any](myAge age) {
	val := int(myAge) + 1
	fmt.Println(val)
}

func main() {
	fmt.Println("Go Generics Tutorial")
	var testAge int64 = 23
	var testAge2 float64 = 24.5

    var testString string = "Elliot"

	newGenericFunc(testAge)
	newGenericFunc(testAge2)
    newGenericFunc(testString)
}

Now, when we go to try and build or run this code, we should see that it fails to compile:

$ go1.18beta1 run generic_issue.go
# command-line-arguments
./generic_issue.go:6:13: cannot convert myAge (variable of type age constrained by any) to type int

In this instance, we are prevented from attempting to cast a type of any to int. The only way around this is to be more explicit about the types being passed in like so:

package main

import "fmt"

func newGenericFunc[age int64 | float64](myAge age) {
	val := int(myAge) + 1
	fmt.Println(val)
}

func main() {
	fmt.Println("Go Generics Tutorial")
	var testAge int64 = 23
	var testAge2 float64 = 24.5

	newGenericFunc(testAge)
	newGenericFunc(testAge2)
}

Being more explicit in the types that we can reasonably work with is advantageous in most scenarios as it allows you to be most deliberate and thoughtful as to how you handle each individual type.

$ go1.18beta1 run generic_issue.go
Go Generics Tutorial
24
25

Explicitly Passing Type Arguments

For most cases, Go will be able to infer the type of the parameter that you are passing into your generic functions. However, in some scenarios, you may wish to be more deliberate and specify the type of the parameter that you are passing to these generic functions.

In order to be more explicit, we can state the type of the parameter being passed using the same [] bracket syntax:

newGenericFunc[int64](testAge)

This will explicitly state that the testAge variable will be of type int64 when passed into this newGenericFunc.

Type Constraints

Let’s look at how we can modify our code and declare a type constraint in Go.

In this example, we’ll move the types that our generic function can accept into an interface that we’ve labelled Age. We’ve then used this new type constraint in our newGenericFunc like so:

package main

import "fmt"

type Age interface {
	int64 | int32 | float32 | float64 
}

func newGenericFunc[age Age](myAge age) {
	val := int(myAge) + 1
	fmt.Println(val)
}

func main() {
	fmt.Println("Go Generics Tutorial")
	var testAge int64 = 23
	var testAge2 float64 = 24.5

	newGenericFunc(testAge)
	newGenericFunc(testAge2)
}

When we go to run this, we should see:

$ go1.18beta1 run type_constraints.go
Go Generics Tutorial
24
25

Now, the beauty of this approach is that we can then reuse these same type constraints throughout our code, just as we would any other type within Go.

More Complex Type Constraints

Let’s have a look at a slightly more complex use-case. Let’s for example imagine we wanted to create a getSalary function that would take in anything that satisfied a given type constraint. We could achieve this by defining an interface and then using this as a type constraint for our generic function:

package main

import "fmt"

type Employee interface {
	PrintSalary() 
}

func getSalary[E Employee](e E) {
	e.PrintSalary()
}

type Engineer struct {
	Salary int32
}

func (e Engineer) PrintSalary() {
	fmt.Println(e.Salary)
}

type Manager struct {
	Salary int64
}

func (m Manager) PrintSalary() {
	fmt.Println(m.Salary)
}


func main() {
	fmt.Println("Go Generics Tutorial")
	engineer := Engineer{Salary: 10}
	manager := Manager{Salary: 100}

	getSalary(engineer)
	getSalary(manager)
}

In this instance, we’ve then specified that our getSalary function has a type constraint of E which must implement our Employee interface. Within our main func we have then defined an engineer and a manager and passed both of these different structs into the getSalary func even though they are both different types.

$ go1.18beta1 run complex_type_constraints.go
Go Generics Tutorial
10
100

Now this is an interesting example, it shows how we can type constraint a generic function to only accept types that implement this PrintSalary interface, however, the same could be achieved by using the interface directly in the function signature like so:

func getSalary(e Employee) {
	e.PrintSalary()
}

This is similar due to the fact that using interfaces in Go is a type of generic programming, understanding the differences between the approaches and the benefits of one over the other is something that is probably better explained in the official go.dev post titled Why Generics.

The Benefits of Generics

So far, we’ve just covered the basic syntax that you’ll encounter when writing generic code in Go. Let’s take this newfound knowledge a step further and look at where this code can be beneficial within our own Go applications.

Let’s take a look at a standard BubbleSort implementation:

func BubbleSort(input []int) []int {
    n := len(input)
    swapped := true
    for swapped {
        // set swapped to false
        swapped = false
        // iterate through all of the elements in our list
        for i := 0; i < n-1; i++ {
            // if the current element is greater than the next
            // element, swap them
            if input[i] > input[i+1] {
                // log that we are swapping values for posterity
                fmt.Println("Swapping")
                // swap values using Go's tuple assignment
                input[i], input[i+1] = input[i+1], input[i]
                // set swapped to true - this is important
                // if the loop ends and swapped is still equal
                // to false, our algorithm will assume the list is
                // fully sorted.
                swapped = true
            }
        }
    }
}

Now, in the above implementation, we’ve defined that this BubbleSort function must take in a type int slice. If we tried to run this with a slice of type int32 for example, we’d get a compiler error:

cannot use list (variable of type []int32) as type []int in argument to BubbleSort

Let’s take a look at how we could write this using generics and open up the input to accept all int types and float types:

package main

import "fmt"

type Number interface {
	int16 | int32 | int64 | float32 | float64 
}

func BubbleSort[N Number](input []N) []N {
	n := len(input)
	swapped := true
	for swapped {
		swapped = false
		for i := 0; i < n-1; i++ {
		  	if input[i] > input[i+1] {
				input[i], input[i+1] = input[i+1], input[i]
				swapped = true
		  	}
		}
	}
	return input
}

func main() {
	fmt.Println("Go Generics Tutorial")
	list := []int32{4,3,1,5,}
	list2 := []float64{4.3, 5.2, 10.5, 1.2, 3.2,}
	sorted := BubbleSortGeneric(list)
	fmt.Println(sorted)

	sortedFloats := BubbleSortGeneric(list2)
	fmt.Println(sortedFloats)
}

By making these changes to our BubbleSort function and accepting a Type constrained Number, we’ve effectively enabled ourselves to reduce the amount of code we have to write if we wanted to support every int and float type!

Let’s try run this now:

$ go1.18beta1 run bubblesort.go
Go Generics Tutorial
[1 3 4 5]
[1.2 3.2 4.3 5.2 10.5]

Now this single example, should demonstrate just how powerful this new concept is within your Go applications.

Conclusion

Awesome, so in this tutorial, we have covered the basics of Generics in Go! We’ve had a look at how we can define generic functions and do cool things like use type constraints to ensure our generic functions don’t get too wild.

Additional Resources

If you enjoyed this, you may also find these articles on the go.dev site useful: