Getting Starting With Go Generics - Tutorial
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:
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:
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:
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: