Constraints in Go

Constraints in Go

From Know Go

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

This is the fourth in a four-part series of tutorials on generics in Go.

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

In my book Know Go, and in the previous tutorials in this series, you’ll learn all about generic programming in Go and the new universe of programs it opens up to us. Ironically, one of the new features of Go that gives us the most freedom is constraints. Let’s talk about that, and explain the paradox.

We saw in the previous tutorial that when we’re writing generic functions that take 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 in the first post in this series. 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.

There are a few things we can do with any values, as you already know, because we’ve done them. For example, 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)

Strictly speaking, of course, we don’t really need to call the String method: fmt already knows how to do that automagically. But if we just passed p directly, we wouldn’t need the Stringer constraint, and we could use any… but what would be the fun in that?

Type set constraints

We’ve seen that one way an interface can specify an allowed range of types is by including a method element, such as String() string. That would be a basic interface, but now let’s introduce another kind of interface. Instead of listing methods that the type must have, it directly specifies a set of types that are allowed.

Type elements

For example, suppose we wanted to write some generic function Double that multiplies a number by two, and we want a type constraint that allows only values of type int. We know that int has no methods, so we can’t use any basic interface as a constraint. How can we write it, then?

Well, here’s how:

type OnlyInt interface {
    int
}

Very straightforward! It looks just like a regular interface definition, except that instead of method elements, it contains a single type element, consisting of a named type. In this case, the named type is int.

Using a type set constraint

How would we use a constraint like this? Let’s write Double, then:

func Double[T OnlyInt](v T) T {
    return v * 2
}

In other words, for some T that satisfies the constraint OnlyInt, Double takes a T parameter and returns a T result.

Note that we now have one answer to the sort of problem we encountered with AddAnything: how to enable the * operator (or any other arithmetic operator) in a parameterised function. Since T can only be int (thanks to the OnlyInt constraint), Go can guarantee that the * operator will work with T values.

It’s not the complete answer, though, since there are other types that support * that wouldn’t be allowed by this constraint. And in any case, if we were only going to support int, we could have just written an ordinary function that took an int parameter.

So we’ll need to be able to expand the range of types allowed by our constraint a little, but not beyond the types that support *. How can we do that?

Unions

What types can satisfy the constraint OnlyInt? Well, only int! To broaden this range, we can create a constraint specifying more than one named type:

type Integer interface {
    int | int8 | int16 | int32 | int64
}

The types are separated by the pipe character, |. You can think of this as representing “or”. In other words, a type will satisfy this constraint if it is int or int8 or… you get the idea.

This kind of interface element is called a union. The type elements in a union can include any Go types, including interface types.

It can even include other constraints. In other words, we can compose new constraints from existing ones, like this:

type Float interface {
    float32 | float64
}

type Complex interface {
    complex64 | complex128
}

type Number interface {
    Integer | Float | Complex
}

We’re saying that Integer, Float, and Complex are all unions of different built-in numeric types, but we’re also creating a new constraint Number, which is a union of those three interface types we just defined. If it’s an integer, a float, or a complex number, then it’s a number!

The set of all allowed types

The type set of a constraint is the set of all types that satisfy it. The type set of the empty interface (any) is the set of all types, as you’d expect.

The type set of a union element (such as Float in the previous example) is the union of the type sets of all its terms.

In the Float example, which is the union of float32 | float64, its type set contains float32, float64, and no other types.

Intersections

You probably know that with a basic interface, a type must have all of the methods listed in order to implement the interface. And if the interface contains other interfaces, a type must implement all of those interfaces, not just one of them.

For example:

type ReaderStringer interface {
    io.Reader
    fmt.Stringer
}

If we were to write this as an interface literal, we would separate the methods with a semicolon instead of a newline, but the meaning is the same:

interface { io.Reader; fmt.Stringer }

To implement this interface, a type has to implement both io.Reader and fmt.Stringer. Just one or the other isn’t good enough.

Each line of an interface definition like this, then, is treated as a distinct type element. The type set of the interface as a whole is the intersection of the type sets of all its elements. That is, only those types that all the elements have in common.

So putting interface elements on different lines has the effect of requiring a type to implement all those elements. We don’t need this kind of interface very often, but we can imagine cases where it might be necessary.

Empty type sets

