Skip to content

proposal: type parameters are comparable unless they exclude comparable types #52614

Closed
@griesemer

Description

@griesemer

Introduction

The predeclared type constraint comparable, introduced with Go 1.18, is a (magic) interface describing the set of types for which == is expected to work without panic. The introduction of comparable led to considerable discussion (see background in #52474 for details). It also led to confusion because the set of types described by comparable does not match the types that are considered comparable per the Go spec.

Here's the current list of issues related to this discussion:

The goal of these proposals is to address the perceived shortcomings of comparable by changing its definition or by separating the notion of interfaces and type sets.

So far none of these proposals (if still open) have gained significant traction, and none of them directly address the core of the comparable problem: in Go ordinary interfaces are always comparable, i.e., they support == and != independently of whether the dynamic type of the interface is comparable. We cannot change this without breaking backward-compatibility.

Instead we propose to embrace this property of interfaces.

Proposal

The underlying type of a type parameter is its type constraint interface; i.e., a type parameter is an interface (albeit with a "fixed" dynamic type which is given when the type parameter is instantiated). Because type parameters are interfaces, we propose:

Type parameters are comparable unless the type parameter's type set contains only non-comparable types.

This is the entire proposal.

Discussion

The reason for having comparable in the first place is to be able to statically express that == is expected to work and that it won't panic. If this proposal is accepted, == will be supported on type parameters unless the type set contains only non-comparable types. We will also lose the guarantee that == won't panic (if == is supported in the first place). We may still keep comparable, but more on that below.

This proposal hinges on the premise that losing the static "no-panic" guarantee is not as severe a loss as it might appear at first. We believe this could be true for the following reasons:

  • We are well-accustomed to the fact that == on ordinary interface types might panic. In code, we tend to address the comparability requirement through documentation; we suggest that we continue to use documentation for this.

  • If a type parameter is instantiated with a non-comparable type and == is expected to work, upon invocation the generic code is likely to panic right away. This contrasts favorably to the situation with ordinary interfaces where a panic may occur for some of the dynamic values but not all of them. In other words, making a comparability mistake in generic code would be detected quickly, probably in the first test run.

  • Better yet, we don't have to rely entirely on dynamic type safety: it should be straight-forward to introduce a vet check that reports when a type parameter for which we expect == to work is instantiated with a type that is not comparable. Such a check would provide the equivalent of a static compile-time check, and virtually eliminate the risk of ==-related panics.

With this proposal unfortunate restrictions caused by the use of comparable can be avoided. The == operator will simply be available to all type parameters unless their type sets contain only non-comparable types (it makes sense to exclude such type sets because we know with certainty that == will always panic for such type parameters). Examples:

interface                          comparable    may panic

any                                yes           yes
interface{ m() }                   yes           yes
interface{ ~int }                  yes           no
interface{ ~struct{ f any } }      yes           yes
interface{ ~[]byte }               no            n/a
interface{ ~string | ~[]byte }     yes           yes

This proposal also opens the door to more flexible (if perhaps esoteric) generic code that relies on == to work for some type instantiations but not for others, something that can be readily expressed through control flow but which is much harder (or impossible) to encode through types.

We still have the option to keep comparable as the "umbrella" set of types which are comparable without panic. Or we could decide to remove it because using it may preclude some uses of generic code (e.g., see #51338 (comment)). Keeping it will also require a programmer to always make the decision whether or not to use it. To remove it we could make use of the provision in the Go 1 compatibility guarantee:

If it becomes necessary to address an inconsistency or incompleteness in the specification, resolving the issue could affect the meaning or legality of existing programs. We reserve the right to address such issues, including updating the implementations.

Eliminating comparable would simplify the language and probably eliminate some confusion. The decision whether to keep or remove it is independent of this proposal.

History and credits

We briefly toyed with a simpler form of this idea (type parameters should always be comparable) as a potential solution to the comparable problem shortly before the 1.18 release. At that time we dismissed making all type parameters comparable (and eliminating the predeclared type comparable) as too radical. The resulting loss of static type safety around == in generic code seemed unacceptable.

We are aware of at least one other person, Conrad Irwin, who independently suggested that all type parameters should be comparable in #52509 (comment).

Activity

added this to the Proposal milestone on Apr 29, 2022
hherman1

hherman1 commented on Apr 29, 2022

@hherman1

Sorry I must be misunderstanding something, but if you were to instantiate a generic function with a concrete non comparable type (e.g a slice) wouldn’t you consider the type parameter to not be an interface? The point that type parameters are interfaces is confusing to me.

Also couldn’t this cause weird action at a distance? A deeply nested generic function requires comparable type parameters and you don’t realize it?

I realize I’m not fully read up on this issue and there’s a lot of history here, but the proposed behavior feels weird and surprising as a dumb user, which is concerning me.

griesemer

griesemer commented on Apr 29, 2022

@griesemer
ContributorAuthor

@hherman1 The view of a type parameter as an interface mostly affects type checking: for instance, in a generic function, the type of a type parameter is unknown when that function is type-checked (consider a generic exported library function were one knows nothing about clients - still we want to fully type-check that function). For type-checking we therefore treat the type parameter (more precisely, a variable of type parameter type) as an interface in the sense that we have to consider all possible types in its type set (we know a bit more than that, for instance we know that the type of such a variable, even if unknown, doesn't change for the duration of that generic function, which is why these are somewhat special interface types). It's also this type set we care about when that type parameter is used to instantiate yet another generic function.

We already have "weird action at a distance" with non-generic Go: a function operating on ordinary (non-type parameter) interfaces may hold a dynamic type that doesn't support comparison yet that function may try to compare the interface (all ordinary interfaces support ==). A point this proposal is trying to make is that this might not be as bad as it might seem: we're used to it and we could have a vet check fairly easily. The other point this proposal is making is that the apparent inconsistencies with respect to type sets (and comparable) disappear if we use the proposed approach.

atdiar

atdiar commented on Apr 29, 2022

@atdiar

I think it could work. It still feels like we are admitting defeat on having full static checking.

If we can use the provision, why not just make type parameters able to take a list of constraints?
Something like

type Set[T (any, comparable), V any]map[T]V

?
With comparable just filtering the set of types that implement any?

zigo101

zigo101 commented on Apr 29, 2022

@zigo101

Does this proposal mean the following code will become valid?

func foo[T any](x T) {
	_ = x == x
}

... Eliminating comparable would simplify the language and probably eliminate some confusion ...

Is there still a plan to use comparable as value type?

magical

magical commented on Apr 29, 2022

@magical
Contributor

Just to be clear, this program

func eq[T any](a, b T) bool { return a == b }

func main() {
     var s []int
     eq[[]int](s, s)
}

would be defined to always panic at runtime, right? Not a compile error even though it's statically obvious that it can't succeed? If the compiler decides to stencil eq[[]int] it would have to compile it down to, essentially,

func(a, b []int) bool { panic("[]int cannot be compared") }

since there is no way to implement the comparison.


How does this interact with comparisons to nil (or other constants)? I assume

func isnil[T any](a T) bool { return a == nil }

would still be invalid, since nil is not convertible to every type in T.

griesemer

griesemer commented on Apr 29, 2022

@griesemer
ContributorAuthor

@atdiar I don't understand what you're asking about with the "list of constraints" (any, comparable). It doesn't seem directly relevant to this proposal.

@go101 Yes, the foo function

func foo[T any](x T) {
	_ = x == x
}

would become valid since the type set of any includes all non-interface types, comparable and incomparable ones. Whether comparable would remain, and whether it should become a value type are questions that don't directly depend on this proposal: we could keep comparable as is for the static type safety that we don't have with this proposal, at the cost of less flexibility. If we keep it, we probably want to also allow it as a value type. But again these decisions are not directly tied to this proposal. On the flip side, if we accept this proposal we do have an opportunity to remove comparable which would simplify the language slightly.

@magical Correct, your example calling eq[[]int](s, s) would compile successfully and panic at run-time. Whether the eq function is fully stenciled and == replaced with a panic call or not is an implementation question. isnil would continue to be invalid code as is the case now. Note that in the case of eq, go vet could easily detect that == is used on operands of type T and report an error if T is instantiated with a non-comparable type like []int.

atdiar

atdiar commented on Apr 29, 2022

@atdiar

@griesemer
If an interface used as a constraint defines the set of permissible types, what keeps us from selecting the comparable subset? (i.e. The set of types that are comparable according to the spec)

It's relevant to that proposal in the sense that it would avoid the use of a static tool.

If it's really too off-topic I will disengage but I am curious.

zigo101

zigo101 commented on Apr 29, 2022

@zigo101

I wonder how feasible to remove comparable. Will it be declared as an alias of any to keep compatibility?

103 remaining items

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @apparentlymart@neild@rogpeppe@ConradIrwin@rsc

        Issue actions

          proposal: type parameters are comparable unless they exclude comparable types · Issue #52614 · golang/go