Go 1.20 brought several interesting updates, and among them, a subtle yet significant change concerning the predeclared type constraint comparable
. If you’ve been working with Go generics, you might have encountered situations where comparable
seemed to behave unexpectedly, especially when dealing with interfaces like any
. Prior to Go 1.20, some types that were indeed comparable according to the Go specification didn’t actually satisfy the comparable
constraint in generic contexts. This article aims to clarify this behavior, explain the changes introduced in Go 1.20, and explore the implications for writing generic Go code.
To illustrate the issue, consider the following scenarios. In Go, you can perfectly define a map where the key type is any
:
var lookupTable map[any]string
This works seamlessly because any
is indeed a comparable type in Go. However, before Go 1.20, the seemingly equivalent generic map type declaration:
type genericLookupTable[K comparable, V any] map[K]V
would stumble when you tried to use any
as the key type K
:
var lookupTable genericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)
This code, which seems logically consistent with the non-generic map example, would fail to compile in Go 1.18 and Go 1.19. Thankfully, starting with Go 1.20, this code now compiles without any issues.
This pre-Go 1.20 behavior of comparable
was not just an academic oddity; it directly hindered the development of truly generic libraries in Go. For instance, a function like maps.Clone
, intended to generically clone maps:
func Clone[M ~map[K]V, K comparable, V any](m M) M { … }
couldn’t be used with maps like our lookupTable
example, precisely because any
couldn’t satisfy the comparable
constraint.
In this article, we’ll delve into the mechanics behind this behavior, starting with a foundational understanding of type parameters and constraints in Go.
Type Parameters and Constraints: Defining Generics in Go
Go 1.18 marked the arrival of generics, introducing type parameters as a fundamental language feature. Think of type parameters as placeholders for actual types, much like regular function parameters are placeholders for values.
In a standard function, a parameter’s type dictates the range of values it can accept. Similarly, in a generic function or type, a type parameter is governed by a type constraint. A type constraint essentially defines the set of types that are permissible as type arguments for a given type parameter.
Go 1.18 also redefined how we understand interfaces. Traditionally, interfaces were viewed as collections of methods. Now, with generics, interfaces also define sets of types. This shift is backward-compatible; for any set of methods defined by an interface, we can envision an (infinite) set of types that implement those methods. For example, the io.Writer
interface implicitly represents all types that possess a Write
method with the correct signature.
However, the new type set perspective offers more power. We can now explicitly define sets of types, not just indirectly through methods. Since Go 1.18, interfaces can embed not only other interfaces but also concrete types, unions of types, or infinite sets of types sharing the same underlying type. These constructs are then used in type set calculations. The union notation A|B
signifies “type A
or type B
,” while ~T
denotes “all types with the underlying type T
.” Consider this interface:
interface { ~int | ~string; io.Writer }
This interface defines a set of types that includes all types whose underlying type is either int
or string
, and which also implement the io.Writer
interface (meaning they have a Write
method).
While these generalized interfaces can’t be used directly as variable types, their strength lies in serving as type constraints, which are themselves sets of types. For instance, we can define a generic min
function:
func min[P interface{ ~int64 | ~float64 }](x, y P) P
This function min
will accept either int64
or float64
arguments (or any type whose underlying type is one of these). A more robust implementation would likely use a constraint that lists all basic numeric types that support the <
operator.
As a syntactic convenience, Go allows us to omit the enclosing interface{}
when enumerating explicit types without methods, leading to a more concise and idiomatic syntax:
func min[P ~int64 | ~float64](x, y P) P { … }
With this type set view, we also need a revised understanding of interface implementation. A non-interface type T
is said to implement an interface I
if T
is a member of the type set defined by I
. If T
itself is an interface, it also describes a type set. For T
to implement I
, every type in the type set of T
must also be in the type set of I
. Otherwise, T
would encompass types that don’t implement I
. In essence, the type set of T
must be a subset of the type set of I
.
Now, let’s connect this to constraint satisfaction. A type constraint specifies the acceptable type arguments for a type parameter. A type argument satisfies its corresponding type parameter constraint if the type argument belongs to the set of types defined 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 was essentially synonymous with constraint implementation. However, as we’ll see, Go 1.20 slightly alters this relationship.
Operations on Type Parameter Values: What Can You Do in Generics?
A type constraint not only dictates which type arguments are valid for a type parameter but also governs the operations you can perform on values of that type parameter. If a constraint defines a method, like Write
in the case of io.Writer
, then the Write
method can be invoked on a value of the corresponding type parameter. More generally, any operation (like +
, -
, *
, /
) that is supported by all types within the type set defined by a constraint is permissible on values of the associated type parameter.
Consider our min
function example again. Within the function body, any operation valid for both int64
and float64
is allowed on values of type P
. This includes basic arithmetic operations and comparisons like <
, but excludes bitwise operations (&
, |
) because they are not defined for float64
.
Comparable Types: The Special Case of Equality
Unlike many operations, the equality operator ==
is not limited to a small set of predeclared types. It’s defined across a wide range of types, including arrays, structs, and interfaces. It’s practically impossible to list all these types explicitly in a constraint. Therefore, we need a different mechanism to express the requirement that a type parameter must support ==
(and !=
) when we need to handle more than just predeclared types.
Go addresses this with the predeclared type comparable
, introduced in Go 1.18. comparable
is an interface whose type set is the infinite set of all comparable types. It serves as a constraint when you need to ensure a type argument supports equality comparisons.
However, the set of types encompassed by comparable
is not identical to the set of all comparable types as defined in the Go specification. By its very construction, an interface’s type set (including comparable
) does not include the interface itself, or any other interface. This means an interface like any
, despite supporting ==
, is not part of the comparable
type set. Why is this the case?
Comparisons involving interfaces (and composite types containing them) can potentially panic at runtime. This occurs when the dynamic type—the actual type of the value stored in the interface variable—is not comparable. Recall our lookupTable
example, which accepts arbitrary values as keys. If we attempt to use a key value that doesn’t support ==
, like a slice, we encounter a runtime panic:
lookupTable[[]int{}] = "slice" // PANIC: runtime error: hash of unhashable type []int
In contrast, comparable
is designed to only include types for which the compiler can guarantee that ==
operations will never panic. These are known as strictly comparable types.
Most of the time, this strictness is desirable. It provides assurance that ==
operations within a generic function won’t panic if the operands are constrained by comparable
, which aligns with intuitive expectations.
Unfortunately, this definition of comparable
, coupled with the rules of constraint satisfaction in Go 1.18 and 1.19, prevented the creation of useful generic code, such as our genericLookupTable
example. For any
to be a valid type argument for K
in genericLookupTable[K comparable, V any]
, any
would need to satisfy (and thus implement) comparable
. However, the type set of any
is broader than (not a subset of) the type set of comparable
, meaning any
does not implement comparable
.
var lookupTable GenericLookupTable[any, string] // ERROR: any does not implement comparable (Go 1.18 and Go 1.19)
Users quickly recognized this limitation, leading to numerous issues and proposals for improvement (#51338, #52474, #52531, #52614, #52624, #53734, and more). It was clear that this was an issue that needed resolution.
One seemingly straightforward solution was to broaden the comparable
type set to include even non-strictly comparable types. However, this approach introduces inconsistencies within the type set model. Consider this example:
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
mandates a type argument that is strictly comparable. Instantiating f
with int
is valid because int
values never cause panics with ==
, and int
therefore implements comparable
(case 1). However, using P
to instantiate f
is not allowed. P
‘s type set is defined by its constraint any
, which includes types that are not comparable at all. Hence, P
does not implement comparable
and cannot be used to instantiate f
(case 2). Similarly, using the type any
itself (instead of a type parameter constrained by any
) also fails for the same reason (case 3).
Yet, the desire to use any
as a type argument in such cases was strong. The only way to resolve this dilemma was to adjust the language rules. But how?
Interface Implementation vs. Constraint Satisfaction: A Subtle but Key Distinction in Go 1.20
As previously discussed, constraint satisfaction was initially defined as interface implementation: a type argument T
satisfies a constraint C
if T
implements C
. This principle is logical, ensuring that T
is within the type set expected by C
, which is the very definition of interface implementation.
However, this very principle was the root of the problem, preventing the use of non-strictly comparable types as type arguments for comparable
.
In Go 1.20, after extensive public discussion and consideration of various alternatives (as seen in the issues linked earlier), a nuanced distinction was introduced. To address the inconsistency, instead of altering the meaning of comparable
, Go 1.20 differentiated between interface implementation, which remains relevant for assigning values to variables, and constraint satisfaction, which is crucial for passing type arguments to type parameters. By separating these concepts, slightly different rules could be applied to each, leading to the solution implemented with proposal #56548.
The crucial change is quite localized within the specification. Constraint satisfaction remains largely the same as interface implementation, but with an important exception:
A type
T
satisfies a constraintC
if
T
implementsC
; orC
can be written in the forminterface{ comparable; E }
, whereE
is a basic interface andT
is comparable and implementsE
.
This second bullet point is the key exception. In simpler terms, a constraint C
that expects strictly comparable types (and potentially other requirements represented by basic interface E
) is now satisfied by any type argument T
that supports ==
(and also implements the methods in E
, if any). Or even more concisely: a type that supports ==
also satisfies comparable
, even if it doesn’t strictly implement it in the interface sense.
This change maintains backward compatibility. The first rule (1st bullet point) ensures that code relying on the original constraint satisfaction as interface implementation continues to function as before. Only when that rule doesn’t apply do we consider the new exception.
Let’s revisit our earlier example with these new rules:
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
indeed satisfies (though does not implement!) comparable
. Why? Because Go allows the use of ==
with values of type any
(which corresponds to T
in the spec rule), and because the constraint comparable
(corresponding to C
) can be expressed as interface{ comparable; E }
, where E
is simply the empty interface in this case (case 3).
Interestingly, P
still does not satisfy comparable
(case 2). This is because P
is a type parameter constrained by any
; it is not the type any
itself. The ==
operation is not guaranteed to be valid for all types within the type set of P
and therefore is not universally available on P
. Thus, the exception rule doesn’t apply to P
. This distinction is important: we retain the enforcement of strict comparability in most generic contexts, but we’ve made an exception for Go types that inherently support ==
, largely due to historical reasons—Go has always allowed comparisons of non-strictly comparable types.
Consequences and Remedies: Trade-offs in Type Safety
The Go language prides itself on a relatively simple and well-defined set of rules, meticulously detailed in the language specification. These rules are refined over time to enhance simplicity and generality, always with a focus on orthogonality and minimizing unintended consequences. Disputes are resolved by referencing the spec, not by arbitrary decisions. This philosophy has been central to Go since its inception.
However, adding an exception to a carefully designed type system inevitably has consequences!
What are the trade-offs? There’s a clear, albeit minor, drawback, and a less obvious but potentially more significant one. The most immediate consequence is a more complex rule for constraint satisfaction, arguably less elegant than the previous simplicity. However, this complexity is unlikely to significantly impact day-to-day Go development.
The more substantial trade-off is a reduction in static type safety for generic functions that rely on comparable
in Go 1.20. The ==
and !=
operations can now potentially panic when used with operands of comparable
type parameters, despite the declaration suggesting strict comparability. A non-comparable value could slip through multiple layers of generic functions or types via a non-strictly comparable type argument, ultimately leading to a runtime panic. In Go 1.20, declaring:
var lookupTable genericLookupTable[any, string]
is now valid at compile time. However, a runtime panic will still occur if a non-strictly comparable key type is used, just as with built-in map
types. We’ve essentially traded some static type safety for runtime checks in these specific scenarios.
There might be situations where this trade-off is undesirable, and strict comparability is essential. Fortunately, there’s a way to enforce strict comparability, at least in a limited form. Type parameters themselves do not benefit from the exception added to the constraint satisfaction rule. For example, in our earlier code, the type parameter P
in function g
, constrained by any
(which is comparable but not strictly comparable), does not satisfy comparable
. We can leverage this to create a compile-time assertion for a given type T
:
type T struct { … }
If we want to ensure T
is strictly comparable, we might be tempted to write:
// 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 ==
This approach, using a blank variable declaration as an “assertion,” isn’t quite sufficient. Due to the constraint satisfaction exception, isComparable[T]
will only fail if T
is not comparable at all. It will succeed even if T
supports ==
but is not strictly comparable.
To work around this, we can use T
as a type constraint rather than a type argument:
func _[P T]() {
_ = isComparable[P] // P supports == only if T is strictly comparable
}
Here’s a working example and a failing example in the Go Playground illustrating this technique.
Final Thoughts: Coming Full Circle
Interestingly, until just two months before the Go 1.18 release, the Go compiler’s implementation of constraint satisfaction behaved exactly as it does now in Go 1.20. However, because at that time constraint satisfaction was defined as interface implementation, the compiler’s behavior was inconsistent with the language specification. This discrepancy was brought to light by issue #50646. With the release date rapidly approaching, a quick decision was needed. In the absence of a more comprehensive solution, aligning the implementation with the specification seemed the safest course of action. A year later, with ample time for reflection and exploration of different approaches, it turns out that the original implementation—the one we inadvertently had—was indeed the desired behavior all along. In a way, we’ve come full circle.
As always, if you encounter any unexpected behavior or issues, please report them at https://go.dev/issue/new.
Thank you for reading!
Robert Griesemer, a key figure in Go’s development, authored this insightful blog post.
Next article: Code coverage for Go integration tests
Previous article: Profile-guided optimization preview