You might be wondering about what happens if we define an interface whose type set is completely empty. That is, if there are no types that can satisfy the constraint.

Well, that could happen with an intersection of two type sets that have no elements in common. For example:

type Unpossible interface {
    int
    string
}

Clearly no type can be both int and string at the same time! Or, to put it another way, this interface’s type set is empty.

If we try to instantiate a function constrained by Unpossible, we’ll find, naturally enough, that it can’t be done:

cannot implement Unpossible (empty type set)

We probably wouldn’t do this on purpose, since an unsatisfiable constraint doesn’t seem that useful. But with more sophisticated interfaces, we might accidentally reduce the allowed type set to zero, and it’s helpful to know what this error message means so that we can fix the problem.

Composite type literals

A composite type is one that’s built up from other types. We saw some composite types in the previous tutorial, such as []E, which is a slice of some element type E.

But we’re not restricted to defined types with names. We can also construct new types on the fly, using a type literal: that is, literally writing out the type definition as part of the interface.

A struct type literal

For example, this interface specifies a struct type literal:

type Pointish interface {
    struct{ X, Y int }
}

A type parameter with this constraint would allow any instance of such a struct. In other words, its type set contains exactly one type: struct{ X, Y int }.

Access to struct fields

While we can write a generic function constrained by some struct type such as Pointish, there are limitations on what that function can do with that type. One is that it can’t access the struct’s fields:

func GetX[T Pointish](p T) int {
    return p.X
}
// p.X undefined (type T has no field or method X)

In other words, we can’t refer to a field on p, even though the function’s constraint explicitly says that any p is guaranteed to be a struct with at least the field X. This is a limitation of the Go compiler that has not yet been overcome. Sorry about that.

Some limitations of type sets

An interface containing type elements can only be used as a constraint on a type parameter. It can’t be used as the type of a variable or parameter declaration, like a basic interface can. That too is something that might change in the future, but this is where we are today.

Constraints versus basic interfaces

What exactly stops us from doing that, though? We already know that we can write functions that take ordinary parameters of some basic interface type such as Stringer. So what happens if we try to do the same with an interface containing type elements, such as Number?

Let’s see:

