Generic types in Go

Generic types in Go

From Know Go

Typing is no substitute for thinking.
“A Manual for BASIC”

This is the third in a four-part series of tutorials on generics in Go, extracted from my book Know Go.

  1. Generics
  2. Type parameters
  3. Generic types
  4. Constraints

Generic functions are great, as we saw in the previous tutorial, but we can do more. We can also write generic types. What does that mean?

Generic types

We often deal with collections of values in Go. For example, consider this slice type:

type SliceOfInt []int

You already know that, just as an int variable can only hold int values, a []int slice can only hold int elements. We wouldn’t want to have to also define SliceOfFloat, SliceOfString, and so on.

Defining a generic slice type

Could we write a generic type definition that takes a type parameter, just like a generic function? For example, could we make a slice of any type?

Yes, we could:

type Bunch[E any] []E

Just as we could define a function such as Len that takes a slice of an arbitrary element type, we’ve now given a name to a new type: a slice of E, for some type E.

And just as with generic functions, a generic type is always instantiated on some specific type when it’s used in a program. That is to say, a Bunch[int] will be a slice of int, a Bunch[string] will be a slice of strings, and so on.

Each of these is a distinct concrete type, as you’d expect, and we can write a Bunch literal by giving the type we want in square brackets:

b := Bunch[int]{1, 2, 3}

The elements all have the same type

It’s very important to understand that, even though a Bunch is defined as a slice of E for any type E, any particular Bunch can’t contain values of different types. All the elements of a Bunch[T] must be of type T, whatever it is.

For example, we can’t create a Bunch of one type, and then try to append a value of a different type:

b := Bunch[int]{1, 2, 3}
b = append(b, "hello")
// cannot use "hello" (untyped string constant) as int value in 
// argument to append

We can have a Bunch[int] or a Bunch[string] or a Bunch of elements of any other specific type. What we can’t have is a Bunch of mixed-type elements.

It’s easy to hear a term like “generic slice” and jump to the conclusion that it means “slice containing values of different types”. But that’s actually not the case.

Generic types need to be instantiated

There are, in a sense, no generic types in Go. I know that sounds crazy, but stick with me.

That is, you can define generic types like Bunch[E], but to actually use them in your program, you need to instantiate them on some specific type, like int.

At that point, what you have is an ordinary Go slice of int, and it stands to reason that such a slice can only contain int elements.

So just because we used a type parameter, it doesn’t mean we can create a single slice that contains elements of different types. What we can create are different slice types, such as []int, or []string, without having to specify their element type in advance.

One way to express this is to say that, while we can define generic types at compile time, there are no generic types at run time.

There’s one partial exception to this, which you’re already familiar with: interface types. We could always create a slice of any in Go, and populate it with elements of different dynamic types. But, for the reasons we’ve discussed, this isn’t the same thing as a truly generic type.

Exercise: Group therapy

Over to you again now, to try your hand at the group exercise. This time, you’ll need to define a generic slice type Group[E] to pass the following test:

func TestGroupContainsWhatIsAppendedToIt(t *testing.T) {
    t.Parallel()
    got := group.Group[string]{}
    got = append(got, "hello")
    got = append(got, "world")
    want := group.Group[string]{"hello", "world"}
    if !slices.Equal(want, got) {
        t.Errorf("want %v, got %v", want, got)
    }
}

(Listing exercises/group)

GOAL: Get the test passing!


HINT: Well, a “group” is a lot like a “bunch”, and I don’t think you’ll need any more hints than that. There’s no trick here: it’s just a confidence builder to get you used to defining generic types.


SOLUTION: And here’s what that looks like, just as we saw before with the Bunch type:

type Group[E any] []E

(Listing solutions/group)

I told you it was easy!

Generic function types

You probably know that functions are values in Go. That is, you can pass a function as an argument to another function, you can return a function from a function, and you can declare variables or struct fields of a particular function type.

In other words, just as Go values can be integers, floats, or strings, they can also be functions. For example, a function that takes an int parameter and returns bool would have the type func(int) bool. This is a very handy feature of Go, and we use it a lot.

Generic functions as values

So what if that function were generic? For example, the Identity[T] function in our earlier example. How would we declare a variable to which we could assign the function Identity[T]? What would be the type of such a variable?

Well, you now know that there’s actually no such function as Identity[T], only one or more instantiations of that function on a specific type, such as string. As we’ve seen, the Identity[T] function definition is just a kind of “stencil” that Go uses to produce actual functions.

So there is such a function as Identity[string], and accordingly we can use it as a value. For example, we can assign it to a variable:

f := Identity[string]

The type is always instantiated

What is the type of the variable f, then? Well, it’s whatever the type of Identity[string] is. Here’s the signature of the generic Identity function again:

