visit
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.
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
}))
}
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
}
After running the program in debug mode we can see that the first call of the Map function contains the list
with []int
.
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
./main.go:24:16: cannot use capitalizedWords (variable of type []string) as type []int in assignment
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
}
./main.go:9:29: cannot use generic function Map without instantiation
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 |