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:
.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}
So far, so fun! In the next post, we’ll talk about another way to constrain type parameters, by using type sets.