Closed griesemer closed 2 years ago
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.
@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.
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
?
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?
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
.
@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
.
@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.
I wonder how feasible to remove comparable
. Will it be declared as an alias of any
to keep compatibility?
To me, this throws the baby out with the bathwater. It seems to have ~all of the downsides of #52509 except that it converts some compile time errors into run time panics. I assume the argument is that it is confusing to have compiler errors for some bugs of this class of, but not all - but IMO that is completely fine and not a good argument to not have compiler errors for any bugs of this class.
I still believe both #51338 and #52509 are acceptable solutions. And the more we talk about it, the more I become entrenched into my view that they are the best ones (either of them). They have downside, but it's fine to accept downsides sometimes.
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
IMO, if we do this, we should absolutely remove comparable
. It wouldn't allow the author of a generic function to do anything new, it would only prevent the user of a generic function from using it. That just feels like the current situation, but worse.
In other words, one of the flagship use cases for comparable
is Set[T comparable]
. The discussion is caused by Set[reflect.Type]
currently being impossible. With this proposal, the recommendation would thus be to write Set[T any]
instead. At that point, this flagship use case for comparable
goes away. Similarly for at least most other obvious use-cases. So ISTM that with this proposal, the general guidance on comparable
would be "don't use it, it unduly restricts the user of your function/type", in which case it should just go away.
Type parameters are comparable unless the type parameter's type set contains only non-comparable types.
How does this definition differ from "Types that supports == or !="? Specifically, what's the differentiation between this proposal and #52509?
@griesemer thanks for taking the time to write this up more concretely, I still think this would be a very good simplification for the language as a whole (and agree with @Merovius that assuming this proposal is accepted, deprecating comparable
would be the obvious next step).
To the point around bathwater... I do agree that it reduces some compiler completeness; but I don't think the programmer error of "passing something not comparable where comparability is needed" is a very common one in my experience (compared to say, just a logic bug). As pointed out in the proposal, it would still be found by cursory testing. So I think the trade-off of "a very confusing keyword" vs "a slightly longer turn around time to identify specific classes of bug in your code" is well worth it overall.
@changkun That proposal still leaves comparable
as a required keyword to use the ==
operator; this proposal would eliminate the need for that to be specified explicitly; which would avoid the problems around "virality" or "coloring" discussed in the other issues (and reduce the number of things for new go programmers to learn about).
this proposal would eliminate the need for that to be specified explicitly
Thanks for clarifying this. The goal here is radical as we already have comparable
in the language. Despite the interpretation of the Go 1 compatibility promise may differ, it remains a significant historical remark if this actually happens.
Take a step back, and I think the suggested wording in CL 401874 that separates the notion of interface and type set may balance this better. As noted in the CL commit message:
Defining "a type parameter is an interface", "may reflect a simplification, but it is also cumbersome for the predeclared identifier comparable
because
1) comparable cannot be easily defined using an interface and implemented using compiler magic; 2) comparable is very confusing to use"
This observation points to the key challenge of the current generics design: Type constraints are limited by the expressiveness of an interface, there are (more than one possible) type sets cannot be defined using an interface. comparable
is already a good example to show this limitation, and the known limitation noted in the 1.18 release note also confirms more examples, such as interface { string | fmt.Stringer }
. A theoretical proof may be more supportive in favor of this observation.
This should arise the reflect: As previous discussions revealed the need of multiple differently defined comparable definitions (in real world), it might worth to do the separation between interfaces and type sets.
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).
I don't think it poses a problem, but if we ever make all interfaces into full types, this restriction would fully go away, as you could always instantiate a generic function using its constraint. i.e. the set of types a constraint would allow will always include a comparable type - the constraint itself.
It's not a problem, because it will be a relaxation of a restriction (i.e. it will only allow more programs to compile, not prevent previously compiling programs to stop compiling). It might hint at a bit of strangeness about this rule though.
@ConradIrwin
I do agree that it reduces some compiler completeness; but I don't think the programmer error of "passing something not comparable where comparability is needed" is a very common one in my experience (compared to say, just a logic bug). As pointed out in the proposal, it would still be found by cursory testing. So I think the trade-off of "a very confusing keyword" vs "a slightly longer turn around time to identify specific classes of bug in your code" is well worth it overall.
I don't think #52509 leaves us with "a very confusing keyword" (nit: predeclared identifier) at all. So I disagree that this is a tradeoff we are making. In particular, anything that would still be confusing about comparable
would only happen in exactly the same situation where we get runtime panics with this proposal. So if you discount that, because it's not a very common mistake to make, it seems only fair to also discount the confusion this mistake would cause about comparable
.
IMO #52509 even makes the better tradeoff - because when that happens, there is at least a very clear indication in the API, that the values are supposed to be comparable. That's not something we even have here. That is, with #52509, if you get a panic by instantiating a generic function using an interface type and then get a panic in the comparison, you can at least look at the signature and say "oh, it says the value needs to be comparable, makes sense", whereas here, you end at "well, the signature says any
, so…".
Just leaving that as a note but I agree with @changkun https://github.com/golang/go/issues/52614#issuecomment-1112926210
The problem with comparable
was that we decided to have only one way to build the set of permissible types for a type parameter: an interface.
That's the reason behind #52531: to be able to introduce a refinement so that we can select the comparable (in its spec sense) subset of a set of types. Although it might be more complex in implementation, I don't know why it was deemed as not addressing the issue. (the only difference was the name, that was different for backward-compatibility reasons)
Also a little nit, it seems to me that the set of permissible types is not always a type set. For instance, basic interfaces are included in the set of permissible types, not in their own type set.
That's the reason behind #52531: to be able to introduce a refinement so that we can select the comparable (in its spec sense) subset of a set of types. Although it might be more complex in implementation, I don't know why it was deemed as not addressing the issue.
I wouldn't say that it doesn't address the issue. I would say that it's either a) the same as #49587 (in its original phrasing) or b) the same as #52509 (with your latest comment, after removing the second comparison constraint). It is phrased more complicatedly, but in terms of what code it allows to write and how that code is written, it does the same.
Both of those proposals do address the issue. Both have other downsides. But that doesn't mean we can't accept them anyways. I wouldn't say there has been a final decision made on any of these proposals.
Well if you leave the part about comparable
not being an interface anymore, they might look similar I guess (it would be equivalent to having both).
But yes, on the topic of the current proposal, it feels like we are dropping the ball perhaps too early. Although it could be seen as an engineering tradeoff, I'm not too sure about it. 😕
Well if you leave the part about
comparable
not being an interface anymore
I think the only practical difference this makes, is that it doesn't allow mixing comparable
with other constraints. Which is a problem with the design that would need to be addressed. At that point, any practical difference vanishes.
Well if you leave the part about
comparable
not being an interface anymoreI think the only practical difference this makes, is that it doesn't allow mixing
comparable
with other constraints. Which is a problem with the design that would need to be addressed. At that point, any practical difference vanishes.
I don't want to hijack this issue much more but there is still a practical difference. comparable
would not define a type set any longer. Only a set of permissible types which would also include all interface types and well-defined composites.
That's an important distinction.
The issue of mixing comparable
with interfaces is acknowledged.
But it's really too off-topic now. Let's talk about it on #52531.
What happens to derived map types?
func F[K any]() map[K]bool { return nil }
does this become valid? Is instantiating it with a non-comparable type a typechecker error or a runtime panic? Or can you actually get a map[[]int]bool
that doesn't work?
func F[K any]() map[K]bool { return nil }
IIUC, that would panic when run.
Here is another tough case. This does not compile:
func f(a []byte) {
var b []byte = nil
_ = a == b
}
But presumably this will compile and panic at runtime?
func f[T *any|[]byte](a T) {
var b T = nil
_ = a == b
}
f[[]byte](nil)
It's confusing because you can compare []byte to nil, but only against a constant nil, not against a variable that happens to have nil in it.
@Merovius
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).I don't think it poses a problem, but if we ever make all interfaces into full types, this restriction would fully go away, as you could always instantiate a generic function using its constraint. i.e. the set of types a constraint would allow will always include a comparable type - the constraint itself.
An alternative here is to adjust the meaning of "comparable" to specifically exclude interfaces that have no comparable types. At least to me, that rule seems at first like it is a bit too subtle, but we already have precedent with struct and array types that the comparability of a type can depend on the contents of the type definition. It is a straightforward rule which also seems reasonable to include in the spec change for this proposal.
I said this in more words in one of the previous proposals, but it seems more relevant here and so I'll state it again more concisely this time:
My general assumption is that whenever I use interface types (by which I mean: the pre-type-parameters mechanism for opting in to dynamic dispatch based on an interface) I am opting in to the possibility of panics at runtime in return for the flexibility of choosing a concrete type at runtime.
Therefore I expect to be able to choose to place an interface type in a type parameter and have the compiler defer to runtime any checks that depend on the final concrete type. This is under control of the caller of the generic function/type and so their own risk to take; the author of the callee only needs to specify that they require a comparable type, without regard to whether a caller will satisfy that constraint statically (by using a concrete type that the compiler can prove is comparable) or dynamically (by using an interface type where comparability is known only at runtime, and might therefore panic).
Constraining use of interface types because we don't know what concrete type they will contain at runtime rather seems to defeat the benefit of having them in the language. Callers are still free to use concrete types if they want the additional static guarantee of no panics at runtime.
This proposal seems to be a compromise within that mental model and so it seems favorable to me.
@griesemer
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
That's interesting. Why is that?
Specifically, would the following code panic? (one could replace false
with any unlikely or non-test-covered condition)
func F[T any](a, b T) {
if false {
_ = a == b
}
}
@rogpeppe I've tried to phrase this carefully, but perhaps the choice of "likely" was not careful enough. To elaborate: I suspect that in practice, generic code that crucially relies on ==
to work for its data is likely going to execute a comparison "fairly often". And if it doesn't, perhaps its test coverage is not good enough.
Of course one can easily construct (unrealistic?) cases where this is not the case.
Your function F
would not panic, of course.
Therefore I expect to be able to choose to place an interface type in a type parameter and have the compiler defer to runtime any checks that depend on the final concrete type.
I don't think this is really true, though. For example, the check that the value has a specific method is done at compile time. Really, the only such check (I can think of) is comparability. Hence the issues we are having with that specifically.
I honestly don't think that "interfaces are for dynamic dispatch" has even the slightest contest with making them type-safe at all. Most languages have some form of dynamic dispatch, even those with significantly stronger type systems than Go. That is, I think "dynamic dispatch" and "dynamic type checking" are mostly orthogonal concepts.
@Merovius, that is a fair observation and makes me realize that there are two conflicting interpretations here, one of which I was taking and one that you are taking:
When using interface types for dynamic dispatch of methods, the compiler checks statically that the interface has the corresponding method and that any concrete type converted to the interface type has the corresponding method. Calling the method therefore cannot panic at runtime. You are choosing to interpret comparable
as, in essence, the presence of a special method that has no name and instead has special syntax, representing equality which the compiler is checking for.
My framing is coming from the perspective of how Go already defines map[K]V
in the spec. I am essentially advocating for comparable
to be defined in exactly the same way as the spec currently defines the handling of various different K
:
The comparison operators
==
and!=
must be fully defined for operands of the key type; thus the key type must not be a function, map, or slice. If the key type is an interface type, these comparison operators must be defined for the dynamic key values; failure will cause a run-time panic.
I do have a bias that the primary way I've used generics so far has been to create type-safe collections where I immediately ran into this inconsistency where there seems to be no way to express with type parameters the same meaning as map[K]V
.
I can certainly see your perspective here too. It didn't immediately resonate with me because I think of Go as being a language which intentionally opted not to have operator overloading and therefore I don't think of ==
(and its corresponding use to define map key handling) as being a method. But it could certainly be valid to consider it that way; I suppose that is the essence of the tradeoff that this growing family of proposals is trying to navigate.
My framing is coming from the perspective of how Go already defines
map[K]V
in the spec. I am essentially advocating forcomparable
to be defined in exactly the same way as the spec currently defines the handling of various differentK
That is, FWIW, what #52509 proposes.
This proposal is both more relaxed (in that it allows using ==
or map[K]V
for more types, panicing at runtime) and more strict (in that it disallows instantiating comparable
with interface types. At least AIUI).
Part of the reason for having type constraints in Go generics was that based on the experience of C++ (pre-concepts), we didn't want a situation where you could write a generic F<T>()
today and have it successfully work with type C, and then tomorrow the implementation of F changes to be incompatible with C, and you get a confusing error message about it at great remove from the actual source of the problem. Unfortunately, this proposal does introduce that problem. If today func F[T any]()
doesn't use ==
but then tomorrow it does, it can go from working with a type to panicking at runtime without a clear signal of compatibility break. At the very least the panic should explain what is going wrong and why, but it may not be caught until testing or even production. For a library, it may not be caught until who ever is using the code in an unusual way trips over the problem and files an issue.
@carlmjohnson
If today func
F[T any]()
doesn't use==
but then tomorrow it does, it can go from working with a type to panicking at runtime without a clear signal of compatibility break.
Functions can always break when their implementations change; the use of ==
has very little bearing on that.
To me, the thing we want to avoid is if F[T]
can be instantiated today (without a type error) and becomes a compile-time error tomorrow. Because a compile-time error affects an entire package (instead of just programs with particular run-time behavior), to me that's very different from the behavior changing for a particular concrete run-time value.
To me, the thing we want to avoid is if
F[T]
can be instantiated today (without a type error) and becomes a compile-time error tomorrow. Because a compile-time error affects an entire package (instead of just programs with particular run-time behavior), to me that's very different from the behavior changing for a particular concrete run-time value.
🤯 A certain proponent of Rust's blog post against Go was making the rounds for no particular reason yesterday, and now I'm imagining the author's head exploding when he reads someone saying that it's better to not introduce a compile time error when possible. 😄
I do think this is a hard tradeoff. I think maybe ideally you could find a way to statically warn against calling it with go vet or something.
@carlmjohnson The proposal explicitly suggests the use of vet
for exactly this purpose.
I think this proposal gives up too much.
An intuitive view of generic functions is that instantiating a generic function is equivalent to writing out the same function with concrete types. Constraints allow us to perform the same type checks at compile type that we would when writing the non-generic equivalent. They also permit us to perform the type check when compiling the generic function, rather than when instantiating it.
This proposal essentially gives up that checking for ==
and !=
.
// Can't write this, because + isn't defined on []byte.
func Add[T int | []byte](a, b T) T { return a + b }
// Can write this, even though == isn't defined on []byte.
func Eq[T int | []byte](a, b T) bool { return a == b }
func init() {
// Runtime panic, even though we know at compile time that we're instantiating
// a function that can never run without crashing.
Eq([]byte{}, []byte{})
}
I don't see why we would want to do that. Constraints are useful. Compile-time validation is useful. There's no need to give up on them for equality.
I think that #52509 (all interface types implement comparable
) is strictly better than this proposal. That proposal permits ==
to panic in generic functions, but only in the cases where the non-generic equivalent also panics. It simply changes the definition of the comparable
interface to apply to types which implement ==
and !=
, as opposed to ones which implement those operators and guarantee they will never panic.
// Eq[T](a, b) can panic at runtime iff EqAny(a, b) would also panic.
func Eq[T comparable](a, b T) bool { return a == b }
func EqAny(a, b any) bool { return a == b }
func init() {
Eq[int](0, 0) // OK
Eq[[]byte])([]byte{}, []byte{}) // Compile-time failure: []byte is not comparable.
Eq[any]([]byte{}, []byte{}) // Run-time panic
EqAny([]byte{}, []byte{}) // Same run-time panic in a non-generic function
}
I do agree that #52509 is a step in the right direction (and as I said there, I'd be happy with that solution too). This proposal seems better to me because I think:
This is obviously a subjective statement (and it seems many people value the benefit much higher than me; and potentially also don't see the cost as so burdensome).
The reasons I think the benefit is low (particularly in comparison to #52509) is:
any
and comparable
are very similar in definition. any
excludes slices, maps and functions (but includes pointers to those things, or interfaces containing those things). (It also excludes structs that embed those things directly, but it's rare to pass such a large struct by copying and usually pointers are passed instead).comparable
may cause runtime panics, the function author must handle (or most commonly, just ignore) runtime non-comparable values anyway, so it makes no difference to the author of the generic code.The reasons I think the cost is high (again in comparison to #52509) are:
any
or comparable
comparable
(unless the typeset contains only listed types that implement ==
in which case it is implied), one the one hand including comparable
in a type-set makes it unusable as an interface, on the other it makes ==
work.constraints.Ordered
you can see how it's defined and how it works).any
as a type set vs any
as an interface{}
func Eq(a, b any) bool { return a == b } // compiles fine and sometimes panics at runtime
func EqAny[T any](a, b T) bool { return a == b } // fails to compile under #52509, would be equivalent to `Eq` here
Assuming that a path forward needs to be found for generic code written under 1.18 to work under this proposal, I think we could change the definition of comparable
to be a (deprecated) alias for any
, and all existing code would continue to compile.
Thinking into the far future: one strand of work to explore if we go with this proposal would be to further minimize values in the language that are not comparable. If slices were defined as equal in the same way as arrays, and maps in the same way as channels; the only thing left would be functions... Although out of scope for today, that would be independently a beneficial change (it's always struck me as odd that []byte{'a'} == []byte{'a'}
is a compile error but "a" == "a"
is not) and further reduce the utility of an explicit comparable
.
An intuitive view of generic functions is that instantiating a generic function is equivalent to writing out the same function with concrete types.
I've filed #52624, as a counterproposal. It attempts to provide exactly that property, while also maintaining coherent run-time semantics for comparable
and interfaces that embed it.
As comparable may cause runtime panics, the function author must handle runtime non-comparable values anyway
I disagree with this premise.
The convention for functions which accept interface values which must be comparable is to document this property and leave it up to the caller to pass values which satisfy it. If the caller passes invalid values to the function, they get a panic and can fix the problem.
For example, errors.Is
will panic if err == target
panics. This is never a problem in practice.
@neild maybe I stated it wrong, but I agree with you that "This is never a problem in practice.".
@atdiar Here a belated response to your https://github.com/golang/go/issues/52614#issuecomment-1112877627:
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)
That was exactly the initial (type parameter proposal) meaning of comparable
: the set of types which are comparable according to the spec.
I think if we knew exactly "what keeps us from selecting the comparable subset" we would't have these discussions. We have run into various problems and/or inconsistencies with the type set model, and you have made related pointed observations yourself in #52509. Obviously we are missing something. See also my comment here https://github.com/golang/go/issues/52624#issuecomment-1113847169.
Making comparable
superfluous might be a good idea, but reducing compile-time safety is disconcerting. IMHO, maybe the most important advantage of type parameters is that they are able to validate types at compile time. Allowing code to compile that will (a) panic at runtime and (b) where the panic is detectable by adding a check to go vet
leads me to think that it'd be better to do that check in the compiler, even if it rejects some technically valid code. It's better software engineering practice. Is there a reason why the check won't work in the compiler, other than being overly aggressive?
Correct, your example calling
eq[[]int](s, s)
would compile successfully and panic at run-time.
I feel like we are going to the opposite direction of one of the main benefits of using type parameters: Compile time type checking. Here we have technically a compile type check but that choses to panic at runtime instead of being a error during the compilation. This feels very wrong to me.
I agree that it would nice to have more static type checking for strictly comparable types. But perfect compile time type checking is not the end goal. If it were, we would need to be much more aggressive in Go. Instead, Go has from the start chosen a middle ground between usability and complexity.
The premise with this proposal is that, yes, we lose some static type checking, but also that in practice the specific loss here is not a source of problems. I cannot prove this but we do have a dozen years of experience with Go in the wild and I am not aware that not being able to statically ensure that map keys are always comparable w/o panic has caused any major problems. I'd be happy to hear evidence to the contrary.
That said, we still can make the rules given by this proposal slightly stronger. Rather than the current phrasing, we should say:
A type parameters is comparable unless its constraint interface is an extended (not ordinary) interface which contains incomparable types in its type set.
Or phrased the other (equivalent) way around:
A type parameter is comparable if its constraint interface is an ordinary interface, or if it is an extended interface with only comparable types in its type set.
With this we get
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 } no
i.e., the last example is now incomparable (vs comparable before) because the type set includes incomparable byte slices.
Again, the reasoning is that type parameters are a form of interfaces, we cannot change the behavior of ordinary interfaces, but we can make new rules for extended interfaces. For those, we permit comparison only if all types in the type set are comparable.
Here's a scenario where having to specify comparability is going to hurt: Consider an elementary generic data structure, a list for lack of a better idea, with various operations (methods). Say most operations have no requirements of the (generic) data maintained by the list (say, push, pop, reset, etc.), and some of the operations require the data to be comparable (say for filtering). We can choose to require the data type to be comparable (via some of the other proposals). This means that the data structure can only be instantiated with comparable types even though the operations (methods) requiring comparability may not ever be called by a client. If this generic list is used as within a larger generic package which happens to never need the list operations that require comparability, nevertheless this larger package may need to require comparability for its generic type(s) that are used with the list. Which is exactly the virality/coloring problem.
Here's a scenario where having to specify comparability is going to hurt
An alternative, type safe, approach is to make the operations which require specific characteristics of the data type stand alone functions rather than methods.
type List[T any] struct { ... }
// List[T].Add is defined for any T.
func (l *List[T]) Add(v T) { ... }
// Contains(somelist, v) is defined iff v is comparable.
func Contains[T comparable](l *List[T], v T) bool { ... }
This approach has the advantage of generalizing to cases where the requirement is not comparability--ordering, numeric operations, some method, whatever.
@griesemer
Here's a scenario where having to specify comparability is going to hurt […]
This example is specific to comparable
, but it can be applied just as much to any other constraint. For example, a generic map might want to provide an operation to iterate over its contents in sorted key order, if the keys are orderable.
The FGG paper suggested a different solution to this: Refined method constraints. That is, methods which need more specific restraints than the type itself, could restrict those methods on the stricter constraint. The type would then only get those methods for type arguments which fulfill those stricter constraints.
AIUI, the generics design has been intentionally given space to perhaps implement this at some point. Personally, I think it would be a good idea. But specifically, it seems a far better solution to the scenario you mention, than mostly abolishing comparable
.
@griesemer
So what will happen if I try to instantiate func EqAny[T any](a, b T) bool { return a == b }
with []byte
under those new rules? Will it compile and fail at runtime?
I think my main problem is that with incorrect type parameter and sufficiently complex generic structure it may fail way down in the depths of a structure and produce a really big stack. And instead of 100k line compile time error from C++ we will get 10k stacktrace run-time panic in Go :)
@DmitriyMV
So what will happen if I try to instantiate
func EqAny[T any](a, b T) bool { return a == b }
with[]byte
under those new rules? Will it compile and fail at runtime?
Yes.
@Merovius Yes, one could use refined method constraints (and as you say, I think we have the syntactic space for it). That said, even with our "simple" form of generics, people seem to not really appreciate the immense complexity of code they permit. I'd be really hesitant to make it more complicated. A major reason for Go's success is its relative simplicity, which includes the simplicity of its type system. Generics, while asked for by many people, really has put a dent into this.
@DmitriyMV Perhaps it will produce a big stack. But how is that different from the same code written using interfaces? Has that caused problems?
I appreciate the concern about lack of static type checking here; I do worry about it myself. But it's not the only concern, yet most feedback so far (in this and the other related proposals) seems to only worry about that. What about:
==
on interfaces in non-generic codego vet
could detect most (all?) statically incorrect uses of ==
@griesemer
increased complexity of rules "interface implementation" (right now we have a single uniform rule based on type sets)
I think this change will introduce additional "this looks strange to me" moment since traditionally we think that any
is "super-type" of every type - which means you can convert anything to any
(ex interface{}
). Right now comparable
is a subset of any
- every type which is comparable
can be passed to any,
but no any
can be passed to the comparable
. This results in a nice mental tree where everything starts from any
.
IIUC with the proposed changes you can have constraints which are actually non-comparable, but pass params to type\function which expects any
and suddenly they become comparable
. To me this sounds wrong. It also creates a strange loophole in future if we decide to have comparable
as an ordinary type for variables. Basically you can't assign func(){}
to comparable
, but you can assign it to any
and then assign resulting any
to comparable
. This is results in unsound type system I think?
any experience reports or comments on real problems with panicking == on interfaces in non-generic code
I don't think we are going to see a lot of this on pre 1.18 codebase, since you don't usually use ==
to compare interfaces. Most of the time interfaces are used with *T
(pointer types with pointer receivers) types rather than T
types, so ==
operator is useless here most of the time. Before generics there wasn't much use for value-typed collections.
the fact that go vet could detect most (all?) statically incorrect uses of ==
I'm not sure that go vet
can traverse the entire tree. For example - I have generic map, which is used in generic cache which is used in generic worker, which is itself is used in generic worker pool. I'm not sure that go vet
can traverse instantiation from generic worker pool down to generic map and find out that we are trying to ==
on non-comparable type. That would require some complex analysis IIUC.
Like @Merovius the more I think, the more I like #51338 since it allows us to have Set[T MyComparable]
in some future Go where MyComparable
can be defined as
type MyComparable interface {
comparable | CustomEqualMethodInterface
}
I appreciate the concern about lack of static type checking here; I do worry about it myself. But it's not the only concern, yet most feedback so far (in this and the other related proposals) seems to only worry about that. What about:
- increased complexity of rules "interface implementation" (right now we have a single uniform rule based on type sets)
- any experience reports or comments on real problems with panicking
==
on interfaces in non-generic code- the fact that
go vet
could detect most (all?) statically incorrect uses of==
The goal of a single uniform rule, although laudable, might not be as easily tractable. Currently, we have to understand 3 sets of types for an interface:
As long as there will be constraints on types that apply regardless of type sets, we will encounter this problem. I think that needs to be taken into account.
Reason why perhaps, at least two different proposals introduce the concept of constraint satisfaction, independently from type sets. That would be closer to an umbrella rule.
The main arguments I see in favor of adopting this approach are:
comparable
may become somewhat viral, a concern that had historically led us to avoid adding const
to the type system.Given the complexities we've encountered with comparable
and any
, I think that the simple answer, and the answer that de-emphasizes types, is to adopt this proposal.
I'm not going to claim that these are overwhelming arguments. The counter-arguments are also good. But if we keep simplicity as our highest goal here, then we do not want to multiply concepts. This proposal is not perfect, but it may be our best path forward.
If we adopt this proposal, then I think that we should make comparable
in a type constraint a synonym for any
, and move toward removing it from the language if possible. The argument here is basically: if we adopted this proposal, would we add comparable
if we didn't have it already? I think the answer to that is no.
@atdiar As far as I can see, if we adopt this proposal, then the three sets of types that you mention are all equivalent. Can you give an example where they are not? Thanks.
@DmitriyMV
I don't think we are going to see a lot of this on pre 1.18 codebase, since you don't usually use
==
to compare interfaces. Most of the time interfaces are used with*T
(pointer types with pointer receivers) types rather thanT
types, so==
operator is useless here most of the time.
I would argue that comparability is used most often on interfaces in Go, through err != nil
checks. Perhaps that is pedantic, since that particular comparison can never panic.
I would also argue that the dynamic types used in interfaces are generally the same regardless of whether the interfaces instantiate type parameters, so if ==
was comparing pointers before, it continues to do so.
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 ofcomparable
led to considerable discussion (see background in #52474 for details). It also led to confusion because the set of types described bycomparable
does not match the types that are considered comparable per the Go spec.Here's the current list of issues related to this discussion:
50646
51257
51338
52474
52509
52531
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 keepcomparable
, 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: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 https://github.com/golang/go/issues/51338#issuecomment-1057735177). 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: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 typecomparable
) 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 https://github.com/golang/go/issues/52509#issuecomment-1109359139.