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. |
There isn't/shouldn't. That's more obvious an issue with Edit: Oh wait... Yes there is a restriction on basic interfaces in unions... At least for now. |
I don't understand what you mean. This seems simply untrue. Can you provide an example of code where they would behave differently under this proposal?
Note that the proposal as written deals with this perfectly fine. It just makes exhaustiveness checks impossible and implies that it's an even worse idea not to match cases in source order, as there are more overlaps. |
To be clear: This proposal it means that switch T {
case int: // …
case ~int: // …
}
// and
switch T {
case ~int: // …
case int: // …
} Behave differently. But they behave the same regardless of whether |
Well that was the point (admittedly not well expressed). Cases are likely to be written in source order of the terms of the union, so somehow the order in the switch is quite likely to match that. Done naively, overlaps would remain unhandled a bit too easily. If decomposition was enforced, there would be much less chance for a switch (or constraint) modification to change the whole program. To be clear, all branches will still be visited in source code order but semantics won't rely on the source code order if cases are dealt with exhaustively. Much safer then to add a union term to a constraint for instance. |
It is frustrating, after three rounds of back-and-forth, to find out that it was all a dud, because you did not actually mean that the two interfaces are treated differently (which would be confusing and bad) you meant that two switch statements are treated differently (which is expected and good). In any case: IMO the principle of least surprise is to behave as other switch statements already do. I disagree that there is any reasonable chance of confusion here. People do not get confused that any of these differ either: switch x.(type) {
case io.Reader:
case *bufio.Reader:
}
// vs.
switch x.(type) {
case *bufio.Reader:
case io.Reader:
}
// or
switch {
case x < 10:
case x < 5:
}
// vs.
switch {
case x < 5:
case x < 10:
} And so on. The "the first matched case is executed" semantic is easy to explain, understand and intuit. No matter in what order the original interface mentioned the cases. You'd be treating a non-existing problem with a caustic solution, by forcing people to write unreadable code: switch T {
case fmt.Stringer:
return v.String()
case ~string:
return string(v)
default:
return fmt.Sprint(v)
}
// vs.
switch T {
case ~string:
switch T {
case fmt.Stringer:
return v.String()
default:
return string(v)
}
default:
switch T {
case fmt.Stringer:
return v.String()
default:
return fmt.Sprint(v)
}
} So, no. I don't believe that's a good change to the proposal. |
Well, sorry to hear that you're frustrated. The proposal does mention that adding a case is equivalent to constraining the type parameter. So case order seems to be reflected at the constraint level, at least for the writer of such code. My point is that the order in which to write the cases is not necessarily obvious when cases cannot be ordered in terms of decreasing specificity. The problem is not exactly with source order execution. My issue is case satisfaction ambiguity. We could eschew the problem with enforced term decomposition. Much less error prone. Wrt readability, that's a syntactic issue most likely. Presumably, something akin to switch i:=v.(type) {
case ~string:
case fmt.Stringer:
return i.String() // we could also decide to do something else here
default:
return string(i)
case fmt.Stringer:
// bonus: we know that it is not a ~string in that branch
return i.String()
}
// vs what you propose which does not handle every case
switch T {
case fmt.Stringer:
return v.String()
case ~string:
return string(v)
default:
return fmt.Sprint(v)
} Admittedly, that's quite more of a concern if we ever want to lift the restriction on basic interfaces as union terms. That's just a question that I think could be considered though. |
@atdiar It is not at all clear what problem you are seeing. Can you express it as a short code example, with your expectation vs. reality? [EDIT: above post now has code example]
Would that not lend itself to backward compatibility problems, i.e. adding another "supported" type to an existing union would immediately break existing code. That might block useful future stdlib changes. A vet warning might be nice, though, but I fear it might not be trivial to implement. The trouble is, if it is too noisy, everyone is going to end up covering their Of course, the fact that you could cover with a My vote (for what it is worth) is no, purely because of the irritation of having to write out every possible case vs. the benefit of doing so. For me:
...should not have to become...
This surely always has to eventually boil down to a if T is some-type then ... else if T is other-type ... else ... decision, and something has to be checked first. Evaluating (I suppose
You are almost certainly going to have to clarify "term decomposition", because it reads like you want
...and, if you mean that, then I'd like to propose we broaden this proposal to replacing |
@atdiar Regarding...
...I would be very concerned if indentation was a deciding factor in the nesting of If you want to be able to nest decisions on types, you would surely have to do:
Of course, it may then hold that Instead, I think the straightforward way to get this over the line would be for...
...and that any extra interfaces are sadly lost (to You could (should) still be able to do...
...if you had to -- you would just have to juggle your use of A terser syntax, such as |
@jimbobmcgee switch T {
case fmt.Stringer:
return v.String()
case ~string:
return string(v)
} The case of the type parameter satisfying both On the other hand, I'm now a little more circonspect about adding new cases to a switch statement appart from in last position. (backward compatibility issues) Ack about indentation. This is a mistake on my part as I was merely thinking about readability improvements. I'm less convinced that it is necessary now. Unless we want to make reeaally safe the addition of new cases to a type parameter switch, regardless of the case position and/or force the coder to deal with all cases of overlapping type sets. I'm also thinking about this issue with union/sum types in mind where I don't think we can have a default clause. I need to think a bit more about it. |
That code is incorrect, under your proposal.
It does. That's exactly the point. It handles every case, exactly as is the natural way to do and as it is intended - it always calls the So, you are re-interpreting the requirements, you then write code which side-steps the problem with your suggestion my example was pointing out (by being incorrect under your proposed rules) and the result still uses a nested switch and is thus significantly harder to read and write. So, no, I remain unconvinced. |
@Merovius when I said, it didn't handle all cases, I meant that My point was that we would have exhaustiveness checking so there wouldn't be a need for the default clause. That depends on the constraint of the type parameter. Anyway, something that needs to be ponder on. |
@atdiar I understand what you meant, but what you meant is wrong. |
I'm not sure you do. Point is that if we want to be exhaustive, embedding switches are needed. |
That is not the code I did want to write. I agree that if you want to do a different thing, you need to write different code, perhaps using more nesting. But the point of the example was that under your proposed rules, you have to write the nested switches even if you want to do the simple thing. Because you always have to explicitly list (and nest) all possible combinations of overlap, which quickly leads into combinatoric explosions. To illustrate, here is how listing all combinations looks for your proposal: switch T { // side-note: the suggested syntax is to switch on the type parameter
case ~string:
switch T {
case fmt.Stringer:
// both fmt.Stringer and ~string
default:
// ~string, but not fmt.Stringer
}
default:
switch T {
case fmt.Stringer:
// fmt.Stringer, but not ~string
default:
// neither fmt.Stringer, nor ~string
} Here's how it looks under the original proposal: switch T {
case fmt.Stringer:
switch T {
case ~string:
// both fmt.Stringer and ~string
default:
// fmt.Stringer, but not ~string
}
case ~string:
// ~string, but not fmt.Stringer
default:
// neither fmt.Stringer, nor ~string
} (edit: had this first, then edited to use Not nice, but strictly nicer than your proposal. That's not a coincidence. It's a consequence of a problem with your proposal. Feel free to try and come up with an example where your proposal requires less nesting, but I believe you'll have trouble. Again, I chose the examples deliberately, to demonstrate a point. Please try to understand and address that point. |
@atdiar ...
If the If you don't own that union (i.e. it is in another package or in the stdlib, such as A If you take "exhaustive" as "any possible combination", does that not become prohibitively difficult to compute? It feels like one of those P=NP problems, but I'm not a CS guy, so maybe I'm worrying about nothing.
Still not sold on needing a nested That said, on the subject of nesting, I do see the potential need to open up type-switching to more than
I think you can always have the var x interface{}
switch x := x.(type) {
case int:
// shadowed x is int
default:
// shadowed x is interface{}
} ...you'd just end up with the un-eliminated type T... switch T {
case int:
var x T // is now an int
default:
var x T // is still T
} I'd need to see an example of a union/sum type that could not Some of this may depend on whether we are talking about |
FTR, I don't think requiring exhaustiveness checks works anyways (edit: just saw that @jimbobmcgee mentioned that possibility, and yes, indeed). Checking that a type parameter switch covers all cases is a similar problem to calculating whether a type set is empty. So at the very least, we'd have to introduce restrictions to make this problem tractable, e.g. disallowing using It just so happens that I also think it would be a bad idea, even if it was computationally feasible. |
@Merovius
I am pondering on this exhaustiveness issue, only in the case of type parameters constrained by a union (possible behavior extended to sum/union types). I do tend to think that it might be too problematic wrt backward compatibility. The one issue I see however is backward-compatibility, would someone attempt to add a term to a union that is being type switched over somewhere arbitrary. So I think that exhaustiveness is not necessarily a good idea to be enforced at the compiler level after all. |
@jimbobmcgee |
@atdiar I had to revert an edit of that example back, because I forgot that And I want to re-invite you to come up with an example where your proposed restriction (where it feasible) would require less nesting than the proposal as written. Don't just make statements like "You would have to define many more new constraint and think about ordering if that is even possible", come up with an actual example. |
@Merovius why would require less nesting...? That's not really what I claimed. I said that nesting would be required. But regardless, I don't think that exhaustive checking mixes too well with backward-compatibility. Unless a constraint cannot be changed once defined. @jimbobmcgee well yes, you can default, and that's the escape hatch that I was hinting at, out of exhaustiveness. |
@atdiar To be clear: My original point was, that your proposed rule would make code harder to read and write, because it requires to split up cases into more and more deeply nested switch cases. Do you agree? Because while you say you agree that nesting is needed, you make it sound like "the original proposal also requires nesting for complex cases" makes up for that deficiency, which I find deeply disagreeable. FWIW the backwards compatibility problem does not exist, I believe. If the constraint is not exported, all code switching over a type parameter constrained on it will be in the same package, so can be changed in the same step. If, OTOH, it is exported, adding a new case to it is already a backwards incompatible change, similar to removing a method from a basic interface: package a
type C interface { ~int }
// --
package b
func F[T ~int]() {}
func G[T a.C]() {
F[T]()
} Adding a new case to |
I'm not interested in bickering back and forth at this point, sorry. [edit] And to be fair, if I'm not mistaken, I think you had a good hunch but unfortunately didn't develop it for
where a,b,c are constraints. (case with the same number of embeddings, symbolized here by No need for a default case here. In a real union, a lot of cases would even disappear. Otherwise, I am confused when you claim that exhaustiveness is similar a problem to checking that type sets are empty which is solving for SAT I believe (unless I am misinterpreting,) |
In passing, for the record: adding a term to a union interface can be backward-incompatible by itself. Consider, for example, adding an additional term (say
|
@rogpeppe That's basically the same example I mentioned here, FWIW. |
My understanding of our suggestion was that you specifically wanted to forbid having cases with non-empty intersection. I thought that was the whole point of your suggestion. Either way, you are not refuting what I said, because that's exactly the "combinatorial explosion" I was talking about. Note how that requires an exponential number of switch cases in the number of union-terms.
Exponential space is strictly worse than exponential time. Because the compiler has to parse the source code, which takes at least linear time in its length. So compiling a program that takes exponential space, necessarily takes exponential time as well (but also, well, exponential space). |
It sounds like maybe we want this, but we don't have the bandwidth to work out the right answer right now. Putting on hold until we have a clearer answer and/or more time. |
Placed on hold. |
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: