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.
- Generics
- Type parameters
- Generic types
- 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:
.Println(Stringify(1))
fmt// 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) {
.Parallel()
t:= &bytes.Buffer{}
buf .StringifyTo[greeting](buf, greeting{})
stringy:= "Howdy!\n"
want := buf.String()
got if want != got {
.Errorf("want %q, got %q", want, got)
t}
}
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) {
.Fprintln(w, p.String())
fmt}
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 {
| Float | Complex
Integer }
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 {
.Reader
io.Stringer
fmt}
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 {
| Chicken
Cow }
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
:
:= Farm[Cow]{}
dairy := Farm[Chicken]{} poultry
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:
:= Farm[Animal]{}
mixed // 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:
.Println(Double(MyInt(1)))
fmt// 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:
:= struct{ x, y int }{1, 2}
p (p) Plot
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 {
, y int
x}
:= Point{1, 2}
p (p)
Plot// 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
}
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) {
.Parallel()
t:= MyInt(1)
input if !intish.IsPositive(input) {
.Errorf("IsPositive(1): want true, got false")
t}
}
func TestIsPositive_IsFalseForNegative1(t *testing.T) {
.Parallel()
t:= MyInt(-1)
input if intish.IsPositive(input) {
.Errorf("IsPositive(-1): want false, got true")
t}
}
func TestIsPositive_IsFalseForZero(t *testing.T) {
.Parallel()
t:= MyInt(0)
input if intish.IsPositive(input) {
.Errorf("IsPositive(0): want false, got true")
t}
}
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
}
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 {
(???) bool // we can't say 'T' here
Equal}
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)
}
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) {
.Parallel()
tif !greater.IsGreater(MyInt(2), MyInt(1)) {
.Fatalf("IsGreater(2, 1): want true, got false")
t}
}
func TestIsGreater_IsFalseFor1And2(t *testing.T) {
.Parallel()
tif greater.IsGreater(MyInt(1), MyInt(2)) {
.Fatalf("IsGreater(1, 2): want false, got true")
t}
}
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)
}
Like most things, it’s delightfully simple once you know. For a type parameter T, the required interface is:
(T) bool Greater
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.