Using Generics In Go for More Re-useable Code
Ok, so the quick overview of what generics are: Generics are basically another way of reducing code duplication by allowing the creation of functions or structs that can accept a variety of types instead of just one. Before you try using Generics though, you MUST make sure you’re running Go version 1.18 or higher since this is the point they were introduced into the language. Other languages like C++, Java, C#, and even Python have had them for a while.
Generic Functions
So usually, when you declare a function in Go, you would do something like this:
func AddNumbers (numA int, numB int) int {
return numA + numB
}
var result int
result = AddNumbers(3, 4)
Ok, ok, so we’re familiar with this. We’ve got a function that accepts two ints, adds them together, then returns a single int. And the function’s internal logic is just, adding two numbers. BUT… the problem with this function is, it only adds two integers (whole numbers). What if you wanted to add two floats (numbers with decimal points)? Well, you can’t use AddNumbers(). It’s off-limits because it wouldn’t accept anything other than variables of the int type. So what you would have to do is declare an entirely new function that accepts floats instead, meaning you’d now have this:
func AddIntegers (numA int, numB int) int {
return numA + numB
}
func AddFloats (numA float64, numB float64) float {
return numA + numB
}
var result_1 int
result_1 = AddIntegers(3, 4)
var result_2 float
result_2 = AddFloats(3.2, 4.7)
Ok, the problem here is obvious right? The code duplication… ughhh, nasty. We can get more DRY than this, so let’s see how Generics can help us fix this and write better, more maintainable code. To fix this problem, we’ll use what Go calls Type Parameters, or Type Arguments. Type Parameters act kind of like regular parameters except they declare which list of variable types the function (or struct as we’ll see later) is allowed to accept. The syntax to declare them are square brackets instead of parentheses and they go right before where you declare the function’s regular parameters. So let’s update our AddNumbers function so that it has type parameters. In this case, we’re gonna allow any types to be passed in using the “any” type keyword (don’t worry, we’ll fix this soon). *Note, any is just a shorthand for an empty interface interface {}.
func AddNumbers[T any, T any] (numA T, numB T) float {
return float64(numA) + float64(numB) // notice we have to type cast the
// parameters to floats so that even if an int is passed in for one, they will both be floats
}
var result_1 int
result_1 = AddNumbers(3, 4)
var result_2 float
result_2 = AddNumbers(3.2, 4.7)
Now, we’re doing better! We reduced the code duplication by transforming AddNumbers into a generic function that can take any two types of variables instead of just ints… Hmmm maybe this isn’t so great after all, because now we could do something like this:
result := AddNumbers(“red”, “blue”)
But this is obviously terrible because we’re allowing any type to be passed into AddNumbers(). Although this code will compile, your program will panic when you try to add two strings together. So what we need is a way of putting some type of restrictions on what can be passed in to the function. So let’s replace that any with int and float separated by a bar character so that it looks like this:
func AddNumbers[N int | float64, N int | float64] (numA N, numB N) float64 {
return float64(numA) + float64(numB) // again, we have to typecast to make sure we’re adding both of the same types
}
var result_1 int
result_1 = AddNumbers(3, 4)
var result_2 float64
result_2 = AddNumbers(3.2, 4.7)
Now we can call AddNumbers() with any two numbers (ints or floats) and we’ll get a result. No need to duplicate logic.
Parameterized Types
Instead of declaring the types directly inside the function declaration, we can pass in interfaces that have a sort of union type declare on them. This accomplishes the same result but is a bit cleaner if you have more allowed types.
type Number interface {
int16 | int32 | float32 | float64 // now we can add even more types without crowding up the function. Just makes it more readable.
}
func AddNumbers[N Number, N Number] (numA N, numB N) float64 {
return float64(numA) + float64(numB) // again, we have to typecast to make sure we’re adding both of the same types
}
And side note, remember when I said that any is just a shorthand for an empty interface? You can see that more clearly if you re-wrote the above like this:
// since we’re passing in empty interfaces, this function would take ANY types again now
func AddNumbers[N interface{}, N interface{}] (numA N, numB N) float64 {
return float64(numA) + float64(numA) // again, we have to typecast to make sure we’re adding both of the same types
}
But of course, we don’t wanna do that, just using it to illustrate the point.
Generic Types in Structs
type Something struct {
someProperty int
}
Of course, we’re familiar with declaring a struct, like in the example above. But we can use the concept of Generics to declare a struct that allows certain types in its properties.
type Something[N int32 | float64] struct {
SomeProperty N
}
Now, when declaring this struct, we can do this:
s_1 = Something[int32]{SomeProperty: 3}
// or
s_2 = Something[float64]{SomeProperty: 3.7}
Gotchas
So, in all of our examples above, after we declare the functions with the type parameters, we just call the functions with its parameters as we normally would, even if the function wasn’t a generic. What I didn’t mention though is Go is inferring the types based on what we’re passing in. But we can also explicitly call the functions with the type parameters set if we wanted to. Using AddNumbers() as an example, that would look like this:
AddNumbers[int, float](7, 4.2)
Go is usually “smart enough” to figure out what types you’re passing to the function so you can omit the type parameters from the call, but it’s good to keep in mind that you might run into some cases where you need to declare them explicitly.
The Constraints Package
So, one thing you might have noticed in the above examples is that it’s pretty common to have Generics that accept similar types of parameters. For example, in our case, we wanted a function that would take numbers, so we had to explicitly state that we wanted int16, int32, float32, and float64. But what if we need to type these out often? Or what if we want all numeric types without having to type them out at all. Don’t worry, the wonderful people at https://golang.org got you. They’ve put together a Go package called constraints that has a lot of common groups of types you probably will want to use. You can check it out in more detail here: https://pkg.go.dev/golang.org/x/exp/constraints, but the gist of it is that we can do stuff like this with it:
package main
import (
"golang.org/x/exp/constraints" // You have to import the package, it’s not built in
)
func DoSomething[N constraints.Numeric, N constraints.Numeric] (numA N, numB N) {
// do something with the numbers
}
DoSomething(3, 5)
DoSomething(7, 4.2)
DoSomething(2i, 4i)
Because we’re using the ‘Numeric’ constraint, this function will accept two of any type of number which includes all integers, floats, and complex numbers.
This is pretty much the basic info you need to start taking advantage of Generics to make your Go code more flexible. Of course, there’s a lot more you can do with them. Try em out!