_content/blog: add blog post on comparable constraint

Change-Id: I542154bb75d5685124f4cd901884cf0391bf310d
Reviewed-on: https://go-review.googlesource.com/c/website/+/467115
Reviewed-by: Ian Lance Taylor <iant@google.com>
Reviewed-by: Ian Lance Taylor <iant@golang.org>
Reviewed-by: Eli Bendersky <eliben@google.com>
TryBot-Bypass: Robert Griesemer <gri@google.com>
Reviewed-by: Robert Griesemer <gri@google.com>
This commit is contained in:
Robert Griesemer 2023-02-09 14:33:58 -08:00 коммит произвёл Michael Pratt
Родитель 3fc36b61c2
Коммит dea5ec8226
1 изменённых файлов: 424 добавлений и 0 удалений

424
_content/blog/comparable.md Normal file
Просмотреть файл

@ -0,0 +1,424 @@
---
title: All your comparable types
date: 2023-02-17
by:
- Robert Griesemer
summary: type parameters, type sets, comparable types, constraint satisfaction
---
On February 1 we released our latest Go version, 1.20,
which included a few language changes.
Here we'll discuss one of those changes: the predeclared `comparable` type constraint
is now satisfied by all [comparable types](/ref/spec#Comparison_operators).
Surprisingly, before Go 1.20, some comparable types did not satisfy `comparable`!
If you're confused, you've come to the right place.
Consider the valid map declaration
```Go
var lookupTable map[any]string
```
where the map's key type is `any` (which is a
[comparable type](/ref/spec#Comparison_operators)).
This works perfectly fine in Go.
On the other hand, before Go 1.20, the seemingly equivalent generic map type
```Go
type genericLookupTable[K comparable, V any] map[K]V
```
could be used just like a regular map type, but produced a compile-time error when
`any` was used as the key type:
```Go
var lookupTable genericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)
```
Starting with Go 1.20 this code will compile just fine.
The pre-Go 1.20 behavior of `comparable` was particularly annoying because it
prevented us from writing the kind of generic libraries we were hoping to write with
generics in the first place.
The proposed [`maps.Clone`](/issue/57436) function
```Go
func Clone[M ~map[K]V, K comparable, V any](m M) M { … }
```
can be written but could not be used for a map such as `lookupTable` for the same reason
our `genericLookupTable` could not be used with `any` as key type.
In this blog post, we hope to shine some light on the language mechanics behind all this.
In order to do so, we start with a bit of background information.
## Type parameters and constraints
Go 1.18 introduced generics and, with that,
[_type parameters_](/ref/spec#Type_parameter_declarations)
as a new language construct.
In an ordinary function, a parameter ranges over a set of values that is restricted by its type.
Analogously, in a generic function (or type), a type parameter ranges over a set of types that is restricted
by its [_type constraint_](/ref/spec#Type_constraints).
Thus, a type constraint defines the _set of types_ that are permissible
as type arguments.
Go 1.18 also changed how we view interfaces: while in the past an
interface defined a set of methods, now an interface defines a set of types.
This new view is completely backward compatible:
for any given set of methods defined by an interface, we can imagine the (infinite)
set of all types that implement those methods.
For instance, given an [`io.Writer`](/pkg/io#Writer) interface,
we can imagine the infinite set of all types that have a `Write` method
with the appropriate signature.
All of these types _implement_ the interface because they all have the
required `Write` method.
But the new type set view is more powerful than the old method set one:
we can describe a set of types explicitly, not only indirectly through methods.
This gives us new ways to control a type set.
Starting with Go 1.18, an interface may embed not just other interfaces,
but any type, a union of types, or an infinite set of types that share the
same [underlying type](/ref/spec#Underlying_types). These types are then included in the
[type set computation](/ref/spec#General_interfaces):
the union notation `A|B` means "type `A` or type `B`",
and the `~T` notation stands for "all types that have the underlying type `T`".
For instance, the interface
```Go
interface {
~int | ~string
io.Writer
}
```
defines the set of all types whose underlying types are either `int` or `string`
and that also implement `io.Writer`'s `Write` method.
Such generalized interfaces can't be used as variable types.
But because they describe type sets they are used as type constraints, which
are sets of types.
For instance, we can write a generic `min` function
```Go
func min[P interface{ ~int64 | ~float64 }](x, y P) P
```
which accepts any `int64` or `float64` argument.
(Of course, a more realistic implementation would use a constraint that
enumerates all basic types with an `<` operator.)
As an aside, because enumerating explicit types without methods is common,
a little bit of [syntactic sugar](https://en.wikipedia.org/wiki/Syntactic_sugar)
allows us to [omit the enclosing `interface{}`](/ref/spec#General_interfaces),
leading to the compact and more idiomatic
```Go
func min[P ~int64 | ~float64](x, y P) P { … }
```
With the new type set view we also need a new way to explain what it means
to [_implement_](/ref/spec#Implementing_an_interface) an interface.
We say that a (non-interface) type `T` implements
an interface `I` if `T` is an element of the interface's type set.
If `T` is an interface itself, it describes a type set. Every single type in that set
must also be in the type set of `I`, otherwise `T` would contain types that do not implement `I`.
Thus, if `T` is an interface, it implements interface `I` if the type
set of `T` is a subset of the type set of `I`.
Now we have all the ingredients in place to understand constraint satisfaction.
As we have seen earlier, a type constraint describes the set of acceptable argument
types for a type parameter. A type argument satisfies the corresponding type parameter
constraint if the type argument is in the set described by the constraint interface.
This is another way of saying that the type argument implements the constraint.
In Go 1.18 and Go 1.19, constraint satisfaction meant constraint implementation.
As we'll see in a bit, in Go 1.20 constraint satisfaction is not quite constraint
implementation anymore.
## Operations on type parameter values
A type constraint does not just specify what type arguments are acceptable for a type parameter,
it also determines the operations that are possible on values of a type parameter.
As we would expect, if a constraint defines a method such as `Write`,
the `Write` method can be called on a value of the respective type parameter.
More generally, an operation such as `+` or `*` that is supported by all types in the type set
defined by a constraint is permitted with values of the corresponding type parameter.
For instance, given the `min` example, in the function body any operation that is supported by
`int64` and `float64` types is permitted on values of the type parameter `P`.
That includes all the basic arithmetic operations, but also comparisons such as `<`.
But it does not include bitwise operations such as `&` or `|`
because those operations are not defined on `float64` values.
## Comparable types
In contrast to other unary and binary operations, `==` is defined on not just
a limited set of
[predeclared types](/ref/spec#Types), but on an infinite variety of types,
including arrays, structs, and interfaces.
It is impossible to enumerate all these types in a constraint.
We need a different mechanism to express that a type parameter must support `==`
(and `!=`, of course) if we care about more than predeclared types.
We solve this problem through the predeclared type
[`comparable`](/ref/spec#Predeclared_identifiers), introduced with Go 1.18.
`comparable` is
an interface type whose type set is the infinite set of comparable types, and that
may be used as a constraint whenever we require a type argument to support `==`.
Yet, the set of types comprised by `comparable` is not the same
as the set of all [comparable types](/ref/spec#Comparison_operators) defined by the Go spec.
[By construction](/ref/spec#Interface_types), a type set specified by an interface
(including `comparable`) does not contain the interface itself (or any other interface).
Thus, an interface such as `any` is not included in `comparable`,
even though all interfaces support `==`.
What gives?
Comparison of interfaces (and of composite types containing them) may panic at run time:
this happens when the dynamic type, the type of the actual value stored in the
interface variable, is not comparable.
Consider our original `lookupTable` example: it accepts arbitrary values as keys.
But if we try to enter a value with a key that does not support `==`, say
a slice value, we get a run-time panic:
```Go
lookupTable[[]int{}] = "slice" // PANIC: runtime error: hash of unhashable type []int
```
By contrast, `comparable` contains only types that the compiler guarantees will not panic with `==`.
We call these types _strictly comparable_.
Most of the time this is exactly what we want: it's comforting to know that `==` in a generic
function won't panic if the operands are constrained by `comparable`, and it is what we
would intuitively expect.
Unfortunately, this definition of `comparable` together with the rules for
constraint satisfaction prevented us from writing useful
generic code, such as the `genericLookupTable` type shown earlier:
for `any` to be an acceptable argument type, `any` must satisfy (and therefore implement) `comparable`.
But the type set of `any` is larger than (not a subset of) the type set of `comparable`
and therefore does not implement `comparable`.
```Go
var lookupTable GenericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)
```
Users recognized the problem early on and filed a multitude of issues and proposals in short order
([#51338](/issue/51338),
[#52474](/issue/52474),
[#52531](/issue/52531),
[#52614](/issue/52614),
[#52624](/issue/52624),
[#53734](/issue/53734),
etc).
Clearly this was a problem we needed to address.
The "obvious" solution was simply to include even non-strictly comparable types in the
`comparable` type set.
But this leads to inconsistencies with the type set model.
Consider the following example:
```Go
func f[Q comparable]() { … }
func g[P any]() {
_ = f[int] // (1) ok: int implements comparable
_ = f[P] // (2) error: type parameter P does not implement comparable
_ = f[any] // (3) error: any does not implement comparable (Go 1.18, Go.19)
}
```
Function `f` requires a type argument that is strictly comparable.
Obviously it is ok to instantiate `f` with `int`: `int` values never panic on `==`
and thus `int` implements `comparable` (case 1).
On the other hand, instantiating `f` with `P` is not permitted: `P`'s type set is defined
by its constraint `any`, and `any` stands for the set of all possible types.
This set includes types that are not comparable at all.
Hence, `P` doesn't implement `comparable` and thus cannot be used to instantiate `f`
(case 2).
And finally, using the type `any` (rather than a type parameter constrained by `any`)
doesn't work either, because of exactly the same problem (case 3).
Yet, we do want to be able to use the type `any` as type argument in this case.
The only way out of this dilemma was to change the language somehow.
But how?
## Interface implementation vs constraint satisfaction
As mentioned earlier, constraint satisfaction is interface implementation:
a type argument `T` satisfies a constraint `C` if `T` implements `C`.
This makes sense: `T` must be in the type set expected by `C` which is
exactly the definition of interface implementation.
But this is also the problem because it prevents us from using non-strictly comparable
types as type arguments for `comparable`.
So for Go 1.20, after almost a year of publicly discussing numerous alternatives
(see the issues mentioned above), we decided to introduce an exception for just this case.
To avoid the inconsistency, rather than changing what `comparable` means,
we differentiated between _interface implementation_,
which is relevant for passing values to variables, and _constraint satisfaction_,
which is relevant for passing type arguments to type parameters.
Once separated, we could give each of those concepts (slightly) different
rules, and that is exactly what we did with proposal [#56548](/issue/56548).
The good news is that the exception is quite localized in the
[spec](/ref/spec#Satisfying_a_type_constraint).
Constraint satisfaction remains almost the same as interface implementation, with a caveat:
> A type `T` satisfies a constraint `C` if
>
> - `T` implements `C`; or
> - `C` can be written in the form `interface{ comparable; E }`, where `E` is a basic interface
> and `T` is [comparable](/ref/spec#Comparison_operators) and implements `E`.
The second bullet point is the exception.
Without going too much into the formalism of the spec, what the exception says is the following:
a constraint `C` that expects strictly comparable types (and which may also have other requirements
such as methods `E`) is satisfied by any type argument `T` that supports `==`
(and which also implements the methods in `E`, if any).
Or even shorter: a type that supports `==` also satisfies `comparable`
(even though it may not implement it).
We can immediately see that this change is backward-compatible:
before Go 1.20, constraint satisfaction was the same as interface implementation, and we still
have that rule (1st bullet point).
All code that relied on that rule continues to work as before.
Only if that rule fails do we need to consider the exception.
Let's revisit our previous example:
```Go
func f[Q comparable]() { … }
func g[P any]() {
_ = f[int] // (1) ok: int satisfies comparable
_ = f[P] // (2) error: type parameter P does not satisfy comparable
_ = f[any] // (3) ok: satisfies comparable (Go 1.20)
}
```
Now, `any` does satisfy (but not implement!) `comparable`.
Why?
Because Go permits `==` to be used with values of type `any`
(which corresponds to the type `T` in the spec rule),
and because the constraint `comparable` (which corresponds to the constraint `C` in the rule)
can be written as `interface{ comparable; E }` where `E` is simply the empty interface
in this example (case 3).
Interestingly, `P` still does not satisfy `comparable` (case 2).
The reason is that `P` is a type parameter constrained by `any` (it _is not_ `any`).
The operation `==` is _not_ available with all types in the type set of `P`
and thus not available on `P`;
it is not a [comparable type](/ref/spec#Comparison_operators).
Therefore the exception doesn't apply.
But this is ok: we do like to know that `comparable`, the strict comparability
requirement, is enforced most of the time. We just need an exception for
Go types that support `==`, essentially for historical reasons:
we always had the ability to compare non-strictly comparable types.
## Consequences and remedies
We gophers take pride in the fact that language-specific behavior
can be explained and reduced to a fairly compact set of rules, spelled out
in the language spec.
Over the years we have refined these rules, and when possible made them
simpler and often more general.
We also have been careful to keep the rules orthogonal,
always on the lookout for unintended and unfortunate consequences.
Disputes are resolved by consulting the spec, not by decree.
That is what we have aspired to since the inception of Go.
_One does not simply add an exception to a carefully crafted type
system without consequences!_
So where's the catch?
There's an obvious (if mild) drawback, and a less obvious (and more
severe) one.
Obviously, we now have a more complex rule for constraint satisfaction
which is arguably less elegant than what we had before.
This is unlikely to affect our day-to-day work in any significant way.
But we do pay a price for the exception: in Go 1.20, generic functions
that rely on `comparable` are not statically type-safe anymore.
The `==` and `!=` operations may panic if applied to operands of
`comparable` type parameters, even though the declaration says
that they are strictly comparable.
A single non-comparable value may sneak its way through
multiple generic functions or types by way of a single non-strictly
comparable type argument and cause a panic.
In Go 1.20 we can now declare
```Go
var lookupTable genericLookupTable[any, string]
```
without compile-time error, but we will get a run-time panic
if we ever use a non-strictly comparable key type in this case, exactly like we would
with the built-in `map` type.
We have given up static type safety for a run-time check.
There may be situations where this is not good enough,
and where we want to enforce strict comparability.
The following observation allows us to do exactly that, at least in limited
form: type parameters do not benefit from the exception that we added to the
constraint satisfaction rule.
For instance, in our earlier example, the type parameter `P` in the function
`g` is constrained by `any` (which by itself is comparable but not strictly comparable)
and so `P` does not satisfy `comparable`.
We can use this knowledge to craft a compile-time assertion of sorts for
a given type `T`:
```Go
type T struct { … }
```
We want to assert that `T` is strictly comparable.
It's tempting to write something like:
```Go
// isComparable may be instantiated with any type that supports ==
// including types that are not strictly comparable because of the
// exception for constraint satisfaction.
func isComparable[_ comparable]() {}
// Tempting but not quite what we want: this declaration is also
// valid for types T that are not strictly comparable.
var _ = isComparable[T] // compile-time error if T does not support ==
```
The dummy (blank) variable declaration serves as our "assertion".
But because of the exception in the constraint satisfaction rule,
`isComparable[T]` only fails if `T` is not comparable at all;
it will succeed if `T` supports `==`.
We can work around this problem by using `T` not as a type argument,
but as a type constraint:
```Go
func _[P T]() {
_ = isComparable[P] // P supports == only if T is strictly comparable
}
```
Here is a [passing](/play/p/9i9iEto3TgE) and [failing](/play/p/5d4BeKLevPB) playground example
illustrating this mechanism.
## Final observations
Interestingly, until two months before the Go 1.18
release, the compiler implemented constraint satisfaction exactly as we do
now in Go 1.20.
But because at that time constraint satisfaction meant interface implementation,
we did have an implementation that was inconsistent with the language specification.
We were alerted to this fact with [issue #50646](/issue/50646).
We were extremely close to the release and had to make a decision quickly.
In the absence of a convincing solution, it seemed safest to make the
implementation consistent with the spec.
A year later, and with plenty of time to consider different approaches,
it seems that the implementation we had was the implementation we want in the first place.
We have come full circle.
As always, please let us know if anything doesn't work as expected
by filing issues at [https://go.dev/issue/new](/issue/new).
Thank you!