Ensure strict comparability at compile time in Go 1.20?
Asked Answered
K

1

7

In Go 1.18 and Go 1.19 I can ensure at compile time that a type is strictly comparable, i.e. it supports == and != operators and those are guaranteed to not panic at run time.

This is useful for example to avoid inadvertently adding fields to a struct that could cause unwanted panics.

I just attempt to instantiate comparable with it:

// supports == and != but comparison could panic at run time
type Foo struct {
    SomeField any
}

func ensureComparable[T comparable]() {
    // no-op
}

var _ = ensureComparable[Foo] // doesn't compile because Foo comparison may panic

This is possible in Go 1.18 and 1.19 due to the very definition of the comparable constraint:

The predeclared interface type comparable denotes the set of all non-interface types that are comparable

Even though the Go 1.18 and 1.19 spec fail to mention types that are not interfaces but also not strictly comparable, e.g. [2]fmt.Stringer or struct { foo any }, the gc compiler does reject these as arguments for comparable.

Playground with several examples: https://go.dev/play/p/_Ggfdnn6OzZ

With Go 1.20, instantiating comparable will be aligned with the broader notion of comparability. This makes ensureComparable[Foo] compile even though I don't want it to.

Is there a way to statically ensure strict comparability with Go 1.20?

Kiaochow answered 14/12, 2022 at 20:32 Comment(0)
K
6

To test that Foo is strictly comparable in Go 1.20, instantiate ensureComparable with a type parameter constrained by Foo.

// unchanged
type Foo struct {
    SomeField any
}

// unchanged
func ensureComparable[T comparable]() {}

// T constrained by Foo, instantiate ensureComparable with T
func ensureStrictlyComparable[T Foo]() {
    _ = ensureComparable[T] // <---- doesn't compile
}

This solution has been originally suggested by Robert Griesemer here.


So how does it work?

Go 1.20 introduces a difference between implementing an interface and satisfying a constraint:

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 and implements E.

The second bullet point is the exception that permits interfaces, and types with interfaces, to instantiate comparable.

So now in Go 1.20 the type Foo itself can instantiate comparable due to the satisfiability exception. But the type parameter T isn't Foo. Comparability of type parameters is defined differently:

Type parameters are comparable if they are strictly comparable (see below).

[...]

Type parameters are strictly comparable if all types in their type set are strictly comparable.

The type set of T includes a type Foo that is not strictly comparable (because it has an interface field), therefore T doesn't satisfy comparable. Even though Foo itself does.

This trick effectively makes the program fail to compile if Foo's operators == and != might panic at run time.

Kiaochow answered 14/12, 2022 at 20:32 Comment(3)
Let's say I have an API that requires strictly comparable types. Is there a way to abstract the static check? Or, does each caller need to implement this check for their respective Foo type(s)?Crossquestion
@HymnsForDisco In ensureStrictlyComparable you can probably constrain T to a union of the types whose strict comparability must hold, because the 1.20 spec says "... all types in their type set are strictly comparable", so if just one isn't, compilation still failsKiaochow
@HymnsForDisco on a second thought: if your question is from the PoV of a library writer, then you the library writer cannot statically make sure your third-party callers will always use strictly comparable type. Mainly because 1) you can't possibly know what types they will define, and 2) the only general-purpose constraint you have at your disposal is still comparable, and callers will indeed be able to instantiate it with non-strictly comparable types.Kiaochow

© 2022 - 2024 — McMap. All rights reserved.