https://bitfieldconsulting.com/posts/constraints

Blog
Subscribe
Books
Courses
DevOps
Contact
About
 
 
 
Blog RSS
[                    ]
 
 
 
Bitfield Consulting Friendly, professional Go & Rust mentoring
 
 
 
 
 
 
 
 
0
[ ]
Oct 31

Oct 31 Constraints in Go

John Arundel
[generics-fish]

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.

John Arundel
Go, golang, generics, interfaces, constraints, approximations, type
sets
Twitter LinkedIn0 Reddit
John Arundel
John Arundel
[image-asset]

Cras mattis consectetur purus sit amet fermentum. Integer posuere
erat a ante venenatis dapibus posuere velit aliquet. Aenean eu leo
quam. Pellentesque ornare sem lacinia quam venenatis vestibulum.

 

Oct 31 Generic types in Go

 

Join my Code Club

 
  
0