func Double(p Number) Number {
// interface contains type constraints

This doesn’t compile, for the reasons we’ve discussed. Some potential confusion arises from the fact that a basic interface can be used as both a regular interface type and a constraint on type parameters. But interfaces that contain type elements can only be used as constraints.

Constraints are not classes

If you have some experience with languages that have classes (hierarchies of types), then there’s another thing that might trip you up with Go generics: constraints are not classes, and you can’t instantiate a generic function or type on a constraint interface.

To illustrate, suppose we have some concrete types Cow and Chicken:

type Cow struct{ moo string }

type Chicken struct{ cluck string }

And suppose we define some interface Animal whose type set consists of Cow and Chicken:

type Animal interface {
    Cow | Chicken
}

So far, so good, and suppose we now define a generic type Farm as a slice of T Animal:

type Farm[T Animal] []T

Since we know the type set of Animal contains exactly Cow and Chicken, then either of those types can be used to instantiate Farm:

dairy := Farm[Cow]{}
poultry := Farm[Chicken]{}

What about Animal itself? Could we create a Farm[Animal]? No, because there’s no such type as Animal. It’s a type constraint, not a type, so this gives an error:

mixed := Farm[Animal]{}
// interface contains type constraints

And, as we’ve seen, we also couldn’t use Animal as the type of some variable, or ordinary function parameter. Only basic interfaces can be used this way, not interfaces containing type elements.

Approximations

Let’s return to our earlier definition of an interface Integer, consisting of a union of named types. Specifically, the built-in signed integer types:

type Integer interface {
    int | int8 | int16 | int32 | int64
}

We know that the type set of this interface contains all the types we’ve named. But what about defined types whose underlying type is one of the built-in types?

Limitations of named types

For example:

type MyInt int

Is MyInt also in the type set of Integer? Let’s find out. Suppose we write a generic function that uses this constraint:

func Double[T Integer](v T) T {
    return v * 2
}

Can we pass it a MyInt value? We’ll soon know:

fmt.Println(Double(MyInt(1)))
// MyInt does not implement Integer

No. That makes sense, because Integer is a list of named types, and we can see that MyInt isn’t one of them.

How can we write an interface that allows not only a set of specific named types, but also any other types derived from them?

Type approximations

We need a new kind of type element: a type approximation. We write it using the tilde (~) character:

type ApproximatelyInt interface {
    ~int
}

The type set of ~int includes int itself, but also any type whose underlying type is int (for example, MyInt).

If we rewrite Double to use this constraint, we can pass it a MyInt, which is good. Even better, it will accept any type, now or in the future, whose underlying type is int.

Derived types

Approximations are especially useful with struct type elements. Remember our Pointish interface?

type Pointish interface {
    struct{ x, y int }
}

Let’s write a generic function with this constraint:

func Plot[T Pointish](p T) {

We can pass it values of type struct{ x, y int }, as you’d expect:

p := struct{ x, y int }{1, 2}
Plot(p)

But now comes a problem: we can’t pass values of any named struct type, even if the struct definition itself matches the constraint perfectly:

type Point struct {
    x, y int
}
p := Point{1, 2}
Plot(p)
// Point does not implement Pointish (possibly missing ~ for
// struct{x int; y int} in constraint Pointish)

What’s the problem here? Our constraint allows struct{ x, y int }, but Point is not that type. It’s a type derived from it. And, just as with MyInt, a derived type is distinct from its underlying type.

You know now how to solve this problem: use a type approximation! And Go is telling us the same thing: “Hint, hint: I think you meant to write a ~ in your constraint.”

If we add that approximation, the type set of our interface expands to encompass all types derived from the specified struct, including Point:

type Pointish interface {
    ~struct{ x, y int }
}

Exercise: A first approximation

Can you use what you’ve just learned to solve the intish challenge?

Here you’re provided with a function IsPositive, which determines whether a given value is greater than zero:

func IsPositive[T Intish](v T) bool {
    return v > 0
}

(Listing exercises/intish)

And there’s a set of accompanying tests that instantiate this function on some derived type MyInt:

type MyInt int

func TestIsPositive_IsTrueFor1(t *testing.T) {
    t.Parallel()
    input := MyInt(1)
    if !intish.IsPositive(input) {
        t.Errorf("IsPositive(1): want true, got false")
    }
}

func TestIsPositive_IsFalseForNegative1(t *testing.T) {
    t.Parallel()
    input := MyInt(-1)
    if intish.IsPositive(input) {
        t.Errorf("IsPositive(-1): want false, got true")
    }
}

func TestIsPositive_IsFalseForZero(t *testing.T) {
    t.Parallel()
    input := MyInt(0)
    if intish.IsPositive(input) {
        t.Errorf("IsPositive(0): want false, got true")
    }
}

(Listing exercises/intish)

GOAL: Your task here is to define the Intish interface.


HINT: A method set won’t work here, because the int type has no methods! On the other hand, the type literal int won’t work either, because MyInt is not int, it’s a new type derived from it.

What kind of constraint could you use instead? I think you know where this is going, don’t you? If not, have another look at the previous section on type approximations.


SOLUTION: It’s not complicated, once you know that a type approximation is required:

type Intish interface {
    ~int
}

(Listing solutions/intish)

Interface literals

Up to now, we’ve always used type parameters with a named constraint, such as Integer (or even just any). And we know that those constraints are defined as interfaces. So could we use an interface literal as a type constraint?

Syntax of an interface literal

An interface literal, as you probably know, consists of the keyword interface followed by curly braces containing (optionally) some interface elements.

For example, the simplest interface literal is the empty interface, interface{}, which is common enough to have its own predeclared name, any.

We should be able to write this empty interface literal wherever any is allowed as a type constraint, then:

func Identity[T interface{}](v T) T {

And so we can. But we’re not restricted to only empty interface literals. We could write an interface literal that contains a method element, for example:

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

This is a little hard to read at first, perhaps. But we’ve already seen this exact function before, only in that case it had a named constraint Stringer. We’ve simply replaced that name with the corresponding interface literal:

interface{ String() string }

That is, the set of types that have a String method. We don’t need to name this interface in order to use it as a constraint, and sometimes it’s clearer to write it as a literal.

Omitting the interface keyword

And we’re not limited to just method elements in interface literals used as constraints. We can use type elements too:

[T interface{ ~int }]

Conveniently, in this case we can omit the enclosing interface { ... }, and write simply ~int as the constraint:

[T ~int]

For example, we could write some function Increment constrained to types derived from int:

func Increment[T ~int](v T) T {
    return v + 1
}

However, we can only omit the interface keyword when the constraint contains exactly one type element. Multiple elements wouldn’t be allowed, so this doesn’t work:

func Increment[T ~int; ~float64](v T) T {
// syntax error: unexpected semicolon in parameter list; possibly 
// missing comma or ]

And we can’t omit interface with method elements either:

func Increment[T String() string](v T) T {
// syntax error: unexpected ( in parameter list; possibly 
// missing comma or ]

And we can only omit interface in a constraint literal. We can’t omit it when defining a named constraint. So this doesn’t work, for example:

type Intish ~int
// syntax error: unexpected ~ in type declaration

Referring to type parameters

We’ve seen that in certain cases, instead of having to define it separately, we can write a constraint directly as an interface literal. So you might be wondering: can we refer to T inside the interface literal itself? Yes, we can.

To see why we might need to do that, suppose we wanted to write a generic function Contains[T], that takes a slice of T and tells you whether or not it contains a given value.

And suppose that we’ll determine this, for any particular element of the slice, by calling some Equal method on the element. That means we must constrain the function to only types that have a suitable Equal method.

So the constraint for T is going to be an interface containing the method Equal(T) bool, let’s say.

Can we do this? Let’s try:

func Contains[T interface{ Equal(T) bool }](s []T,  v T) bool {

Yes, this is fine. In fact, using an interface literal is the only way to write this constraint. We couldn’t have created some named interface type to do the same thing. Why not?

Let’s see what happens if we try:

type Equaler interface {
    Equal(???) bool // we can't say 'T' here
}

Because the type parameter T is part of the Equal method signature, and we don’t have T here. The only way to refer to T is in an interface literal inside a type constraint:

[T interface{ Equal(T) bool }]

At least, we can’t write a specific interface that mentions T in its method set. What we’d need here, in fact, is a generic interface, and you’ll learn how to define and use these in my book, Know Go. If these tutorials have given you an appetite for generic programming in Go, I think you’ll really enjoy the book—check it out!

Exercise: Greater love

Your turn now to see if you can solve the greater exercise.

You’ve been given the following (incomplete) function:

func IsGreater[T /* Your constraint here! */](x, y T) bool {
    return x.Greater(y)
}

(Listing exercises/greater)

This takes two values of some arbitrary type, and compares them by calling the Greater method on the first value, passing it the second value.

The tests exercise this function by calling it with two values of a defined type MyInt, which has the required Greater method.

type MyInt int

func (m MyInt) Greater(v MyInt) bool {
    return m > v
}

func TestIsGreater_IsTrueFor2And1(t *testing.T) {
    t.Parallel()
    if !greater.IsGreater(MyInt(2), MyInt(1)) {
        t.Fatalf("IsGreater(2, 1): want true, got false")
    }
}

func TestIsGreater_IsFalseFor1And2(t *testing.T) {
    t.Parallel()
    if greater.IsGreater(MyInt(1), MyInt(2)) {
        t.Fatalf("IsGreater(1, 2): want false, got true")
    }
}

(Listing exercises/greater)

GOAL: To make these tests pass, you’ll need to write an appropriate type constraint for IsGreater. Can you see what to do?


HINT: Remember, we got here by talking about constraints as interface literals, and in particular, interface literals that refer to the type parameter.

If you try to define some named interface with the method set containing Greater, for example, that won’t work. We can’t do it for the same reason that we couldn’t define a named interface with the method set Equal: we don’t know what type of argument that method takes.

Just like Equal, Greater takes arguments of some arbitrary type T, so we need an interface literal that can refer to T in its definition. Does that help?


SOLUTION: Here’s one way to do it:

func IsGreater[T interface{ Greater(T) bool }](x, y T) bool {
    return x.Greater(y)
}

(Listing solutions/greater)

Like most things, it’s delightfully simple once you know. For a type parameter T, the required interface is:

Greater(T) bool

And that’s how we do that.

Well, I hope you enjoyed this tutorial series, and if so, why not treat yourself to a copy of Know Go? There’s much more to explore, so I’d love you to come along with me for the ride.

Rust and Go vs everything else

Rust and Go vs everything else

Generic types in Go

Generic types in Go

0