gogenericscomparablecompile-time-type-checking

Ensure strict comparability at compile time in Go 1.20?


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?


Solution

  • 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.