-
Notifications
You must be signed in to change notification settings - Fork 17.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
proposal: spec: generics: type switch on parametric types #45380
Comments
Replying to @candlerb #45346 (comment)
Isn't this very similar to what happens with a Let's assume for the sake of argument that two versions of Then the |
I assume you would apply the same to union-elements? FWIW, while it doesn't add expressive power, I'd prefer if we'd allow both approximation- and union-elements directly, if we can make it work without syntactical ambiguities. It's more convenient and IMO still clear. And I would prefer if the type Constraint interface {
~int | ~int8 | ~string
}
func ThisSyntax[T Constraint]() {
switch type T {
case ~int | ~int8:
// …
case ~string:
// …
}
}
func IsClearerThanThisSyntax[T Constraint]() {
switch type T {
case interface{~int | ~int8 }:
// …
case interface{ ~string }:
// …
}
} But it's a weakly held opinion. I think this would be good, but one concern I have is that I think it would have unfortunate overlap with simply allowing approximation-elements in type switches and assertions (let's call it "approximate type switch"). I think the form of type switch you propose here (let's call it "parameter type switch") is clearly better for type parameters. But the approximate type switch is clearly better for "sum-types", as you'd need to unpack the value at some point - so you'd want to type-switch on a value, not a type. So, IMO
Of course, we can always eat the cost of "slightly unfortunate" and add both, when the time comes. |
One significant difference is that Arguably, the parameter type switch could "declare a new type" (shadowing the old one), scoped to the |
I think that adding approximation-elements to the regular type switch is orthogonal to this proposal. In the case of regular interface values, knowing the type of the value doesn't tell you anything about the type of any other value, but that's not the case here, where once we know about the type, we know the type of all values that use that type. That's why I chose to restrict this proposal to allow only exactly type parameter names to be specified in the switch, because then there's no ambiguity - we're further constraining the already-known type and thus we know that all arguments, variables and return parameters that use that type are constrained likewise. |
To try to be clearer, this proposed statement is a "switch on a type" rather than a "switch on the type of a value". That's why it's very different from the current type switch statement, and also why it's specifically useful in the context of parametric types. |
I think they are different, but I don't think they are orthogonal/independent. In particular, the "approximate type switch" would in terms of what you can express subsume the "parameter type switch" (or at least most of it). That is, you can write code doing the same, with the same static guarantees, using either - even if less convenient.
That is true. But this proposal doesn't talk about regular interface values, it talks about type parameters. So, if you are in a situation where you can use the "parameter type switch", you do know that two values have the same type. That is, you could write func Max[T constraints.Ordered](a, b T) T {
switch a := a.(type) {
case ~float64:
return math.Max(a, b.(~float64))
default:
if a > b {
return a
}
return b
}
} and this is statically known to be correct - even if inconvenient, by requiring an extra type-assertion, you know this type-assertion can't fail.
Yupp :) That's what I meant when I said this is clearly better for type parameters :) |
I don't think that's quite right - You'd need something like this instead:
That would become less verbose if value type assertions could be done directly on non-interface values, but still not great.
Although as a programmer, you might know that those dynamic type assertions are OK, they seem problematic to me - they're verbose and easy to get wrong (with a nasty panic if you do). |
Who knows, we are talking about a speculative design with no proposal filed :) IMO,
Probably true. This is harder to handwave away :)
That's why I said this proposal is better, as long a we're only concerned with type parameters. |
As I see it, this proposal is to add a way to say, "given a thing that might be one of possibly infinitely many types, add special handling for cases where it is one of a chosen set of types." This is already exactly what the current syntax for type switches does, for where "thing" means "value" rather than "type." Considering how similar they are, why is the proposed syntax so different? In particular, with the current generics proposal, it would be possible to implement parsing with the only new AST nodes being those needed to describe constraints (type lists for the proposal as accepted, or type set operations for #45346, both only on I would prefer if the spelling of these type switches were also |
One other option — in an earlier conversation (still in the context of type lists), Ian had floated this syntax:
The proposed semantics at that time though were not as nice as the semantics proposed here under type sets, I think. |
I agree with @Merovius that I would expect (I also note that if the constraint of the function were |
I would think that if a case were to include an approximation type (e.g. |
Yes, but to allow it, the compiler would have to know about it. It is easy to use var r io.Reader = new(bytes.Buffer)
_ = r.(*bytes.Buffer) // succeeds
var br *bytes.Buffer = (*bytes.Buffer)(r) // type-assertion succeded, so we know that we can convert r to *bytes.Buffer It's a little different, because we are talking type parameters, but it still destroys the elegant simplicity. So, I do agree with @rogpeppe that type switching on the type parameter is useful and enables you to do new things, because there is inherent logic in saying "inside the switch case, T is interpreted as being constrained to the type set in the case". I still don't think they are orthogonal though. They still both have significant overlap. |
I believe it is because of what you mention - we aren't switching on a value, but on a type. But FTR, I don't think it really matters for the substance of the proposal whether it's |
FWIW, after the discussion so far, I've come around to this. I think it's a good addition :) I still would like to allow union- and approximation elements directly (without wrapping them in |
FWIW the semantics I'd expect from an approximate type switch on a value would be that the value inside the case would allow just the same operations as if the type had been constrained by the switched type in a generic function parameter. That is, in this example, Note that being able to assert on However, switching on the type parameter itself does potentially help that case, because then the slice can be used as an argument to any function with suitable constraints. For example:
This makes me realise an interesting point here: even if one has an approximate type switch, unless you significantly add to the power of the approximation and union element features (a big ask, given their current elegance), you will still not be able to do something like assert on the underlying type of a type component such a slice element. That is, something like this would probably not be allowed, which arguably makes approximate type switches on values
|
@zephyrtronium The concrete syntax I've proposed here is only one of many possible. A syntax like |
Note that you could add union elements for completeness, but they're technically redundant with respect to this proposal because a case with multiple comma-separated elements is the same as a union element. Come to think of that: you can use multiple elements in a type switch case currently, but the result is the original interface type. That behaviour probably can't change for backward compatibility reasons, but if one allowed a union element directly, one could constrain the available operations to the intersection of operations available on all the selected types, just as with generics. That doesn't affect this proposal though. My main concern about this proposal is that it might make the compiler's job considerably harder. I'm trying to think through the potential implications there. |
That would require Note that this is completely different when we are talking about this proposal. When you change the constraints put on Agreed, to the rest of the comment.
FWIW, I think approximate type switches will be a must if we ever allow to use all interfaces as types. Up until then, they are a nice-to-have, at best (if something like this proposal gets accepted - I do strongly feel that we need some way to specialize on the type argument eventually).
This is a drawback to me. Personally, I think I would consider to disallow multiple cases in this type switch construct. It seems we need to choose whether we'd rather be consistent with other switch constructs or be consistent with the constraint syntax. Unfortunate.
Can you give an example? I'm not sure what would make it harder. Conceptually, each |
Getting a bit off-topic over an example, but I don't follow your argument here. You can do this: var r io.Reader = new(bytes.Buffer)
switch r := r.(type) {
case *bytes.Buffer:
var br *bytes.Buffer = r
} which much closer resembles the example under discussion. The question is the operations (specifically conversions) legal on a parameterized type within a case of a type switch that effectively redefines the type parameter's constraint. Now, there is a bit of a difference here in that I use Perhaps this does relate to @rogpeppe's comment while I was typing this:
And, perhaps this line leads me to conclude that |
Noted. To me, there is a major difference between proposing an extension of semantics for existing syntax to analogous behavior, and proposing new syntax when an analogous one already exists. But perhaps I am a bit too early to this complaint. |
I can definitely see some trickiness with this idea.
I think it is cleaner if you introduce a new type-parameter-like-identifier, like:
|
Because the original type still carries semantic weight and could be useful. A type switch tells you what the dynamic type of the value is; it doesn't convert it to some other type. For example, I'd expect the following code to print "MyFloat", not "float64":
|
In this proposal, you can. Within that case, The important point is that the specialisation affects all variables in scope that use type T. That is, we're not creating a new |
Ah, ok, so we could think of these switches as happening at the top level. We put them syntactically lower down in the AST just to share all the code outside the switch. |
Yes, you could look at it that way, although I'm not sure how helpful that is. The way I look at it is that within the case statement, you get a more precise view of what |
ISTM that the confusion is that you are talking about this proposal (reasonably so) while I was discussing a different idea - namely allowing type switches/assertions on values to assert on the underlying type. And I was discussing that idea to compare its expressive power to the one proposed by @rogpeppe. Everything you say is true, under this proposal. |
Without entering in the details of your future talk and for the sake of this technical problem that really needs solving, why is it difficult to check whether a go type satisfies another? We do that all the time? Note: There are algorithms for that already. Is the issue the number of overlapping cases? Which shouldn't be an issue in practice if we really have compiler enforced disjoint cases? (the programmer needs to provide the intersection case when there is one being detected) |
I agree that it needs solving, but I disagree that it needs solving in the next two months. As I said, I don't think we should re-hash the same conversation we had then. Feel free to assume these problems are easy, against my recommendation. I don't believe I will convince you otherwise. |
Ok fine. A technical discussion postponed only for the sake of self-promotion. That's a bit of a pity. I wouldn't do that. :) |
@atdiar The offer I made you over a year ago still stands: If you write up a detailed proposal of how to allow methods in unions, I'll be happy to look it over. |
FWIW as long as the compiler is not required to answer any of these questions (or questions of that kind), there probably is no future problem:
This class of question is hard to answer, in general, and very strongly interacts with the exact constraints we allow. I believe any of them can be relatively easily translated into a reduction-proof for CNFSAT, with even a relatively simple constraint language (as long as it allows methods in unions - currently, we disallow them, which enables us to answer these questions). Under that assumption, the proposal in the top-post creates problems, for example. Because it allows this code: func F[T any](v T) string {
switch type T {
case interface{~string}, fmt.Stringer:
// Here, T is constrained on `interface{ ~string | fmt.Stringer }`,
// which is not a constraint we currently allow.
//
// That requires answering "does interface{ ~string | fmt.Stringer } imply C?",
// to type-check this call:
G[T](v)
}
}
func G[T C](v T) { /* … */ } // with some constraint C Obviously, any proposal requiring the compiler to check that such a type-check is exhaustive would immediately ask one of those difficult questions. In my opinion, we have to be content with simply checking each type in-order, as with regular (type-)switches. Any proposal allowing overloaded functions or methods (i.e. having multiple functions/methods with the same name, differing only in constraints) is extremely likely going to have to ask one of those questions, to decide what actual function to call for a given type argument (that is how this program breaks clang). If we allow Refined Method Constraints, I believe we still have to require there to only be one method per name (though as I've shown, that should be enough). The most general proposal I can see right now is what the top-post proposes, with the added restriction that
(matching the current restriction on union elements). In particular
With that, the compiler only ever has to answer the question "does a given type argument satisfy a specific (refined) constraint". That question is easy to answer and will always remain easy to answer by construction. I think we might still consider weakening the power of the proposal anyways. The reason is that if we want to be able to have methods in unions, we might still want to limit the actual set of interfaces you can express (for example by limiting their nesting depth or similar limitations). If we do that, the implicit interfaces created by In short, no version of this proposal really makes me entirely confident about the prospects of allowing methods in unions. But there is a version of this proposal I feel confident we could do right now - if we accept that it might create friction in some cases later. Personally, I'd be unhappy to make that commitment without knowing what exactly we are talking about. Alternatively, we could commit to never allow methods in unions. In that case, with the restriction above, this proposal seems fine to me. Personally, I'm not particularly happy making this commitment - especially if we at some point want to use general interfaces as union types. Lastly, we could commit to ignoring the problem - either we describe a heuristic to solve the resulting CNFSAT problems in the spec, or we leave it up to the implementation to perhaps time out, or we leave it up to the implementation to impose limits… Personally, I'd be unhappy with this as well. All of these are, of course, opinions. I've thought about these problems quite a bit, but take these opinions as you like. |
@atdiar That last comment is not appropriate in this forum. Please be constructive. Thanks. |
@ianlancetaylor my apologies. @Merovius a bit more than a year ago I had written a proposal in which I detailed one way to proceed with entailment (what you called an interface implying another) and it is on hold. At the time, you had parsed it (we had had a little discussion off github). You're right that depending on the constraint available in the language, the problem is more or less tractable. In the present case, unless major upset, it should be tractable because unlike in the refinement type case, the set of expressible type of constraints is bounded for the compiler. It's easy to make the equivalence between a type and the set of constraints it denotes. Then entailment is simply set inclusion. |
Are you refering to #53734? [edit] Well, I read through that and you don't actually talk about removing the restriction for methods in unions over there, so I'll comment here anyways. You have recognized the problem with that proposal yourself:
You are correct that you are describing an expansion into DNF. You are correct that this requires exponential runtime in general. You are correct that in the current language, that is not a big problem, because constraints end up being mutually exclusive (in all but a finite number of cases). But if we where to allow methods in union elements, that would of course no longer be true. You'd have to expand the actual full DNF. So what you propose would indeed take exponential time to check. Because you are proposing a brute-force solution to an NP-complete problem. [/edit] |
I want something like this but the part where the meaning of the type parameter implicitly changes in the block never quite sat right with me. My preference would be for something a little more explicit:
For example, the first switch switches on the parameter T and defines the result to be the new type parameter S which can be used: var x T
switch type S T {
case ~int | ~uint:
y := S(x)
x = T(y)
case fmt.Stringer, ~float64:
// S undefined as it could match either constraint
}
switch type T {
case int:
// we know T is an int
// but no new type parameter declared
// so we can't really do much about it
} This is roughly equivalent to allowing all constraints to be interface types var x T
switch y := any(x).(type) {
case ~int | ~uint:
case fmt.Stringer, ~float64:
}
switch any(x).(type) {
case int:
} While I also think interface types should be generalized, the parameter type switch avoids having to smuggle everything through interfaces and allows conversions and more compile-time type checking. |
@jimmyfrasche I don't think I understand why you'd prefer that. It seems like more typing and syntax without more power, to me. What's the benefit of having to have a second type parameter and having to explicitly convert? |
|
@jimmyfrasche
If so, perhaps you could share your reasoning. If not, how would you accomplish a similar task? |
No. I don't think anything like that should be allowed, though. It would be too magical. I suppose you could use unsafe quite safely in this case, if you absolutely needed the performance boost. |
Like @Merovius , does seem a bit more confusing to me. In your initial example, is If so, should the type parameter really be undefined? |
It's different. The case is hit for either of those constraints. If you had |
That part is interesting, that's something to ponder. The current limitation might also be lifted at some point but that can indeed be something to keep in mind for later consideration. |
I would love this feature, but think the syntax should be more consistent with the interface type assertion syntax, using symbol characters rather than a keyword since the So instead of I would specifically prefer this not just for consistency with type assertion but more importantly because I think it would be easier (at least for me) to recognize this when scanning code vs. if we use keywords. Modifying @jimmyfrasche's earlier example: var x T
switch [S T] {
case ~int | ~uint:
y := S(x)
x = T(y)h
case fmt.Stringer, ~float64:
// S undefined as it could match either constraint
}
switch [T] {
case int:
// we know T is an int
// but no new type parameter declared
// so we can't really do much about it
} Of course I am doing a bit of bikeshedding here, so maybe just upvote or downvote this idea to indicate sentiment? |
@rogpeppe For the record, this does not compile today: func F[T byte](list []T) int {
// cannot convert list (variable of type []T) to type []byte
_ = []byte(list)
// cannot use list (variable of type []T) as []byte value in argument to bytes.Index
return bytes.Index(list, []byte("."))
} It seems you can't convert a generic slice to a concrete one, even if the type parameter is constrained by the concrete type in question. Same goes for converting a |
While the title of this issue is constraining us to think in terms of a switch, could there be room to explore other ways to specialize behavior for specific types or families of types? My feeling is type parameter switches will lead to code that is very difficult to test for all the types that it can/will be used for. I would favor a technique that explores a form of overloading for generic function specialization. For example, given this generic function: func PrettyPrint[T any](v T) string {
return fmt.Sprintf("%v", v)
} Example of specialization of the function, just for the float64 type: func PrettyPrint[T = float64](v T) string {
return fmt.Sprintf("%.2f", math.Abs(v))
} Or this syntax: func PrettyPrint[](v float64) string {
return fmt.Sprintf("%.2f", math.Abs(v))
} |
Overloading is a pretty fraught topic. In particular, what should happen if a type satisfies the constraints of multiple functions? I'll note that this is exactly what makes C++ concepts NP-complete: It allows overloading of functions and, if the constraints of multiple functions match a type, chooses "the most specific one". Determining that is an NP-complete problem. This wouldn't be a problem for Go yet (right now, checking which constraint is "more specific" can probably be implemented efficiently), but it definitely is a road that should be chosen with care. I'll also note that overloading is the one form of polymorphism Go has so far eschewed. Personally, I still favor the original proposal here (with the exception of the I'm not sure having access to the "outer" type really matters. Especially given that the type argument is the same - so the dynamic type of interfaces or what expressions evaluate to don't change - and the type parameter in the switch case will have stronger constraints than the one on the function. So ISTM anything that's possible with the "outer" one should also be possible with the "inner" one. The original type switch just seems the simpler syntax and - to me, at least - the simpler semantics as well. I find it significantly easier to understand than having to introduce an entirely new type parameter. YMMV, of course. |
@jimmyfrasche wrote in reply to this comment:
FWIW there's no need for unsafe for that case, even now: https://go.dev/play/p/n0yqq6EGPfy This is precisely the kind of case that this proposal is designed for: it's common to have several For the record, this is what it would look like using this proposal:
@arvidfm wrote:
But this does (and this is what this proposal is aimed at replacing):
|
I am once again finding myself daydreaming about this proposal as I'm playing around with the new experimental function iterators in 1.22. I wanted to write a function that takes any kind of iterable type and spits out an type Iterable[V any] interface {
~func(func(V) bool) | ~[]V
}
func AsSeq[V any, VI Iterable[V]](it VI) iter.Seq[V] {
switch type VI {
case ~func(func(V) bool):
return iter.Seq[V](it)
case ~[]V:
return func(yield func(T) bool) {
for _, v := range it {
if !yield(v) {
return
}
}
}
}
} which could then be used as: func Map[T, U any, TS Iterable[T]](it TS, f func(T) U) iter.Seq[U] {
return func(yield func(U) bool) {
seq := AsSeq[T](it)
seq(func(t T) bool {
return yield(f(t))
})
}
} This would have made for a really nice and ergonomic API, where users could pass in any kind of iterable value to Maybe someone smarter than me can think of a solution, but for the life of me I can't come up with an implementation that doesn't impose major constraints, like forcing the user to convert to The best I've managed to come up with is this reflection-based monstrosity: func AsSeq[V any, VS Iterable[V]](it VS) iter.Seq[V] {
switch vi := any(it).(type) {
case iter.Seq[V]:
return vi
case []V:
return func(yield func(V) bool) {
for _, v := range vi {
if !yield(v) {
return
}
}
}
}
// VS is a derived type, fall back to reflection
v := reflect.ValueOf(it)
return func(yield func(V) bool) {
switch v.Kind() {
case reflect.Func:
v.Call([]reflect.Value{reflect.ValueOf(yield)})
case reflect.Slice:
length := v.Len()
for i := 0; i < length; i++ {
if !yield(v.Index(i).Interface().(V)) {
return
}
}
default:
panic("unexpected type")
}
}
} |
In the discussion for #45346, this comment mentioned the possibility of adding a type switch on parametric types. Given that it's threatening to derail the discussion there, I'm creating this proposal to talk about it.
Proposal: type switching on type parameters
I propose that a new statement be added to the language which allows a function with type parameters to further constrain its type parameters:
The switched type (
T
above) must be the name of a type parameter. The type in a case arm can be any type, including constraint types.The first case is chosen that has a constraint that matches the type parameter.
Within a chosen arm of the case statement, the type parameter that's being switched on becomes further restricted by the type selected, just as if it had originally been constrained by that type too. Precisely, given a switched type
T
constrained byC
, and a type in the switch armA
, within the code of the switch arm,T
takes on the type:If there are multiple cases mentioned in the arm (
A1
,A2
, ...An
), thenT
is constrained by using a union element:Example
The above assumes that the constraint:
would allow all operations allowed on
X
, but I believe that's true under #45346 proposal.Note: I don't think there's any need to allow
~
to be used directly in these type switch cases -case interface {~T}:
is sufficient if necessary.The text was updated successfully, but these errors were encountered: