Upcoming Go's generics

2021-02-26
Go Golang generics

In the very early adoption of Go by the open source community, lot of developers already suggested the lack of generics. However, some other claimed that simplicity of Go shouldn’t be compromised by generics. Few days ago, the never ending discussions pro and contra generics are finally over. The proposal is accepted and we can expect generics in Go 1.18 somewhere in spring of 2022. You may wonder why does it take that long, but this is not just about the compiler but also some parts of the standard library may need to be rewritten in order to replace the old code which could benefit from generics.

What will generics bring to us? In my opinion, lot of good features and much better abstraction. I think that generics are a must for static typed languages. Simplicity is nice, but is Go really simple without generics? Let’s check this on an example of maps. If you want to implement kind of generic, string keyed map, you would probably write something like this:

 1func NewMap() *Map {
 2	return &Map{
 3		internal: make(map[string]interface{}),
 4	}
 5}
 6
 7func (m *Map) Get(key string) (interface{}, bool) {
 8	m.RLock()
 9	result, ok := m.internal[key]
10	m.RUnlock()
11	return result, ok
12}
13
14func (m *Map) Set(key string, value interface{}) {
15	m.Lock()
16	m.internal[key] = value
17	m.Unlock()
18}
19
20func (m *Map) Delete(key string) {
21	m.Lock()
22	delete(m.internal, key)
23	m.Unlock()
24}
25
26func (m *Map) Keys() []string {
27	m.Lock()
28	ks := make([]string, 0, len(m.internal))
29	for k, _ := range m.internal {
30		ks = append(ks, k)
31	}
32	m.Unlock()
33	return ks
34}

By using empty interfaces we can use this map with basically any value type. But, after retrieving a value, we always have to do type assertion and this makes code less readable. With this implementation our map could also contain values of different types which makes things even more complicated and less strict. Let’s look at the example usage:

 1func main() {
 2	m := NewMap()
 3
 4	m.Set("hello", "world")
 5	m.Set("number", 1)
 6	m.Set("url", &url.URL{})
 7
 8	for _, k := range m.Keys() {
 9		v, _ := m.Get(k)
10
11		switch v := v.(type) {
12		case string:
13			fmt.Printf("string %s\n", v)
14		case int:
15			fmt.Printf("int %d\n", v)
16		default:
17			fmt.Printf("%T %v\n", v, v)
18		}
19	}
20}

Do we really want our map to be able to store any type? Is this really type strict? Is it simple to do type assertions every now and then? In such cases very often we have to introduce reflection and this is far from simple. Now let’s take a look at the generic implementation:

 1type Map[T any] struct {
 2	sync.RWMutex
 3	internal map[string]T
 4}
 5
 6func NewMap[T any]() *Map[T] {
 7	return &Map[T]{
 8		internal: make(map[string]T),
 9	}
10}
11
12func (m *Map[T]) Get(key string) (T, bool) {
13	m.RLock()
14	result, ok := m.internal[key]
15	m.RUnlock()
16	return result, ok
17}
18
19func (m *Map[T]) Set(key string, value T) {
20	m.Lock()
21	m.internal[key] = value
22	m.Unlock()
23}
24
25func (m *Map[T]) Delete(key string) {
26	m.Lock()
27	delete(m.internal, key)
28	m.Unlock()
29}
30
31func (m *Map[T]) Keys() []string {
32	m.Lock()
33	ks := make([]string, 0, len(m.internal))
34	for k, _ := range m.internal {
35		ks = append(ks, k)
36	}
37	m.Unlock()
38	return ks
39}

We can now use strictly typed map with string values:

 1func main() {
 2	m := NewMap[string]()
 3
 4	m.Set("hello", "world")
 5	m.Set("welcome", "generics")
 6
 7	for _, k := range m.Keys() {
 8		v, _ := m.Get(k)
 9		fmt.Printf("%s %s\n", k, v)
10	}
11}

This is quite clear, no type assertions and we can use the same implementation for some other value type, let’s say int:

 1func main() {
 2	m := NewMap[int]()
 3
 4	m.Set("one", 1)
 5	m.Set("two", 2)
 6
 7	for _, k := range m.Keys() {
 8		v, _ := m.Get(k)
 9		fmt.Printf("%s %d\n", k, v)
10	}
11}

For comparison reasons, let’s see how this last example can be written in Rust:

 1use std::collections::HashMap;
 2
 3fn main() {
 4    let mut map = HashMap::new();
 5    map.insert("one".to_string(), 1);
 6    map.insert("two".to_string(), 2);
 7
 8    for (k, v) in map {
 9        println!("{} {}", k, v)
10    }
11}

Like for all other types, Rust already has generic version of map, actually HashMap. There are no generic parameters in the example above because Rust can infer type from the implementation. In this case, it can figure out that it has to use HashMap<String, i32>. We will see how will type inference work in Go.

But let’s go back to Go. I believe Go’s standard library will also benefit a lot from generics. We may expect generic, strictly typed implementation of sync.Map, generic functions like Map, Reduce, Filter and many other goodies. Basically, for each type that uses empty interfaces we may expect a generic version.

Let’s see how generic implementation of Filter function may look like:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6	cities := []string{"Amsterdam", "Berlin", ""}
 7
 8	filterEmpty := func(s string) bool {
 9		if s == "" {
10			return false
11		}
12
13		return true
14	}
15
16	fmt.Println(Filter[string](cities, filterEmpty))
17}
18
19func Filter[T any](s []T, f func(T) bool) []T {
20	var r []T
21
22	for _, v := range s {
23		if f(v) {
24			r = append(r, v)
25		}
26	}
27
28	return r
29}

Are generics really threatening Go’s simplicity? I would say no, on the contrary. I am looking forward to version 1.18 and you?

You can run and play with all the examples on the following links:

Map without generics

Map with generics (string type)

Map with generics (int type)

Rust version

Filter function

Running stack of microservices using docker-compose and acim/go-reflex image

How to easily run multiple Go microservices locally using just one image
Go microservice docker-compose docker

Go vanity imports

Release your packages under custom domain using vanity imports
Go Golang

A first impression of Rust from the perspective of a Go developer

Rust is very powerful, but let's see how it compares to Go
Go Rust Kubernetes controller secret replicator