func Identity[T any](v T) T {

So if T is string in this case, then we feel the type of Identity[string] should be:

func(string) string

Let’s ask Go itself. Conveniently, the fmt package can report the type of a value, using the %T verb. So we’ll see what type it thinks f is in this case:

f := Identity[string]
fmt.Printf("%T\n", f)
// Output:
// func(string) string

How straightforward!

There are no generic functions

Just as with generic types, then, there are no generic functions in Go, if you want to be a smart-alec about it (and I do).

Any generic functions you may write will in fact be instantiated on some specific type at compile time, and they will then just be plain old functions, like func(string) string. If we wanted to give such a specific function type a name, we could:

type Stringulator func(string) string

Could we define a generic function type, though? That is, give a name to the type of a generic function on some arbitrary type T.

For example, could we write something like:

type idFunc func[T any](T) T
// syntax error: function type must have no type parameters

Nope. If you think about it, how could Go compile this? It couldn’t, because all type arguments must be known at compile time.

What we can write is this:

type idFunc[T any] func(T) T

This is fine, because it’s a generic type, just like the ones we’ve already seen. For some T, we’re saying, an idFunc[T] is a function that takes a T and returns a T.

If this is ever instantiated in our program, it will become some specific type like func(int) int. If not, Go can safely ignore it. Either way, no unknown types are involved.

Generic types as function parameters

We can create both generic functions and generic types, as we’ve seen. So an interesting question occurs: could we write a generic function that takes a parameter of a generic type?

The answer is absolutely yes:

func PrintBunch[E any](v Bunch[E]) {
    fmt.Println(v)
}

func main() {
    b := Bunch[int]{1, 2, 3}
    PrintBunch(b)
    // Output:
    // [1, 2, 3]
}

This is pretty exciting stuff! But there’s a limit to what we can do with generic functions that take literally any type. We’ll see what that limit is in a moment, but first, let’s do a little more confidence building.

Exercise: Lengthy proceedings

Now it’s your turn to write a generic function on a generic type, to solve the length exercise.

func TestLenOfSliceIs2WhenItContains2Elements(t *testing.T) {
    t.Parallel()
    s := []int{1, 2}
    want := 2
    got := length.Len(s)
    if want != got {
        t.Errorf("Len(%v): want %d, got %d", s, want, got)
    }
}

(Listing exercises/length)

GOAL: Your task is to implement a generic function Len[E] that returns the length of a given slice. As usual, the test will tell you when you’ve got it right!


HINT: Actually, the implementation is easy, isn’t it? We don’t need to write code to figure out the length of some slice; there’s a built-in function for that (see if you can guess which one).

The tricky bit, if it is tricky, might be writing the signature of the Len function. But it’s not really any more complicated than what we’ve seen already.

For example, the PrintBunch function takes a Bunch[E] for some arbitrary element type E. Well, so does this one! Can you see what to do?


SOLUTION: Here’s one possible answer:

func Len[E any](s []E) int {
    return len(s)
}

(Listing solutions/length)

Constraining type parameters

We saw in the previous tutorial that one of the limitations of interface types in Go is that we can’t use them with operators such as +. Remember our AddAnything example?

We can’t add any to any

You might be wondering if the same kind of limitation applies to functions parameterised by T any, and indeed it does. If we try to write a generic AddAnything[T any], for example, that doesn’t work:

func AddAnything[T any](x, y T) T {
    return x + y
}
// invalid operation: operator + not defined on x (variable
// of type T constrained by any)

Go is always right, but it can sometimes express itself in a rather terse way, so let’s unpack that error message a little.

It’s complaining about this line:

return x + y

And it’s saying:

operator + not defined on x

To put it another way, Go has no way to guarantee that whatever specific type T happens to be when the function is instantiated, that type will work with the + operator.

It might, but then again, it might not, because:

variable of type T constrained by any

Not every type is “addable”

In other words, because x can be any type, there’s no way to guarantee that, when the program runs, x will be one of the types that supports the + operator. So this is just the same kind of problem as when we tried to add together two any values.

If x and y were some struct type, for example, that certainly wouldn’t work: structs don’t support +, because it’s not meaningful to add two structs together. The + operator isn’t defined on structs, we say.

Go is telling us that we can’t write the expression x + y with values of literally any type T: that’s too broad a range of possible types.

It might be that, in a given program, we only ever instantiate AddAnything on types that do happen to support +, such as int or string. But that’s not good enough for Go: we need to guarantee that it can’t be instantiated on some inappropriate type.

To do this, we need to constrain T a little more. That is, to restrict the allowed possibilities for T to only those types that support the operator we want to use. And we’ll see how to do that in the next post.

Constraints in Go

Constraints in Go

Rust vs Go in 2024

Rust vs Go in 2024

0