Constraints in Go

Constraints in Go

From Know Go

Design is the beauty of turning constraints into advantages.
Aza Raskin

Ironically, constraints are one of the features of Go that gives us the most freedom as programmers. Let’s talk about that, and explain the paradox.

As we saw in Generic types, when we’re writing generic functions that take literally any type, the range of things we can do with values of that type is necessarily rather limited. For example, we can’t add them together. For that, we’d need to be able to prove to Go that they’re one of the types that support the + operator.

Method set constraints

It’s the same with interfaces, as we discussed earlier in Generics in Go. The empty interface, any, is implemented by every type, and so knowing that something implements any tells you nothing distinctive about it.

Limitations of the any constraint

Similarly, in a generic function parameterised by some type T, constraining T to any doesn’t give Go any information about it. So it has no way to guarantee that a given operator, such as +, will work with values of T.

A Go proverb says:

The bigger the interface, the weaker the abstraction.
https://go-proverbs.github.io/

And the same is true of constraints. The broader the constraint, and thus the more types it allows, the less we can guarantee about what operations we can do on them.

My book Know Go is principally about generics in Go, and the new ways we can use generics to write flexible, expressive, and useful programs. It’s a relatively new, and not yet widely used, feature of Go, and I hope you’ll find the book a helpful guide to both how generics work, and even more importantly, what we can do with them.

In this excerpt from the book, let’s see some examples of how we can make generic functions more powerful by constraining the types they can accept. There’s not much we can do with the any type, but the more constrained our type becomes, the more operations become available on it.

There are a few things we can do with any values: we can declare variables of that type, we can assign values to them, we can return them from functions, and so on.

But we can’t really do a whole lot of computation with them, because we can’t use operators like + or -. So in order to be able to do something useful with values of T, such as adding them, we need more restrictive constraints.

What kinds of constraints could there be on T? Let’s examine the possibilities.

Basic interfaces

One kind of constraint that we’re already familiar with in Go is an interface. In fact, all constraints are interfaces of a kind, but let’s use the term basic interface here to avoid any confusion. A basic interface, we’ll say, is one that contains only method elements.

For example, the fmt.Stringer interface we saw in the first tutorial:

type Stringer interface {
    String() string
}

We’ve seen that we can write an ordinary, non-generic function that takes a parameter of type Stringer. And we can also use this interface as a type constraint for a generic function.

For example, we could write a generic function parameterised by some type T, but this time T can’t be just any type. Instead, we’ll say that whatever T turns out to be, it must implement the fmt.Stringer interface:

func Stringify[T fmt.Stringer](s T) string {
    return s.String()
}

This is clear enough, and it works the same way as the generic functions we’ve already written. The only new thing is that we used the constraint Stringer instead of any. Now when we actually call this function in a program, we’re only allowed to pass it arguments that implement Stringer.

What would happen, then, if we tried to call Stringify with an argument that doesn’t implement Stringer? We feel instinctively that this shouldn’t work, and it doesn’t:

fmt.Println(Stringify(1))
// int does not implement Stringer (missing method String)

That makes sense. It’s just the same as if we wrote an ordinary, non-generic function that took a parameter of type Stringer, as we did in the first tutorial.

There’s no advantage to writing a generic function in this case, since we can use this interface type directly in an ordinary function. All the same, a basic interface—one defined by a set of methods—is a valid constraint for type parameters, and we can use it that way if we want to.

Exercise: Stringy beans

Flex your generics muscles a little now, by writing a generic function constrained by fmt.Stringer to solve the stringy exercise.

type greeting struct{}

func (greeting) String() string {
    return "Howdy!"
}

func TestStringifyTo_PrintsToSuppliedWriter(t *testing.T) {
    t.Parallel()
    buf := &bytes.Buffer{}
    stringy.StringifyTo[greeting](buf, greeting{})
    want := "Howdy!\n"
    got := buf.String()
    if want != got {
        t.Errorf("want %q, got %q", want, got)
    }
}

(Listing exercises/stringy)

GOAL: Your job here is to write a generic function StringifyTo[T] that takes an io.Writer and a value of some arbitrary type constrained by fmt.Stringer, and prints the value to the writer.


HINT: This is a bit like the PrintAnything function we saw before, isn’t it? Actually, it’s a “print anything stringable” function. We already know what the constraint is (fmt.Stringer), and the rest is straightforward.


SOLUTION: Here’s a version that would work, for example:

func StringifyTo[T fmt.Stringer](w io.Writer, p T) {
    fmt.Fprintln(w, p.String())
}

(Listing solutions/stringy)

So far, so fun! In the next post, we’ll talk about another way to constrain type parameters, by using type sets.

Rust and Go vs everything else

Rust and Go vs everything else

Generic types in Go

Generic types in Go