Golang Generics: First Impressions

Golang Generics: First Impressions

Go 1.18 was received with delight by many Gophers. Among the improvements it brought was generics support, a decade after many had grown weary of waiting. That and fuzzing have been the best parts of Go 1.18 for me. Here is my experience with generics so far.

Generics: the TL;DR

Go’s generics design is best understood by reading the type parameters proposal it grew out of. Here I’ll outline a sketch as is relevant for this article.

Go 1.18 introduced type parameters on structs and functions. This allows a struct to be generic over its content, and a function to be generic over its parameters. The syntax is as follows.

Here we define a pair type that can hold two values of possibly different types.

type pair[a, b any] struct {
    first a
    second b
}

And here we define a function that removes duplicates from a slice.

func unique[T comparable]([]T) []T {
    // todo
}

Notice that in each case we specify what we can do with a type parameter by stating what interface or constraint it must conform to. Go 1.18 also introduces constraints, a way to require values that fall into a range of types. We can declare the integers in Go as follows.

type signed interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

type unsigned interface {
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

type integer interface {
    signed | unsigned
}

This syntax of types separated by "|" is called a type list; the ~ before a type t says that the constraint matches any type whose underlying type is t.

And we can require that a function argument be any integer by constraining its arguments with integer.

func accepts_integers[T integer](value T) {}

A motivating use case: iterators

Iterators are among the features I've always wanted in Go, so I set out to prototype what an iterator library could look like. My scratch pad is here on GitHub, take a look. Iterators generalise patterns of synchronous iteration across various data structures. Lists, maps, generators, and any structure that yields a sequence of values. We could write generic functions for various containers in terms of iterators rather than write the same function for each container.

What would an iterator interface in Go look like? I don't know, I just borrowed from JavaScript. In JavaScript we have the concept of an iterator—an object with a next() method that returns an object of the following type

interface iterator_result<T> {
    done: boolean;
    value: T;
}

So I defined Iterator[T] as

type iterator[T any] interface {
    Next() (value T, done bool)
}

I could return a pair instead, but let's be "idiomatic". Now the rest of the work is in defining the expected behaviour of Iterator[T].Next() and implementing the interface for each container. That, good fellow, is on the GitHub repository. I'll reference parts of it while explaining the limitations I encountered in this endeavour.

Evaluation

Go 1.18 added generics with a number of limitations. Coming from TypeScript, these limitations were really…limiting, albeit reasonable on reading the proposal. The Go team did a remarkable job in retrofitting generics onto Go without changing the language too much. Not that I like how shy they are of change, but that's fine. While trying out generics I found the limitations on variance, returning constraints, and type parameters on methods, in that order of severity, …limiting.

Limitations on variance

What does this mean? Go doesn't define variance

That's the point. It appears that common variance rules we are used to in other programming languages don't apply here. It feels different, it makes me second-guess my thoughts. Ignoring variance has one consequence that I ran into: functions/methods are not covariant in their return types, which breaks implicit interface satisfaction. I'll illustrate with an example.

Consider this code shared on The Go Playground. We define two interfaces, Cloneable[T] and Iterator[T], and a third interface, CloneableIterator[T] that embeds Iterator[T] and Cloneable[Iterator[T]]. That is, cloning a cloneable iterator returns an iterator. We then define *forwardIterator[T] as an iterator over slices. Note that *forwardIterator[T] satisfies the Iterator[T] interface by its Next() method, and it satisfies CloneableIterator[T] by return *forwardIterator[T] in its Clone() method. However, when we try to use *forwardIterator[int] as a CloneableIterator[int], it does not type-check. Here's the error message:

./prog.go:50:39: cannot use Slice[int]{…}.Iter() (value of type *forwardIterator[int]) as type CloneableIterator[int] in variable declaration: *forwardIterator[int] does not implement CloneableIterator[int] (wrong type for Clone method) have Clone() *forwardIterator[int] want Clone() Iterator[int]

Go build failed.

If we change the return type of forwardIterator[T].Clone() from *forwardIterator[T] to Iterator[T], it type-checks.

This isn't a fault with the generics implementation, the same limitation applies to non-generic interfaces, as you can verify by running this modified snippet on the Go Playground. For comparison, a straightforward translation of this code to TypeScript type-checks because functions are (as they should be) covariant in their return types. What this means is that a function that promises to return a type can return any subtype of that type.

This unfortunate detail limits what can be expressed in Go. It limits the implicit interface satisfaction promise: any type that wants to conform to an interface, where the target interface mentions another interface in a method's return position, must also mention the interface in its corresponding method's return position.

Limitations on constraints

Another limitation I encountered quickly is the rule that a constraint can only be used in the type parameters list of a function. If you try to define an Option[T] as one of two structs as follows, you won't be able to write a function that returns an Option[T].

type Option[T any] interface {
    Some[T] | None
}

type None struct {}

type Some[T any] {
    Value T
}

func returns_an_option[T any]() Option[T] {} // you can't do this

This is a limitation that was already mentioned in the type parameters proposal, so I should know. I only wish it wasn't there. Generics would be much more useful without it.

Type parameters restriction on methods

Finally we cannot define new type parameters on methods of type. This too is already documented in the proposal, and it's not as limiting as the previous two. Its only consequence, AFAIK, is that you have to define functions on your parameterised type as free functions. This means you cannot have two Map functions (one for slices and another for maps) in the same package, so you now need two packages. You can get around it with some stutter, as in MapSlice and MapMap 😬

Summary

Although I've presented only limitations here, I do not mean to suggest that Go's generics implementation isn't useful. I already use it sparingly at work. But I wish I could wish these restrictions away, they get in the way of common use cases I often try to reach for. Overall, the Go team did a good job on this one, especially in merging the concepts proposal into this without much (if any) loss of expressive power.

Credits

  1. Thanks to Maria Letta from whose free-gophers-pack I got the hero image.