Generic Programming in Go has always been awkward compared to other compiled languages. The most popular ways to implement it in Go are using interfaces, typecasting, and code-generation. But every method has its significant limitations. For instance, using interfaces requires implementing an interface for each data type. Typecasting leads to potential runtime errors. And in the case of code generation, we have to write generators, which takes a lot of time.
The article's primary goal is to understand how Generics work in Go and compare their performance with previous Generic Programming methods in Go. I will implement a popular function, "Map," that iterates over an array of data and transforms each element using a callback function.
The article code examples are stored in the Github repository.
Generic functions were added in Go in the 1.18 release.
Generic functions refer to a mechanism for compile-time polymorphism, specifically parametric polymorphism. These are functions defined with Type Parameters, intended to be resolved with compile-time type information. The compiler uses these types to instantiate suitable versions, resolving any function overloading appropriately.
Generic functions in Go allow:
In practice, it gives the following features:
Let’s see how Generic Functions work in Go. And for the real-world example, we will use the Map function. The function takes a slice and a callback that modifies every item and returns a new slice.
The Map function implementation for integer values looks like this:
// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
if list == nil {
return nil
}
if modify == nil {
return list
}
mapped := make([]T, len(list))
for i, item := range list {
mapped[i] = modify(item)
}
return mapped
}
There are two checks on nil
values for a list and a callback function for safety. After that, there is a new slice with the same length as a list. Then, the function iterates through a list, modifies each item, and writes the item in a new slice mapped
. An example of how the function works:
package main
func main() {
// prints 2, 4, 6
fmt.Println(Map([]int{1,2,3}, func(item int) int {
return item * 2
}))
}
However, if there is a need to use the Map function with another type, it needs to implement a new function. This architecture isn't scalable and hard to maintain. But using new Generic Functions, we can create a single function that will work with all types that we need:
// Map modifies every item of list and returns a new modified slice.
func Map[V any](list []V, modify func(item V) V) []V {
if list == nil {
return nil
}
if modify == nil {
return list
}
mapped := make([]V, len(list))
for i, item := range list {
mapped[i] = modify(item)
}
return mapped
}
Type any
is a new alias for interface{}
.
The function implementation looks similar to the integer mapping implementation. The only difference is the function signature. Now there is a type parameter definition [V any]
which means that the function can handle any type, but it should be the same type in a callback function modify func(item V) V) []V
. Let’s see how the Map function works for different types:
package main
import (
"fmt"
"strings"
)
type person struct {
name string
age int
}
func main() {
// prints [2 4 6]
fmt.Println(Map([]int{1, 2, 3}, func(item int) int {
return item * 2
}))
// prints [HELLO WORLD]
fmt.Println(Map([]string{"hello", "world"}, func(item string) string {
return strings.ToUpper(item)
}))
// prints [{Linda 19} {John 23}]
fmt.Println(Map([]person{{name: "linda", age: 18}, {name: "john", age: 22}}, func(p person) person {
p.name = strings.Title(p.name)
p.age += 1
return p
}))
}
This section will research how Generic Functions behave during compilation and program runtime.
For the research, I will use the following Go program:
package main
import (
"fmt"
"runtime"
"strings"
)
type person struct {
name string
age int
}
func main() {
ints := []int{1, 2, 3}
doubledInts := Map(ints, func(item int) int {
return item * 2
})
// prints [2 4 6]
fmt.Println(doubledInts)
words := []string{"hello", "world"}
capitalizedWords := Map(words, func(item string) string {
return strings.ToUpper(item)
})
// prints [HELLO WORLD]
fmt.Println(capitalizedWords)
people := []person{{name: "linda", age: 18}, {name: "john", age: 22}}
modifiedPeople := Map(people, func(p person) person {
p.name = strings.Title(p.name)
p.age += 1
return p
})
// prints [{Linda 19} {John 23}]
fmt.Println(modifiedPeople)
runtime.Breakpoint()
}
// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
if list == nil {
return nil
}
if modify == nil {
return list
}
mapped := make([]T, len(list))
for i, item := range list {
mapped[i] = modify(item)
}
runtime.Breakpoint()
return mapped
}
There are two runtime breakpoints for debug purposes. For convenience, I use JetBrains Goland IDEA to debug the code.
After running the program in debug mode we can see that the first call of the Map function contains the list
with []int
.
However, the following two function calls have different types in runtime:
Therefore, a specific, strictly defined type is passed from a caller function in runtime inside the Map function.
Also, in the main
function all types are defined as well:
We can conclude, that Generics in Go do retain their type information at runtime, and in fact, Go does not know about the generic "template" at runtime - only how it was instantiated.
To make sure that type is retained during compilation, we can try to assign capitalizedWords []string
to doubledInts []int
:
ints := []int{1, 2, 3}
doubledInts := Map(ints, func(item int) int {
return item * 2
})
words := []string{"hello", "world"}
capitalizedWords := Map(words, func(item string) string {
return strings.ToUpper(item)
})
doubledInts = capitalizedWords
Here happens a compilation error:
./main.go:24:16: cannot use capitalizedWords (variable of type []string) as type []int in assignment
Therefore, Go does enforce type safety for generic types at runtime.
Let’s see what happens if we try to inspect a Generic Function in runtime using reflection:
package main
import (
"fmt"
"reflect"
)
func main() {
fmt.Println(reflect.TypeOf(Map))
}
// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
if list == nil {
return nil
}
if modify == nil {
return list
}
mapped := make([]T, len(list))
for i, item := range list {
mapped[i] = modify(item)
}
return mapped
}
Trying to compile the program returns an error:
./main.go:9:29: cannot use generic function Map without instantiation
Therefore, Generics are not helpful in Go until instantiated. There is no way to refer to generic "templates" using reflection, which means it is not possible to instantiate new types using Generics at runtime. It is almost as if generic types do not exist in compiled Golang binaries.
We researched how Generic Functions work during compilation and runtime. The last important question is: how fast do they perform?
There are three different implementations of the Map function:
// Map modifies every item of list and returns a new modified slice.
func Map[T any](list []T, modify func(item T) T) []T {
if list == nil {
return nil
}
if modify == nil {
return list
}
mapped := make([]T, len(list))
for i, item := range list {
mapped[i] = modify(item)
}
return mapped
}
// MapTyped modifies every item of list and returns a new modified slice. It works only with Integer values.
func MapTyped(list []int, modify func(item int) int) []int {
if list == nil {
return nil
}
if modify == nil {
return list
}
mapped := make([]int, len(list))
for i, item := range list {
mapped[i] = modify(item)
}
return mapped
}
// MapAny modifies every item of list and returns a new modified slice. It works with Any type, so you should cast types by yourself.
func MapAny(list []any, modify func(item any) any) []any {
if list == nil {
return nil
}
if modify == nil {
return list
}
mapped := make([]any, len(list))
for i, item := range list {
mapped[i] = modify(item)
}
return mapped
}
For benchmark, we use a list of integers []int{1,2,3}
and a callback function that doubles each integer value:
func BenchmarkGenericMap(b *testing.B) {
for i := 0; i < b.N; i++ {
Map([]int{1, 2, 3}, func(item int) int {
return item * 2
})
}
}
func BenchmarkTypedMap(b *testing.B) {
for i := 0; i < b.N; i++ {
MapTyped([]int{1, 2, 3}, func(item int) int {
return item * 2
})
}
}
func BenchmarkAnyMap(b *testing.B) {
for i := 0; i < b.N; i++ {
MapAny([]any{1, 2, 3}, func(item any) any {
return item.(int) * 2
})
}
}
After calling the go test -bench=. -benchmem -v ./...
command, we have the benchmark results that are described in the table below:
Map function type |
Operations count |
ns/op |
bytes/op |
allocs/op |
---|---|---|---|---|
Generic |
42033705 |
28.90 |
24 |
1 |
Typed |
41317022 |
29.16 |
24 |
1 |
Any (using type casting) |
17563975 |
68.61 |
48 |
1 |
The benchmark takeaways:
We researched what the Generic Functions are and how they work in Go. Also, we compared the Generic Functions' performance with previous Generic Programming methods. Finally, there are the main conclusions: