https://github.com/golang/go/issues/45380
In the discussion for https://github.com/golang/go/issues/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.
I propose that a new statement be added to the language which allows a function with type parameters to further constrain its type parameters:
switch type T {
case A1:
case A2, A3:
...
}
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 by C, and a type in the switch arm A, within the code of the switch arm, T takes on the type:
interface{
C
A
}
If there are multiple cases mentioned in the arm (A1, A2, ... An), then T is constrained by using a union element:
interface{
C
A1 | A2 | ... | An
}
type Stringish interface {
string | fmt.Stringer
}
func Concat[S Stringish](x []S) string {
switch type S {
case string:
// S is constrained by interface {Stringish; string} which is the same as
// interface{string} which is the same as string, so we can use x
// as a normal []string slice.
return strings.Join(x, "")
case fmt.Stringer:
// S is constrained by interface {Stringish; Stringer}
// which is the same as `Stringer`, so we can call
// its `String` method but we can't use x directly as
// []fmt.Stringer because it might have any layout
// or size.
var buf strings.Builder
for _, s := range x {
buf.WriteString(s.String())
}
return buf.String()
}
}
```
The above assumes that the constraint:
interface {
X | Y | Z
X
}
```
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.
Replying to @candlerb https://github.com/golang/go/issues/45346#issuecomment-813000803
```
func SortT Lessable[T] {
var less func(a, b T) bool
switch type T {
case Ordered:
less = func(a, b T) bool { return a < b }
case Lesser:
less = func(a, b T) bool { return a.Less(b) }
}// sort using less
}
```I can't see how the two branches of the "switch" can be compiled in the same function instance. For a given T, only one branch or the other is semantically valid.
Isn't this very similar to what happens with a switch x := x.(type) statement currently?
Let's assume for the sake of argument that two versions of Sort with different type parameters can be compiled into the same code (it might do this when both types have the same GC layout).
Then the type switch statement could look at the metadata for the type and "unlock" other operations on the type, just as a type switch does for interface values in current Go - there's no particular reason that Sort would need to be split into two versions just because of the type switch. Splitting is of course still a valid implementation strategy and would result in more efficient code, at the usual cost of potential code bloat.
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.
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 cases can more directly reflect what's in the constraint, that is, I think
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.
Isn't this very similar to what happens with a
switch x := x.(type)statement currently?
One significant difference is that switch x := x.(type) visibly declares a new variable (shadowing the old one), scoped to the ~~switch~~ case block.
Arguably, the parameter type switch could "declare a new type" (shadowing the old one), scoped to the ~~switch~~ case block. But it's still arguably more implicit and "magic".
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 atype.
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 that adding approximation-elements to the regular type switch is orthogonal to this proposal.
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.
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
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.
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.
~~I don't see how case ~int supposedly adds ambiguity, where interface{ ~int } doesn't. One is simply syntactic sugar for the other.~~ Sorry, I think I misunderstood what you where saying, disregard this.
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.
Yupp :) That's what I meant when I said this is clearly better for type parameters :)
I think that adding approximation-elements to the regular type switch is orthogonal to this proposal.
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.
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
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
go 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 } }
I don't think that's quite right - ~float64 can't be assigned to float64 without an explicit type conversion, and neither could the float64 returned by math.Max be assigned to the return parameter.
You'd need something like this instead:
func Max[T constraints.Ordered](a, b T) T {
switch a := a.(type) {
case ~float64:
return interface{}(math.Max(float64(a), float64(interface{}(b).(~float64)))).(T)
default:
if a > b {
return a
}
return b
}
}
That would become less verbose if value type assertions could be done directly on non-interface values, but still not great.
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.
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).
I don't think that's quite right -
~float64can't be assigned tofloat64without an explicit type conversion
Who knows, we are talking about a speculative design with no proposal filed :) IMO, x.(~float64) should evaluate to float64 exactly and if ~float64 is used as a case, a should have type float64. But either way, that doesn't matter a lot.
neither could the float64 returned by
math.Maxbe assigned to the return parameter.
Probably true. This is harder to handwave away :)
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).
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 interface declarations). Depending on how the implementation of type parameters is handled, static analysis of type-checked programs could be done with only the same new node; tools that only analyze function bodies might not need to be updated at all. How is the cost of an entirely new syntactic construct to every Go code parser and to every static analysis tool using them justified?
I would prefer if the spelling of these type switches were also switch T.(type).
I would prefer if the spelling of these type switches were also switch T.(type).
One other option — in an earlier conversation (still in the context of type lists), Ian had floated this syntax:
func F[T constraints.Integer]() {
switch T {
case int:
case int8:
}
}
The proposed semantics at that time though were not as nice as the semantics proposed here under type sets, I think.
I don't think that's quite right -
~float64can't be assigned tofloat64without an explicit type conversion, and neither could thefloat64returned bymath.Maxbe assigned to the return parameter.You'd need something like this instead:
func Max[T constraints.Ordered](a, b T) T { switch a := a.(type) { case ~float64: return interface{}(math.Max(float64(a), float64(interface{}(b).(~float64)))).(T) default: if a > b { return a } return b } }That would become less verbose if value type assertions could be done directly on non-interface values, but still not great.
I agree with @Merovius that I would expect case ~float64: to cause T to become float64 within the case, but my reason is specifically that, as proposed in #45346, ~float64 is not a type. There are no values of type ~float64, only of types whose underlying type is float64. Aside from your comment, none of the suggestions in this thread seem to suggest that behavior should change.
(I also note that if the constraint of the function were ~float64, and hence the constraint applied in the type switch case were the same, then T and float64 should be convertible to each other, so it should be fine under even the most conservative type checking of this proposal to return T(math.Max(float64(a), float64(b))).)
I would think that if a case were to include an approximation type (e.g. ~float64), then the compiler should be able to deduce that within the branch T would be a type whose underlying type is float64, and should thus be able to convert a float64 back to T. If that is really the case, then restricting such a switch to only non-approximation types sounds like an unnecessary restriction.
I also note that if the constraint of the function were
~float64, and hence the constraint applied in the type switch case were the same, thenTandfloat64should be convertible to each other
Yes, but to allow it, the compiler would have to know about it. It is easy to use v.(~float64) as a float64, because neither v itself, nor it's type, actually change - you look at a new variable with a new type. Meanwhile, if you'd want to convert T(v) after that, the compiler would have to take into account that the type-assertion before succeeded. That's not how Go works so far - for example, you can't do
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.
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?
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 switch T, switch type T or switch T.(type) - so maybe we shouldn't worry about the color of our bikeshed just yet :)
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 interface) though and I also think that this shouldn't prevent us from adding type switches/assertions using approximation elements either :)
I don't think that's quite right -
~float64can't be assigned tofloat64without an explicit type conversionWho knows, we are talking about a speculative design with no proposal filed :) IMO,
x.(~float64)should evaluate tofloat64exactly and if~float64is used as a case,ashould have typefloat64.
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, case ~float64 would allow only operations allowed by every type with underlying type float64, and since you can't assign a value with a defined float64 type to a float64 without an explicit type conversion you wouldn't be able to call Max, hence the need for an explicit type conversion in my code snippet.
Note that being able to assert on ~float64 doesn't help when you're wanting to operate on a slice or other higher level type though, because the constraint syntax doesn't allow interface{[]~float64} AFAICS.
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:
func MaxVec[F comparable](xs []F) F {
switch F {
case interface{~float64 | ~float32}:
// This is OK because F is here constrained by interface{comparable; ~float64 | ~float32}.
return MaxFloats(xs)
default:
...
}
}
func MaxFloatVec[F interface {~float64 | ~float32}](xs []F} F {
...
}
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
considerably less interesting as a candidate feature for the language.
func X(x interface{}) {
switch x := x.(type) {
case []~float64:
...
}
}
@zephyrtronium The concrete syntax I've proposed here is only one of many possible. A syntax like switch T { would work just as well; in fact I think I might prefer that, although it arguably gives less clues to the reader of the code about what's going on.
I still would like to allow union- and approximation elements directly
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.
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 would require ~float64 to be a type though. That is, with v := v.(~float64), v needs to have some type. IMO float64 is the most natural and most useful type here. Why would I not want it to be a float64? Except avoiding having to learn "v.(~T) evaluates to a T"?
Note that this is completely different when we are talking about this proposal. When you change the constraints put on T inside the case block, you can obviously do more things with it, because you can have many values of that type and be sure they are the same type.
Agreed, to the rest of the comment.
That is, something like this would probably not be allowed, which arguably makes approximate type switches on values
considerably less interesting as a candidate feature for the language.
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).
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.
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.
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.
Can you give an example? I'm not sure what would make it harder. Conceptually, each case block is just an anonymous generic function with a more constrained type parameter. ISTM that if we can type-check a generic function, we should already be able to type-check this construct as well?
I also note that if the constraint of the function were
~float64, and hence the constraint applied in the type switch case were the same, thenTandfloat64should be convertible to each otherYes, but to allow it, the compiler would have to know about it. It is easy to use
v.(~float64)as afloat64, because neithervitself, nor it's type, actually change - you look at a new variable with a new type. Meanwhile, if you'd want to convertT(v)after that, the compiler would have to take into account that the type-assertion before succeeded. That's not how Go works so far - for example, you can't do
go 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
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 r := r.(type) whereas the switch on a parameterized type does not. You can't assign var br *bytes.Buffer = r without shadowing, of course, because the r remains type io.Reader. However, the difference is that r is a value with an interface type, whereas T in the original example is a constraint – not even a type. Per the proposal, within the switch case, the latter is defined to operate as if the function's constraint type set were originally the intersection of the actual constraint and the constraint used in the case. The only types in that intersection are convertible to float64 and float64 is convertible to any type in it, so by the behavior specified in the accepted proposal, conversions between those types are allowed.
Perhaps this does relate to @rogpeppe's comment while I was typing this:
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.
And, perhaps this line leads me to conclude that switch T.(type) is not quite the correct syntax, either, because this sort of type constraint switch effectively includes a redeclaration, which arguably should not be implicit.
@zephyrtronium The concrete syntax I've proposed here is only one of many possible. A syntax like
switch T {would work just as well; in fact I think I might prefer that, although it arguably gives less clues to the reader of the code about what's going on.
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.
func f[T any](a T) {
switch T {
case float64:
var x T // x is a float64
x = a // both are type "T", why can't I assign them?
}
}
I think it is cleaner if you introduce a new type-parameter-like-identifier, like:
func f[T any](a T) {
switch U specializing T { // or some other syntax
case float64:
var x U // x is a float64
x = a // now it is clear this is not allowed, as it is assigning a T to a U.
}
}
That would require
~float64to be a type though. That is, withv := v.(~float64),vneeds to have some type. IMOfloat64is the most natural and most useful type here. Why would I not want it to be afloat64?
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":
type MyFloat float64
func P[F any](f F) {
switch type F {
case ~float64:
fmt.Printf("%T\n", f)
default:
fmt.Printf("%T\n", f)
}
}
func main() {
P(MyFloat(64))
}
I can definitely see some trickiness with this idea.
```
func fT any {
switch T {
case float64:
var x T // x is a float64
x = a // both are type "T", why can't I assign them?
In this proposal, you can. Within that case, T is known to be exactly float64 as if with a type alias.
So both x and a are of both type float64 and T.
The important point is that the specialisation affects all variables in scope that use type T.
It's OK to do that because, unlike regular interface values, there's no special GC shape associated with T, so we can specialise without needing to create a new value.
That is, we're not creating a new T scoped to the case - we are genuinely specialising the original T for the extent of that case.
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.
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 T happens to be, so you can do more specific operations on values defined in terms of T. If the generated code isn't fully expanded out for every possible type, the switch operation may well involve some runtime cost (not at the top level) to look up the relevant dictionary of available operations, much as the usual type switch does with the method dictionary.
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.
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.
I completely missed that! My mistake. I've reread those comments now and everything makes much more sense.
Because the original type still carries semantic weight and could be useful.
You can still type assert on a specific type. The dynamic type of v is not lost by type asserting it to a different one - only the new value loses it. That is, if you know the original type and want to use it, you can still write v.(MyFloat) to get a MyFloat. We just add the capability of using an interface value even if you don't know the type (but only know the underlying type).
A type switch tells you what the dynamic type of the value is; it doesn't convert it to some other type.
Potato, tomato. Note that a type-assertion on an interface does convert, though (it converts the dynamic value to the interface type being asserted). And that conversion is often far less trivial than converting to the underlying type.
For example, I'd expect the following code to print "MyFloat", not "float64":
I agree. But (and that is my fault for encroaching on this issue) you are using your proposal, not the idea of purely adding type assertions on approximation elements. I agree that for a type switch on a type parameter, the parameter should get refined constraints and not be implicitly assumed to be float64 (or whatever). But that's separate from the question of what type v.(~float64) should have where we to introduce that (which is not this proposal).
So the analogous question would be "what does fmt.Printf("%T", v.(~float64)) print, if v contains a type MyFloat float64"? Personally, I find the answer "it should print MyFloat reasonable, but ultimately pointless for little benefit and making the design more complicated than needed. "v.(~float64) evaluates to a float64" is IMO easy to understand, easy to implement and easy to explain. But if the above prints MyFloat, it requires a bunch of extra changes to the language - we need to introduce a new type ~float64, which behaves like an interface (in that it carries a dynamic value), but also allows most operations of float64, but is neither float64 nor MyFloat (as MyFloat isn't even known, statically).
Again, to be clear: With a type switch on a type parameter these questions are easily answered, because var v T still has a specific, concrete and known (theoretically) static type and we are not actually changing that, we just assert that the operations on it are allowed.
Anyway, mayhaps it's just wrong to discuss this here :) But I'm resolved to not file any proposals for extending generics myself, until we actually get generics (and if I would, I would start elsewhere) :)
Because the original type still carries semantic weight and could be useful.
You can still type assert on a specific type. The dynamic type of
vis not lost by type asserting it to a different one - only the new value loses it. That is, if you know the original type and want to use it, you can still writev.(MyFloat)to get aMyFloat. We just add the capability of using an interface value even if you don't know the type (but only know the underlying type).
Fair point.
A type switch tells you what the dynamic type of the value is; it doesn't convert it to some other type.
Potato, tomato. Note that a type-assertion on an interface does convert, though (it converts the dynamic value to the interface type being asserted). And that conversion is often far less trivial than converting to the underlying type.
Yeah, type asserting on an interface type is somewhat different. In fact, it's already a kind of approximate type switch - what you get has the same dynamic type, but a different static type that allows for a different set of operations.
That's very similar to what I'm imagining for the other approximate case situations.
For example, I'd expect the following code to print "MyFloat", not "float64":
I agree. But (and that is my fault for encroaching on this issue) you are using your proposal, not the idea of purely adding type assertions on approximation elements. I agree that for a type switch on a type parameter, the parameter should get refined constraints and not be implicitly assumed to be
float64(or whatever). But that's separate from the question of what typev.(~float64)should have where we to introduce that (which is not this proposal).So the analogous question would be "what does
fmt.Printf("%T", v.(~float64))print, ifvcontains atype MyFloat float64"? Personally, I find the answer "it should printMyFloatreasonable, but ultimately pointless for little benefit and making the design more complicated than needed. "v.(~float64)evaluates to afloat64" is IMO easy to understand, easy to implement and easy to explain.
In that particular trivial case, perhaps. But ~float64 is an example taken from a much richer set of possibilities. For example, what if the case was comparable, or ~int | ~string or ... ? What type would the value take on then?
When the full generality of constraints are taken into account, I don't think your rule easy to explain, because it seems to me that it would be an additional special case, whereas "keeps its original dynamic type and permits all operations allowed by the constraint" is easy to explain IMHO and requires no special casing.
But if the above prints
MyFloat, it requires a bunch of extra changes to the language - we need to introduce a new type~float64, which behaves like an interface (in that it carries a dynamic value), but also allows most operations offloat64, but is neitherfloat64norMyFloat(asMyFloatisn't even known, statically).
Yup, that's what I believe approximate cases in type switches would entail. It seems like quite a hard problem to me and I'm not convinced it would be worth it. If you didn't implement this, then you can find out something about the value, but you can't actually do much with the value without reflection. I can't see that being worthwhile tbh.
For example, what if the case was
comparable, or ~int | ~string or ... ? What type would the value take on then?
These are not approximation elements. They are out of scope for what I was suggesting. Again, the suggestion I was talking about (which is what I was comparing your proposal to in terms of expressive power) is
v.(~T) using approximation elements (given that T is its own underlying type). Such a type assertion checks if vs dynamic type has underlying type T and if so, evaluates to vs dynamic value converted to T.,ok and type switches.This is enough to de-structure "sum types" if they are built based on #45346 - the value might have type interface{ ~string | ~int } or comparable, but you would still type switch with separate ~string and ~int (and/or string and int) cases.
It would also give a way to specialize generic variables, via switch interface{}(v).(type) - in that sense it has overlap with this proposal. It would be a bit less powerful for that usecase (it wouldn't allow the Max function from above, for the reasons discussed), but it would have applications in other usecases, not addressed by this proposal (in particular use cases that don't involve generics).
The reason I brought it up here is that a) I think we'd definitely want something like this if we ever allow to use constraint interfaces as types, because we need to be able to de-structure them, even in the absence of generics and b) given the overlap, it might be confusing to have both. But, ultimately, as I said I don't think that needs to keep us from doing this and eventually both, if it comes to it. So it really is a minor-ish concern.
If you didn't implement this, then you can find out something about the value, but you can't actually do much with the value without reflection. I can't see that being worthwhile tbh.
This seems a very strange turnaround to me :) I was suggesting for v.(~T) to evaluate to a T. You can do anything to that which you can do with a T, without any reflection. Which, I think, is generally more than you can do to a hypothetical ~T value.
For example, what if the case was
comparable, or ~int | ~string or ... ? What type would the value take on then?These are not approximation elements. They are out of scope for what I was suggesting. Again, the suggestion I was talking about (which is what I was comparing your proposal to in terms of expressive power) is
- It is allowed to write a type-assertion
v.(~T)using approximation elements (given thatTis its own underlying type). Such a type assertion checks ifvs dynamic type has underlying typeTand if so, evaluates tovs dynamic value converted toT.- Similar for
,okand type switches.
OK, fair enough - I'd been erroneously thinking of all the new constraint semantics as "approximation elements" and that's not accurate.
This is enough to de-structure "sum types" if they are built based on #45346 - the value might have type
interface{ ~string | ~int }orcomparable, but you would still type switch with separate~stringand~int(and/orstringandint) cases.
It was this kind of consideration that led me to suggest the limitations that I mentioned here, namely restricting these "sum type" values to those that can be easily destructured without the need to add support for approximation elements to type switches.
If you didn't implement this, then you can find out something about the value, but you can't actually do much with the value without reflection. I can't see that being worthwhile tbh.
This seems a very strange turnaround to me :) I was suggesting for
v.(~T)to evaluate to aT. You can do anything to that which you can do with aT, without any reflection. Which, I think, is generally more than you can do to a hypothetical~Tvalue.
As before, I was mis-thinking of "approximate matching" as "constraint matching", sorry. With approximate matching (only), your suggestion would work OK. I'm not sure how useful it would be in practice though. Have you got some use cases in mind?
I ponder about switching over signature types.
switch type T {
case func(int) int:
case func(byte) byte:
...
}
It was this kind of consideration that led me to suggest the limitations that I mentioned here, namely restricting these "sum type" values to those that can be easily destructured without the need to add support for approximation elements to type switches.
Yes. I drafted a response to that and discarded it in favor to the "this is off-topic" comment :)
IMO, the goal should be to remove the distinction between "constraint interfaces" and "type interfaces". Replacing one restriction with another doesn't achieve that. I would even argue that the distinction becomes harder to understand, relying on finite type sets - I don't think it's super obvious what that means to many people.
And given that I see an IMO clear and simple way out of having that distinction while allowing de-structuring of arbitrary type sets - namely to allow type switches/assertions to use approximation elements - I don't see the point in keeping it :)
Have you got some use cases in mind?
The ones I can think of mainly revolve around de/serialization. They still need reflection for composite types though, if you want to support that. It's not an incredibly strong case TBQH - I'm mainly thinking about the eventuality of removing the constraint/type interface dichotomy.
I'm troubled by cases like FromStrings2 in the section https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#pointer-method-example of the proposal. In that function, if I know T, I also know PT. But if I do a type switch on T as suggested here, in a specific case, T will take on a specific value, but PT will not. I'm concerned that that could be confusing.
@ianlancetaylor
As a user and writer of FromStrings2, I would see that the switch is over type T and not PT. If I were to also want PT to take a specific value, I'd also create another switch within whatever case I was currently in to do that. Since I wrote FromStrings, I already know that PT exists, so that in itself is not a problem. All of it seems rather explicit, but certainly not confusing.
I'm troubled by cases like
FromStrings2in the section https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#pointer-method-example of the proposal. In that function, if I knowT, I also knowPT. But if I do a type switch onTas suggested here, in a specific case,Twill take on a specific value, butPTwill not. I'm concerned that that could be confusing.
In that particular case I think it works out OK. Let's work it through:
type Setter2[B any] interface {
Set(string)
*B
}
type M struct {
f string
}
func (m *M) Set(s string) {
m.f = s
}
func FromStrings2[T any, PT Setter2[T]](s []string) []T {
switch type T {
case M:
// XXX
}
}
At the point XXX, the operations available are as if FromStrings2 had been declared as:
func FromStrings2[T interface {any; M}, PT Setter2[T])(s []string) []T
The constraint interface{any; M} is equivalent to interface{M} which is equivalent to M itself.
So Setter2[T] is now equivalent to Setter2[M], which is equivalent to interface {Set(string); *M} which is equivalent to *M, so we do know the exact type of PT.
That seems good to me.
It doesn't work out the other way though. If we switch type of PT, then theoretically we could know the type of T, but because T isn't defined in terms of PT, the type of T remains any.
I think that all seems reasonably intuitive, but I might be too close to the subject here.
It doesn't work out the other way though. If we switch type of PT, then theoretically we could know the type of T, but because T isn't defined in terms of PT, the type of T remains any.
If the last scenario that @rogpeppe just sketched ends up happening, some related questions would be:
* who would see any related error?
* would any related errors be cryptic or clear?
* would one or more solutions be obvious or reasonably guessable based on the errors and the code?
If for example the type of T remains any after the type switch, but the author was expecting it to have some type M and writes some code that relies on that expectation, it seems the author of the generic code would be the one to trigger a compiler error, and not some later innocent consumer?
And it seems that compiler error has some decent chance of being clear, e.g., improper operation being used against a variable, where the type parameter is reported within the error? (e.g., some error like foo.WrongMethod undefined (type bound for T has no method WrongMethod), which is a current error from the current prototype, or maybe something in the future that is more specific and mentions any)?
Whether or not the a solution is obvious or reasonably guessable based on that error might then depend on the complexity of the code at the point in time the error is introduced... but it seems the generics author would at least have a decent chance of being drawn back to the type switch in question if the author's intent or assumption was that the type switch would have changed the type reported in error message? But it less obvious if a solution would be obvious to an author at that point (though FWIW, adding a second type switch is the solution that popped to mind based on Ian's initial comment above, but that of course was in the context of a small example and having been told what the problem was).
In any event, I think those are useful questions even if I don't know the answers 😅 (and sorry if I have not followed the problem being highlighted).
I would even argue that the distinction becomes harder to understand, relying on finite type sets - I don't think it's super obvious what that means to many people.
"Finite type set" in that context is more-or-less "no approximation elements" (†). I think that might be fairly easy to explain.
Anyway this is still off topic and we should probably stop discussing it here :)
† Obviously it's a little bit more involved than that, because interface{~int; int} does contain an approximation element, but would still be allowed because the approximation element is removed by set intersection).
It doesn't work out the other way though. If we switch type of PT, then theoretically we could know the type of T, but because T isn't defined in terms of PT, the type of T remains any.
If the last scenario that @rogpeppe just sketched ends up happening, some related questions would be:
- who would see any related error?
- would any related errors be cryptic or clear?
- would one or more solutions be obvious or reasonably guessable based on the errors and the code?
The error would be a compile-time error on the generic code, not the code that's using the generic code.
It might be something like: type T with constraint any cannot be assigned to variable of type M.
It's not entirely dissimilar to the kind of error that you get if you inappropriately try to use a value that's been type-asserted
by a switch v.(type) outside of the switch.
It seems to me like things should be OK in this respect.
Inside case M, if the compiler automatically knows the type of PT as *M and allows any value of PT to be used directly as *M, this might just make it the first real implicit behavior in the language (though I could be wrong). There might be nothing wrong with that, but it's something to consider.
I guess one could always go with a more verbose approach, like:
switch type (T, PT) {
case (M, *M):
}
but it may make things more cumbersome as well.
Inside
case M, if the compiler automatically knows the type ofPTas*Mand allows any value ofPTto be used directly as*M, this might just make it the first real implicit behavior in the language (though I could be wrong). There might be nothing wrong with that, but it's something to consider.
The main "implicit" things here are the implications of the rules started in the proposal - the type switch is explicit and the rule that T (and hence every type expression involving T and every variable defined in terms of T) is further constrained by the type in the switch case, is also explicit.
So I'd say this is more a consequence of the explicit rules rather than implicit behaviour.
Note that we are already using these rules in order to be able to use arguments, variables and return parameters as the newly constrained T - the PT case is just another example.
BTW technically it's not that the compiler knows that PT is *M: more accurately PT is now a constraint with a type set that contains exactly one type, and since that type is *M, by the rules outlined in #45346
all operations allowed on *M are allowed on PT.
Which amounts to exactly the same thing in the end, but is maybe worth pointing out.
There is a bit of awkwardness here w.r.t. “is” vs. “implements”. Normally when a value appears in the case of a switch x, statement the switched value must be exactly that value, but when an interface type appears in the case of a switch x.(type), the value can be of any type that implements that interface.
The distinction matters here because I can't intuitively tell whether case fmt.Stringer in switch type S should match any S that implements fmt.Stringer, or only S = fmt.Stringer exactly. That distinction matters when we're considering things like function signatures, or element-types of slices or maps.
I can't intuitively tell whether case fmt.Stringer in switch type S should match any S that implements fmt.Stringer,
The rule here is that it's exactly as if you'd used fmt.Stringer as a type constraint so, as with constraints on type parameters, there would be (I believe) no way to require exactly an interface type as opposed to anything that implements that type. I like the uniformity of that approach (and it's easy to explain), but it could be argued that it is too restrictive.
One variant I considered but rejected as too hard to explain is to allow switching on any type defined in terms one or more type parameters. The type in the switch would be unified with the type in the case, allowing multiple types to be specialised at once if desired.
In that case, switch *T would enable finding the exact type of T because *io.Reader doesn't match any other type.
I like the uniformity of that approach (and it's easy to explain)
FWIW, for consistency with a) normal type switches, b) using other type sets in cases in a type parameter switch and c) using type sets in a constraint interface (as you mentioned), I really think it has to be this interpretation. Anything else is too inconsistent and confusing.
it could be argued that it is too restrictive.
If people really need to check if a type parameter is a given interface, they can do so via
if interface{}(new(T)) == new(fmt.Stringer) {
// T is known to be exactly fmt.Stringer
}
They would then have to refer to fmt.Stringer in the block directly, converting/type asserting/… usages of identifiers outside the block using T as appropriate. Which they can, because a) they know the type of the interface they are interested in (it's not possible to use an unknown type instead of fmt.Stringer, as it's not possible to restrict type-parameters to be interface types) and b) they know the actual identity, not just membership in a type set.
It's not very convenient code to write and it can't happen as a case of a switch (but would have to happen in a separate statement before/after/in a different case). But it should be very rare to need this, so it seems fine to me for it to be inconvenient.
The rule here is that it's exactly as if you'd used
fmt.Stringeras a type constraint ….
I like the uniformity of that approach (and it's easy to explain),
I don't agree that that approach is uniform, though — it is only uniform with respect to type parameters, not with respect to type-switches.
FWIW, I think you can write the Concat example almost as clearly using only ordinary type-switches and assertions:
type Stringish interface {
string | fmt.Stringer
}
func Concat[S Stringish](x []S) string {
switch x := x.(type) {
case []string:
return strings.Join(x, "")
default:
// S is not string, so it must be fmt.Stringer.
var buf strings.Builder
for _, s := range x {
buf.WriteString(s.(fmt.Stringer).String())
}
return buf.String()
}
}
The type-assertion to fmt.Stringer would presumably be straightforward for the compiler to optimize: it already knows the type set of S, and it's not difficult to extrapolate from a type switch on []S to type information about S itself.
If people really need to check if a type parameter is a given interface,
To me the interesting question is not “do they need to check if the type parameter is a given interface?”, but rather “in what ways do they need to contort the code to express what they want to express?” Knowing that a type parameter is exactly a specific interface, for example, doesn't make the code particularly less verbose if the author still needs to write a bunch of type-assertions, or (worse!) boilerplate conversions.
The really interesting wrinkle with the Concat example, I think, is how it interacts with https://golang.org/doc/faq#convert_slice_of_interface and https://golang.org/doc/faq#convert_slice_with_same_underlying_type. If you only know that S implements fmt.Stringer, you can't use that information in the same way that you can use the information that S is string. The “is string” information allows you to convert and/or type-assert to a concrete type, whereas the “implements fmt.Stringer” information does not.
I don't agree that that approach is uniform, though — it is only uniform with respect to type parameters, not with respect to type-switches.
Actually, it is uniform with respect to type switches, at least somewhat: if I have an interface type in a regular type switch case, I'm asking for anything that matches the interface type, not anything that's exactly that type, which is like the behaviour proposed here.
The difference here in this proposal is that it can very much matter whether a type is exactly something or not, because that influences compatibility with the outside world (the type switch might say that R matches io.Reader, but that doesn't mean I can use io.MultiReader as func(...R)R).
The different semantics are why I've suggested the type keyword in the construct (as opposed to just switch). Maybe some other syntax might help to make the intended semantics clearer?
FWIW, I think you can write the
Concatexample almost as clearly using only ordinary type-switches and assertions:
Yes, that's not a great example for showing the benefits of this construct (it just happened to be current in the original thread).
Your example isn't quite allowed though, because you can't directly type assert on s currently, so you'd need:
for _, s := range x {
buf.WriteString(interface{}(s).(fmt.Stringer).String())
}
The Max example is better than the Concat example, and better still might be something that showed a bunch of disparate parameters and local variables all unified by the type switch.
There is a technique that does allow using a single regular type switch and no dynamic type assertions even in the Max example, BTW, but it's clunky! (and it might not be allowed by the current spec; I can't remember)
func Max[T ordered](a, b T) (r T) {
type args[S any] struct {
a S
b S
r *S
}
aa := args[T]{
a: a,
b: b,
r: &r,
}
switch aa := interface{}(aa).(type) {
case args[float64]:
*aa.r = math.Max(aa.a, aa.b)
return
}
if a > b {
return a
}
return b
}
it is only uniform with respect to type parameters, not with respect to type-switches.
Can you explain that? You mentioned yourself, that in a type-switch we check for implementation of an interface, not identity to an interface type. Isn't that exactly what @rogpeppe is describing?
FWIW, I think you can write the Concat example almost as clearly using only ordinary type-switches and assertions:
It stops working once you use ~string in Stringish. There's also this Max example from a different context, which IMO demonstrates an edge-case. While a normal type assertion/switch can get you from a T to a float64 (or whatever), it can't take you back from a float64 to a T. For that, the compiler actually needs knowledge about T.
The “is string” information allows you to convert and/or type-assert to a concrete type, whereas the “implements fmt.Stringer” information does not.
It does, just only to types you can convert a fmt.Stringer to. That's the difference here - you are comparing "is a string" to "implements fmt.Stringer", but the interesting comparison is to "is fmt.Stringer". That information is far less useful than "is a string".
I agree that there has to be a way to know the actual concrete type for many things. But you can do that. You can use string in a case. I don't buy that there are many use case to know that a type parameter is a specific interface type though - if there was, we should definitely consider changing the generics proposal to be able to express that.
@rogpeppe wow. That's… something. I think it's allowed.
@Merovius did you mean a different playground link there?
Nah. I know it's not exactly your code, but I think the general principle applies. Your code doesn't work, FWIW, I assume because of a bug with the prototype (it also doesn't support local generic types).
That code does work on the tip go2go branch FWIW.
I know it's not exactly your code, but I think the general principle applies.
It looks quite different to me - it uses more than one dynamic type assertion.
ISTM that what that code is doing is doing a somewhat similar thing to the switch type statement - it's gathering all the variables in the frame together into one place under a common type parameter (but awkwardly, oh so awkwardly!),
but what you can't do is general constraint tightening, something that you can't get any other way, but that fits
very nicely into the model described in #45346 as far as I can work out so far.
it is only uniform with respect to type parameters, not with respect to type-switches.
Can you explain that? You mentioned yourself, that in a type-switch we check for implementation of an interface, not identity to an interface type. Isn't that exactly what @rogpeppe is describing?
switch type as @rogpeppe proposes is analogous to a value-switch, not a type-switch:
A type-switch allows binding a variable; the proposed switch type does not (because what would it bind to?).
A type-switch takes the program from a lower sort (“values”) to a higher sort (“types”). Go's type system does not have a higher sort than “type”, but something analogous to a type-switch for types would take the program from types to kinds (think map[…]…, or chan …), and allow that kind to be instantiated with other types.
So, reasoning by analogy with value-switches, the proposed switch should match using identity (as value-switches do), not “implements” (as type-switches do). But type-sets, as proposed, do reason using “implements” instead of identity.
Hm. I can see your argument for comparing them to "value switches", but I disagree that most people would see it that way. I think most people would see them as more closely related to type-switches.
And BTW, the third way I mentioned (besides type sets and type switches) is "other cases in the same construct" (the type parameter switch proposed here). That is, ~string also matches via checking for set membership, not for identity. So, by analogy, fmt.Stringer should also check for set membership.
Seems to me that the type switch should check the type name even and especially for interfaces.
Exact type matching. Otherwise, if a string type implements Stringer, and is added to the Stringish union element, how to determine which case is triggered in a tri-case type switch? Even worse if the union elements holds two different traditional interfaces where one interface type sets is included in the other one (subtyping)
Essentially, we would lose the discriminated part of discriminated union. And it is a requirement that cannot be dropped.
Otherwise, if a string type implements Stringer, and is added to the Stringish union element, how to determine which case is triggered in a tri-case type switch?
The first one. That's how type-switches already work.
Yes but I think there is currently a github issue about it. For instance, if we have several traditional interface types, source order is too limiting.
No guarantees that source order is correct either if we want type switches as an implementation of exhaustive pattern matching too later.
https://play.golang.org/p/-RBR08LHzP1
Source order is also how normal switches work, FWIW. It's also just natural. It makes a switch equivalent to a chain of if-statements.
Yes but I think there is currently a github issue about it. For instance, if we have several traditional interface types, source order is too limiting.
I don't know of that issue, but I can't imagine us changing this. We are bound by the compatibility promise.
No guarantees that source order is correct either if we want type switches as an implementation of exhaustive pattern matching too later.
I have no idea how that will or would work, but a) I'm pretty sure source order will be the correct choice then as well and b) I don't think speculative problems with a speculative language extension are a good basis to make decisions on.
In any case, the point is that we have a natural and consistent answer to the question of how to resolve any ambiguities in which cases match. We don't need to change the semantics of the proposal to accommodate the possibility of choosing another answer.
I would tend to disagree...
Switching on constraints is different from switching on types.
I think that it was a mistake that may or may not be amendable.
For example, this : https://play.golang.org/p/-RBR08LHzP1
I think that the whole idea of interface types was to have them as constraints which is why they are structural. The issue is that they are types too and the nominal constraint is sometimes useful.
So source order should probably not be the default. It's not just about having consistency but also correctness (especially if the alternative precludes further extension to the language, I don't think that implementing blindly into a corner is a good way to see things given that languages usually want to evolve to meet user needs)
I would tend to disagree...
Okay. We can agree to disagree.
Seems to me that the type switch should check the type name even and especially for interfaces.
I think there's value in having a rule for matching that's very easily described. Using exactly the same semantics as for generic type constraints means that you only need to learn one set of rules - "the first case that fits the type constraints" is a very easy description IMHO.
Given that, I think that using exact matching isn't a good idea, because you'd still want to be able to do imprecise matching, which this proposal allows.
I do agree that there might be cases where you want to know the exact interface type. This proposal does allow you to find that out using the same construct, albeit awkwardly:
func appendAny[T any](xs []T, x T) []T {
// Use a secondary generic function so that we
// can type switch on the pointer type too.
return appendAny1[T, *T](xs, x)
}
func appendAny1[T any, PT interface {*T}](xs []T, x T) []T {
switch type PT {
case *fmt.Stringer:
return appendStringer(xs, x)
default:
return append(xs, x)
}
}
func appendStringer(ss []fmt.Stringer, s fmt.Stringer) []fmt.Stringer {
return append(ss, s)
}
Another way to enable exact matching is to allow switching on an arbitrary type expression rather than restricting it to a type parameter. Then you could do this, for example, to get exact type matching:
switch type *T {
case *io.Stringer:
}
(the use of * there is arbitrary - it could be []T or func() T or whatever).
If this turned out to be a common requirement, there's no reason that the "you can only switch on a type parameter" restriction couldn't be dropped later. It's a bit harder to describe the unification rules in this case, which is why I thought it best to keep it simple, at least initially.
Essentially, we would lose the discriminated part of discriminated union. And it is a requirement that cannot be dropped.
I don't know what you mean by "discriminated union" there. The unions that the generics proposal provides does not provide unique discriminated items; for example, it would be fine to do something like this:
type I1 interface {
~int | ~string | ~float64
}
type I2 interface {
~float64 | int | bool
}
type I3 interface {
I1 | I2
}
In this case, there's no way of saying if something that matches I3 is matching exactly one of I1 or I2 - it could be either or both.
No guarantees that source order is correct either if we want type switches as an implementation of exhaustive pattern matching too later.
If you want exhaustive pattern matching, you can avoid the use of interface types in the union.
If we're going to use constraint matching for the type switch (something I think is very useful and that you can't get any other way), and since a constraint is inherently something that can be matched by many types, source order is the only possible solution, I believe.
It also fits well with the way the rest of the language works IMHO.
For instance, if we have several traditional interface types, source order is too limiting.
I don't see how it's limiting. You can always ask about each case individually: https://play.golang.org/p/R__HXSj4Nfz
The issue is that constraint matching and type matching should be different.
They only seem similar because we use types as constraints and interfaces "types" exhibit a somewhat ambiguous "type" matching behaviour.
In your example, we should be able to find whether a type belongs to the type Set of I1 or I2 but it might need some rule around matching priority between a type t and ~t when t is part of the union. (also note that ~int is not a type so the semantics of "type" switch would be a bit blurried)
A type switch (should) verifies type identity.
A constraint switch would verify implementation/constraint satisfaction.
interfaces define constraints without enforcing the interface type name. In a way, interface function arguments are a mix of constraints and types (boxing).
Re. The "too limiting" part, I was writing specifically in the context of a type switch.
But yes, the type switch could be foregone.
It's merely the semantics of the type switch that change mid-way (with interface types being treated differently, emphasis on type) which I think were a mistake. Not sure if it can be changed before go2.
But yes, I still think that the semantics need to be cleared up a little.
Having a proper constraint object could have made everything a bit clearer but alas.
It's actually the same root problem if we decide that we want to switch between ~int and int.
int is a concrete type. exact type matching rule.
~int is a constraint, the rule is different.
An interface type is a type and we try hard to use them as constraint (because it is convenient as function argument for instance)
It's the semantics of the use of interfaces that are a bit blurry.
If we had a clear distinction between types and constraints however, we could have a single rule for types, and a single rule for constraints (constraints name would not matter, as opposed to type names)...
(do I repeat myself? it sure seems so :)
No guarantees that source order is correct
Source order is the only correct order, at least in programs to be written by and read by humans.
No guarantees that source order is correct
Source order is the only correct order, at least in programs to be written by and read by humans.
Why would it be the case? that seems not true at all and very brittle in the case of refactorings and whatnot.
Especially when dealing with concurrent multithreaded programs...
Unless the programs are executed by humans too ;)
Why would it be the case?
FWIW we've given a couple reasons so far
switch statementswitch equivalent to a series of if/else if statementsAFAICT the only reason given for this not being the right thing is "it shouldn't".
Especially when dealing with concurrent multithreaded programs...
Concurrency has no effect whatsoever on switch statements. It does effect select statements and there are reasons why that uses the "chosen uniformly at random" rule. But switch statements are purely sequential code.
Why would it be the case? [...] Unless the programs are executed by humans too ;)
For every source code line coined by a human first runs on protein based CPU.
:) haha true...
But source order does not have to be execution order.
It's not in concurrent multithreaded code.
It's not when dealing with go maps (keys are shuffled wrt iteration).
Processors have OOE... But I digress.
@Merovius it wasn't directed at type switch specifically.
If you read attentively, the explanation of why source order is tricky here is when we deal with compatible interfaces.
It makes the code brittle because what should be a type identity matching rule becomes a source order matching rule for no obvious reason.
@atdiar I am trying to read attentively. I still don't understand what the actual arguments are. You linked to a couple of examples, but they work just fine and exactly as I would expect them to.
It makes the code brittle because what should be a type identity matching rule becomes a source order matching rule.
Again: It "should"? Why "should" it? You can't just state that it should be one way, you have to actually make a case for it.
Because if you want to switch on types, you should be able to switch on types.
I put an example reusing your playground code earlier.
Namely implementing a NumStringer interface that is a subtype of Stringer.
Why should we be unable to switch exactly on the interface type?
Why should it be source order?
I put an example reusing your playground code earlier.
Yes. What you haven't done, is say what's wrong with that. Again, it works fine. Exactly as I would expect.
Why should it be source order?
Why should we be unable to switch exactly on the interface type?
It does not work as it should unless mistaken.
I would expect NumStringer to be returned both times if we were really "type" switching.
In one case in returns fmt.Stringer because of source order matching rule.
This is how it works today but that might be a mistake. It's potentially something that can be changed w/o breaking the compatibility promise too. Not too sure.
Why should we be unable to switch exactly on the interface type?
That's still a workaround...
The only simple way is to not use type switches and then we have the ability to test exact type match as @rogpeppe wrote.
type switches are not a syntactic construct that replace one-for-one if/else statements actually.
Still, the semantics could be refined a little.
type switches are not a syntactic construct that replace one-for-one if/else statements actually.
Yes, they are (more specifically, if/else if. I wrote the second if for a reason). And maybe you wouldn't be as surprised by their semantics if you'd start viewing them that way.
You are forgetting the type switching.
It's not a mere select case equivalent. I may forget something but how do you check that a random value is of a given type (interface types included) with mere if ?
To try and rephrase, Interfaces in type switches don't behave like types and that's a problem if we need them to.
What is type equality for an interface type?
Independently, what is constraint equality?
When do we use one over the other? (function args? type switches ? Etc. )
These are questions that I think we ought to answer better to make sure that no corner case are created.
how do you check that a random value is of a given type [...] with mere if
if v, ok := x.(T); ok {; ... }
Type assertions are for interfaces only.
You can't reproduce what a type switch does with just if/else if
Well, maybe could box the value in an interface{} beforehand.
Edit: why do i even want to switch on a concrete value anyway... Bug of mine, ignore.
Type assertions are for interfaces only.
So are type switches.
You can't reproduce what a type switch does with just if/else if
Yes, you can. Perhaps you can provide an example of a type switch that can't be done using if/else if?
You are forgetting the type switching.
No.
It's not a mere select case equivalent.
select is distinct from switch (which is why it uses a different keyword) and used for blocking on several possible communication possibilities in concurrent code.
I may forget something but how do you check that a random value is of a given type (interface types included) with mere if
What @ohir said. You use a type-assertion.
To try and rephrase, Interfaces in type switches don't behave like types and that's a problem if we need them to.
They behave exactly the same as in a type-assertion - they check if the dynamic value contained in an interface value implements the interface you are asserting on.
What is type equality for an interface type?
That's defined in the spec:
A defined type is always different from any other type. Otherwise, two types are identical if their underlying type literals are structurally equivalent; that is, they have the same literal structure and corresponding components have identical types. In detail:
[…]
Two interface types are identical if they have the same set of methods with the same names and identical function types. Non-exported method names from different packages are always different. The order of the methods is irrelevant.Independently, what is constraint equality?
This is not a concept that currently exists and I don't know of a proposal that would introduce it, so I don't know how to answer this.
It does not work as it should unless mistaken.
I would expect NumStringer to be returned both times if we were really "type" switching.
This is how it works today but that might be a mistake. It's potentially something that can be changed w/o breaking the compatibility promise too. Not too sure.
It's definitely not a mistake. In that example, both Stringer and NumStringer are entirely valid choices. To say otherwise you'd need to define an ordering for matches and choose the most specific. But that's not possible in general - for example, if the concrete type implements both io.Reader and io.Closer, which of those should be chosen in a type switch statement?
This is why pattern matching constructs in most programming languages use source ordering to define which case to choose.
Type assertions are for interfaces only.
You can't reproduce what a type switch does with just if/else if
You might care to check the validity of your statements before asserting them. A type assertion expression is almost exactly equivalent to a single-case type switch in fact; you can indeed type assert on concrete types too.
For io.Reader and io.Closer.. My point is to not match on implementation but on the type of the boxed value when needed.
Type assertions are for interfaces only.
So are type switches.
You can't reproduce what a type switch does with just if/else if
Yes, you can. Perhaps you can provide an example of a type switch that can't be done using
if/else if?You are forgetting the type switching.
No.
It's not a mere select case equivalent.
selectis distinct fromswitch(which is why it uses a different keyword) and used for blocking on several possible communication possibilities in concurrent code.I may forget something but how do you check that a random value is of a given type (interface types included) with mere if
What @ohir said. You use a type-assertion.
To try and rephrase, Interfaces in type switches don't behave like types and that's a problem if we need them to.
They behave exactly the same as in a type-assertion - they check if the dynamic value contained in an interface value implements the interface you are asserting on.
What is type equality for an interface type?
That's defined in the spec:
A defined type is always different from any other type. Otherwise, two types are identical if their underlying type literals are structurally equivalent; that is, they have the same literal structure and corresponding components have identical types. In detail:
[…]
Two interface types are identical if they have the same set of methods with the same names and identical function types. Non-exported method names from different packages are always different. The order of the methods is irrelevant.Independently, what is constraint equality?
This is not a concept that currently exists and I don't know of a proposal that would introduce it, so I don't know how to answer this.
I stand corrected. Meant switch and wrote select.
And also you're right about type switches as switch statement equivalents and if/elseif.
:)
For io.Reader and io.Closer.. My point is to not match on implementation but on the type of the boxed value when needed.
If you don't use interfaces in type-assertions or type-switches, that is exactly the behavior you want. So, this is already very much supported in the language as it exists today. It also is supported in @rogpeppe's proposal.
My point is to not match on implementation but on the type of the boxed value when needed.
Now I am entirely lost regarding purpose of everything you authored above, the more in the light of some (unmentioned but implied) Oracle that should decide on what to match first. Match using defined type is already supported by the language. This proposal is all about deciding over parametric types, see the title.
... Bug of mine, ignore.
There is [ Hide -> as off-topic ] item in the dot menu. It would be prudent to make use of it.
Defined types yes. But interfaces are not defined types.
They match by first one who is at least implemented and appears in the source wins.
I'm arguing that in some cases, this should be the behaviour if all else fails only.
In some cases, one may want interfaces to work just like defined types.
Imagine we have interface{ int | float | Stringer | NumStringer}
And we want to type switch... But anyway, nevermind... As @merovius highlighted, this is just a switch statement and as @rogpeppe wrote, can forego the whole type switch and write the type assertions one by one.
So the point is moot.
Ps. It's not an oracle, it's the interface type name that would discriminate. It does at time already.
Defined types yes. But interfaces are not defined types.
Nit: A "defined type" just means there is a type-definition for it. io.Reader is a defined type, while interface{ Read([]byte) (int, error) } is not. The established nomenclature you are looking for is "concrete type".
I'm arguing that in some cases, this should be the behaviour if all else fails only.
Of course. But you can already express any precedence of matches you want (including this one) by ordering them lexically. So if you want to check concrete types first, you can simply put them earlier in the type-switch than any interface type. You can already get exactly what you want - with the added bonus, that the precedence of the order of precedence is clear just from the order they appear in the code.
Yes, ack. (not defined, concrete is accurate here indeed)
Without derailing rhe discussion though, this new switch statement would be differently from a regular type switch because even interface types would be checked for identity I assume?
We would be switching over the values (types) that the type parameter can take, not the additional contraints that it may satisfy. Am I right?
this new switch statement would be differently from a regular type switch because even interface types would be checked for identity I assume?
No, all cases would be checked for constraint satisfaction as defined in the latest generics proposal.
That means that if a case is an interface type, it matches the set of types matched by that interface type when used as a type parameter constraint.
I updated the description of this issue to try to make that clearer.
Ok I think I get it now. Lgtm.
Just still thinking about the ordering of cases when several (if taken independently) could be triggered. For instance when the actual type of the type parameter belongs to multiple type sets.
Starting to think that it's likely that one will want to reswitch within the ambiguous cases sometimes.
A solution to avoid the limitations of source ordering.
Putting this proposal on hold until we have more experience with generics.
This can essentially already be done, maybe all thats needed for now is better optimisations.
I have the following code:
package main
import "fmt"
import "time"
func foo[T any](d T, i *int){
switch interface{}(d).(type) {
case string:
*i++
default:
*i--
}
}
func bar(d interface{}, i *int){
switch d.(type) {
case string:
*i++
default:
*i--
}
}
const iters = 1<<30
func main() {
var d int;
t1 := time.Now()
for i := 0; i< iters; i++{
foo("", &d)
}
fmt.Println(time.Since(t1))
t2 := time.Now()
for i := 0; i< iters; i++{
bar("", &d)
}
fmt.Println(time.Since(t2))
}
Running this code on dev.typeparams shows that the generics code is marginally faster. (and that the more iterations the closer it gets? not sure why that would be. maybe the CPU gets good at predicting and can optimize a lot.)
But when you look at the generated assembly using go tool compile -G=3 you will notice that there is still a branch in the generic code. This should definitely be avoidable, that make type switches like the above really powerfull.
assembly for the generic function:
0x0000 00000 (generics.go:232) TEXT "".foo[string](SB), DUPOK|NOSPLIT|ABIInternal, $0-24
0x0000 00000 (generics.go:232) FUNCDATA $0, gclocals·a35fa7d7e7129fc330c152d6236a3e5c(SB)
0x0000 00000 (generics.go:232) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0000 00000 (generics.go:232) FUNCDATA $5, "".foo[string].arginfo1(SB)
0x0000 00000 (generics.go:233) CMPL type.string+16(SB), $-520135500 // not needed branch
0x000a 00010 (generics.go:233) JNE 17 // not needed code
0x000c 00012 (generics.go:235) INCQ (CX)
0x000f 00015 (generics.go:233) JMP 20 // not needed code
0x0011 00017 (generics.go:237) DECQ (CX) // not needed code
0x0014 00020 (generics.go:233) RET
@JAicewizard The utility intended here is larger than a type-switch on interface{}(v). In particular, there is no way for a switch-case to match a user-defined type:
type ApproxString interface { ~string }
func F[T ApproxString](v T) {
switch (interface{})(v).(type) {
case string:
fmt.Println(v)
default:
panic("not a string type")
}
}
type MyString string
func main() {
F(MyString("Hello World")) // panics
}
This proposal would give a switch construct that allows this to work.
Ah, in that case I misunderstood the goal of this proposal. I still think a large part would be coverable by a type switch, but indeed not everything.
Please feel free to delete this comment as it adds only to the spirit, and not content, of the discourse, but I have learned so much watching @rogpeppe and @Merovius go back and forth. I have recently hit upon a need for this as well, so I hope this gets revisited soon 😃
Hi all,
Just a have a question with "type assertions" in generics, it doesnt look like this is possible:
./prog.go:28:14: invalid operation: cannot use type assertion on type parameter value t (variable of type T constrained by int|float64)
Am I instead supposed to do a type switch, casting T to interface{}? This seems weird and out of the ordinary, but want to be sure this is the proper answer. I have some code that relies on the underlying types int, float64, etc, but uses recursion if the type is map[string]interface{}. I do not want to convert some of this code to generic implementation if it's just going to be hackier to get it to work.
switch interface{}(t).(type) { ...
Full sample code is here; just want to be sure before I start converting code over to generics...
Yes, at least for now convert to any and then do the type switch.
switch any(t).(type) {
@withernet To be clear:
I have some code that relies on the underlying types
int,float64, etc
IIUC you want your code to work with defined types (which you don't know beforehand) which are not int, but have underlying type int? Because in that case, you'll have to use reflection. Making that work is one of the main arguments for this proposal.
Hi all,
I was playing around with the generics feature to try and create a generic 'Set' implementation, and here is what I have come up with: https://go.dev/play/p/sPS8XqUk5w1?v=gotip.go?download=true.go?download=true
I'm generally happy with it, but there are a few things I don't like:
1. The need to instantiate an instance of T in New() in order to do a type switch on it
2. The use of reflection to grab the "Hash" function from that instance of T (if that T happens to implement "Hashable")
3. The need for an unsafe cast in Insert() and List() (even though I know it is actually safe because of how the types are inferred / propagated to these functions)
4. The need to limit the set of types I actually support in New() because I can't actually type switch on "HashTypes" or "Hashable[T HashTypes]".
I believe most of these limitations could be overcome by this proposal. The only one I'm not sure about is the last one (4). Does this proposal cover doing something like the following:
func New[T any]() Set[T] {
switch T {
case Hashable[U], U HashTypes:
return NewHashable[T, U]()
}
}
func NewHashable[T Hashable[U], U HashTypes]() Set[T] {
return ...
}
type Hashable[U HashTypes] interface {
Hash() U
}
type HashTypes interface {
constraints.Ordered
}
Where T can match on the type Hashable[U], U HashTypes such that we can then access the underlying HashTypes parameter that was matched (via the new type variable U).
By splitting the implementation for the two different cases, you can fairly easily fix all four problems. Here's a partial simplification. It can be simplified a lot more, as some of the code is rather redundant or just replicates existing functionality in basic Go, but I think it illustrates the idea, at least.
I realize I can split them and that this would simplify quite a lot.
In fact, my first implementation looked something like this:
https://go.dev/play/p/1GA33NDtpBk?v=gotip.go?download=true.go?download=true
That is not the question I am trying to ask though. In trying to unify them, I saw that this would be possible if all 4 of the cases I mentioned above were addressed by this proposal. As far as I can tell, the first 3 are, but wanted to get clarity if the 4th one was as well.
Or at least allow typed returns based on type switch, for example:
func LessOrEqFn[T comparable]() func(a, b T) bool {
var zero T
switch any(zero).(type) {
case string:
return func(a, b string) bool {
return a <= b
}
}
panic("x")
}
@OneOfOne That doesn't work if T is an interface type (then the value in the type switch would always be nil).
ISTM that it would be hard to write the specification for the above.
Would this be allowed, for example?
func LessOrEqFn[T comparable]() func(a, b T) bool {
var f func() *T
switch any(f).(type) {
case func() *string:
return func(a, b string) bool {
return a <= b
}
}
panic("x")
}
For the record, here's how you could implement that function with current functionality. It's awkward and error-prone but works:
func LessOrEqFn[T comparable]() (f func(a, b T) bool) {
switch f := any(&f).(type) {
case *func(a, b string) bool:
*f = func(a, b string) bool {
return a <= b
}
default:
panic(fmt.Errorf("unknown type %T", f))
}
return f
}
Let me add little real life example.
What I have:
func getEnvInt64[T ~int64](envName string, fallback T) T {
str := os.Getenv(envName)
if str != "" {
if t, err := strconv.ParseInt(str, 10, 64); err == nil {
return T(t)
}
}
return fallback
}
What I really want to have:
func getEnv[T ~int64|~string|~whatever](envName string, fallback T) T {
str := os.Getenv(envName)
if str != "" {
switch T { // or switch type T {
case ~int64:
if t, err := strconv.ParseInt(str, 10, 64); err == nil {
return T(t)
}
case ~string:
return T(str)
case ~whatever:
// ...
}
}
return fallback
}
And want to be able to
timeout := getEnv("TIMEOUT", time.Second * 3)
param := getEnv("PARAM", "default")
Here's an untested alternative, @porfirion:
package env
type Parser[T any] func(string, bool) T
func Int[T constraints.Signed](fallback T) Parser[T] {
return func(v string, ok bool) T {
if !ok {
return fallback
}
n, err := strconv.ParseInt(v, 10, 64);
if err != nil {
return fallback
}
return T(n)
}
}
func String[T ~string](fallback T) Parser[T] {
return func(v string, ok bool) T {
if !ok {
return fallback
}
return T(v)
}
}
func Get[T any](name string, parse Parser[T]) T {
return parse(os.LookupEnv(name))
}
Then you can do stuff like
timeout := env.Get("TIMEOUT", env.Int(3 * time.Second))
param := env.Get("PARAM", env.String("default"))
Though really, this is just kind of using generics to wrap conversions back and forth. You could easily do this with a few manual conversions for the cases that need it. You could also create different top-level functions for each type category that all call a single, unexported generic one. Then you don't need to do weird stuff with closures.
It's also possible (and perfectly reasonable) to use reflect from a generic function. i.e.
func getEnv[T ~int64 | ~string | ~whatever](envName string, fallback T) (T, error) {
str, ok := os.LookupEnv(envName)
if !ok {
return fallback, nil
}
var v T
rv := reflect.ValueOf(&v).Elem()
rt := rv.Type()
switch rt.Kind() {
case reflect.Int64:
if n, err := strconv.ParseInt(str, 10, 64); err != nil {
return v, err
} else {
rv.SetInt(n)
return v, nil
}
case reflect.String:
rv.SetString(str)
return v, nil
}
// do whatever based on methods or something using reflect
}
It's not as nice, yes. But at least the API is the same, the level of type-checking is the same and if we ever get this proposal (or something like it) the code behind it can always be swapped out.
Just because we have generics now doesn't mean reflect is no longer usable.
Just because we have generics now doesn't mean reflect is no longer usable.
reflect is slow / not same as compile time / to be use only when nothing else can, imo
+1 this proposal having to do stuff like
func Parse[T any](input string) (val T, err error) {
switch any(val).(type) {
case bool:
var v bool
v, err = strconv.ParseBool(input)
val = any(v).(T)
case int64:
var v int64
v, err = strconv.ParseInt(strings.TrimSpace(input), 0, 64)
val = any(v).(T)
...
instead of
func Parse[T any](input string) (val T, err error) {
switch T {
case bool:
val, err = strconv.ParseBool(input)
case int64:
val, err = strconv.ParseInt(strings.TrimSpace(input), 0, 64)
...
make generics in go feel awkward (and also it took me a while to find that I needed that magic any(val).(type) sequence, it's not new user discoverability friendly). Note here I'm +1 this proposal but is there one for the 2nd part: being able to assign without a cast when in a branch of a given type?
(oh and maybe a third proposal that strconv has native Parse[T] :) )
Thanks!
ps1: a fourth proposal which would be even better for me is If could write
func Parse[bool] ...
func Parse[int64]...
and it would automatically imply either compile or runtime error if it ends up being called with a type not defined
ps2: complete actual code is https://github.com/fortio/fortio/pull/593/files#diff-31ad54e670f6bf98ddf9def0d0fab58512c2bdff2e30a269274687e121a76aa2R95-R119
Note here I'm +1 this proposal but is there one for the 2nd part: being able to assign without a cast when in a branch of a given type?
If I understand you correctly, this proposal does cover that (and even makes use of that property in its example):
"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."
@Merovius certainly, it's possible with reflection.
But I assumed that generics are designed not only to do type checking (as in your example), but also to reduce the amount of boilerplate code!
Also you need to instantiate additional variable just only to determine the type.
And in case when you use tilde operator (~string) - you have to check all type hierarchy.
It looks completely awkward, when you already have all required data :(
That doesn't work if
Tis an interface type (then the value in the type switch would always benil).
@rogpeppe, btw - as Axel mentioned var v T; reflect.TypeOf(&v).Elem() works for interfaces too. But it's still awkward(
@porfirion To repeat myself: I acknowledge that it's not as nice as with this proposal. I'm not saying we shouldn't implement it. I was only trying to help you clean up the API you have, as you seemed dissatisfied with what you have right now.
Note that I learned today that you can simplify a bit the casting using
switch v := any(&val).(type) {
case *bool:
*v, err = strconv.ParseBool(input)
compared to my earlier
switch any(val).(type) {
case bool:
var v bool
v, err = strconv.ParseBool(input)
val = any(v).(T)
https://github.com/fortio/fortio/commit/1d4dbd87f28f083a0a9beb0de4da7d32fba1e7b2 for such simplification
(credit to #generics on the gophers discord and @rogpeppe in particular - thank you!)
@ldemailly this is still slightly different, than the proposal is about: in go (at least for now) generics works like codegen at compiler time. If you try to switch type in this case, you will place this statement to every generated function. For 1.18-1.19 age that's ok. But not in the future.
For guys who came here from google or stack overflow: If you are in 2022-2023 years — use @ldemailly variant. For now that's ok. But, please, if you are available to work on this proposal — do it. It's really important change in language.
This seems to undermine the abstraction properties that we wanted from generics in the first place:
"In the beginning, we wanted to abstract away concrete types, so we added type variables to Go. Then, we wanted those abstract types to have certain concrete behaviors, so we added type variable constraints to Go. Now, we want to turn those constrained abstract types back into full concrete types, so we want to add type variable type switches to Go."
We've come full circle. 🙃
Type variable type switches would (further) break parametricity, meaning that (1) generic functions wouldn't have to behave the same regardless of the type arguments, and (2) not all type arguments (satisfying constraints) would have to be valid for generic functions; both of which are useful properties for reasoning about the behavior of generic functions.
This seems more like an issue of built-in types not having methods, so they don't play nicely with interfaces and constraints. If string had a String() string method, then this particular example wouldn't be an issue. Built-in types could be made into stdlib package types, with methods corresponding to each operation. Then we wouldn't even need non-basic interface constraints.
@willfaught You have quotes around your "In the beginning" paragraph as if it's an actual quotation from somewhere, but I believe this is only your characterisation of the proposal process, and doesn't necessarily reflect how things actually went.
With regard to breaking parametricity, I'm afraid that ship has already sailed. We can already type switch on parametrically typed values by converting them to any, so we can already do the thing that you are concerned about, albeit awkwardly.
I believe that there's a strong connection between interface types and type parameters. The Go authors could have chosen a stronger abstraction for interface types that did not allow downcasting to more specific types, but they didn't, because sometimes we want to be able to specialise. Arguably this is a flaw in Go, but it's also extremely useful in many ways - from fmt.Printf to file streaming optimisations.
So we could choose not to support type specialisation of generic types, but quite apart from the fact that we can already do it, I believe that it is similarly useful to type specialisation of interface types, and thus worth having.
With regard to your point about this just being about built-in types not having methods, with respect, I don't believe that's the case. One significant use for type switching on generic types is to provide optimised paths for some types, similar to the way that we use type switching on interface types. That wouldn't be changed at all if built-in types had methods.
@rogpeppe Correct, it's just an observation and contextualization of my own, not an actual quotation with attribution. Sorry for not making that clearer.
I'm afraid that ship has already sailed
Hence why I wrote "further." Allowing type variables to be converted to interfaces is a mistake, in my opinion, which is why I prohibited it in my own generics design. We shouldn't make it worse. I doubt we could find a single type system theory expert who would support further breaking parametricity in Go. We already have a way to vary behavior based on type arguments: constraints.
The Go authors could have chosen a stronger abstraction for interface types that did not allow downcasting to more specific types, but they didn't, because sometimes we want to be able to specialise.
From what I recall, they said they did it to accommodate a union-like semantics, and they couldn't think of enough uses to justify a separate union feature. They now argue against adding unions to the language because it wouldn't be orthogonal to type assertions. So it's not about specialization, it's about two separate features that were mashed together into one. Let's not double down on that approach.
it is similarly useful to type specialisation of interface types, and thus worth having
Type assertions treat interface values as cases in a union-like semantics. This proposal is asking for a way to treat type variable values as cases in a union-like semantics. A solution would be to use interface values instead of type variable values, which was my point: string should be a Stringer.
One significant use for type switching on generic types is to provide optimised paths for some types, similar to the way that we use type switching on interface types.
We already have a way for code to behave differently depending on the type: interfaces. That's where optimizations should go.
There are fundamental trade-offs here: you can have special code per type, or you can have one code for all types. You can't have both without ruining the benefits of both. Type specialization within type abstraction undermines the point of type abstraction. If you break the abstraction, then you can't reason based on the abstraction, which was the whole point of the abstraction.
Where does it end? Should we have type abstraction within type specialization within type abstraction, where certain optimized paths for certain types for certain abstracted types can be shared? And then type specialization within that? It should end at: type abstraction and type specialization are separate things, and cannot be combined.
I should point out that Stringish is invalid: "cannot use fmt.Stringer in union (fmt.Stringer contains methods)".
From what I recall, they said they did it to accommodate a union-like semantics
If that was true, I don't believe we'd have interface type-assertions ([edit] to clarify: I mean a type-assertion where the type being asserted to is also an interface, e.g. r.(io.WriterTo) [/edit]). We also wouldn't have reflect, which really has all the same downsides type-assertions have but which is definitely useless to implement unions.
There are fundamental trade-offs here: you can have special code per type, or you can have one code for all types. You can't have both without ruining the benefits of both.
Again, I don't believe this is true, empirically. We have interfaces in Go and we have benefited from them greatly - despite also having type-assertions. Yes, some aspects of the language become harder (or even impossible) to implement, some guarantees get lost. But "ruin" seems to be going a bit far, to me.
I should point out that Stringish is invalid
I say this as one of the people who pointed out the necessity of this restriction: We'd prefer for it not to be. It's a useful concept. It really is a bug that we can't express it, albeit one we don't know how to fix.
But you also don't have to go so far. For example, it is perfectly reasonable to have
type Uint interface{ ~uint8 | ~uint16 | ~uint32 | ~uint64 }
// ToBigEndian returns a big endian representation of v.
func ToBigEndian[T Uint](v T) []byte {
var b [8]byte
switch T {
case ~uint8:
b[0] = v
return b[:1]
case ~uint16:
binary.BigEndian.PutUint16(b[:2], v)
return b[:2]
case ~uint32:
binary.BigEndian.PutUint32(b[:4], v)
return b[:4]
case ~uint64:
binary.BigEndian.PutUint64(b[:8], v)
return b[:8]
}
}
This is a reasonable function to want to write and writing it currently requires using reflect.
If that was true, I don't believe we'd have interface type-assertions ([edit] to clarify: I mean a type-assertion where the type being asserted to is also an interface, e.g. r.(io.WriterTo) [/edit]).
It's what a Go Team member said to me on Reddit, and I think I've seen the Team say it again once or twice since then, if I remember correctly. I'm not basing this on supposition. They have flat-out said something to the effect of, "the only case we could think of for [union] types was go/ast.Expr, so it didn't seem worth it to include them."
I don't see how this is related to the point about type specialization. We're not talking about converting one type variable to another.
Again, I don't believe this is true, empirically. We have interfaces in Go and we have benefited from them greatly - despite also having type-assertions.
You've taken that quotation out of context.
This is a reasonable function to want to write and writing it currently requires using reflect.
We don't need either of those things:
type Uint8 uint8
func (x Uint8) BigEndian() []byte {
var b [8]byte
b[0] = byte(x)
return b[:1]
}
func UseBigEndian(b BigEndian, w io.Writer) {
w.Write(b.BigEndian())
}
// UseBigEndian(Uint8(123), os.Stdout)
Or, even better, with uint8 being a BigEndian-er, like string should be a Stringer:
// UseBigEndian(uint8(123), os.Stdout)
They have flat-out said something to the effect of, "the only case we could think of for [union] types was go/ast.Expr, so it didn't seem worth it to include them."
Oh, yes, sure. But "we don't need union types because we have interfaces and type switches/assertions" is an extremely different statement to "we only have type switches/assertions to use them as union types" (and to be clear, both are paraphrased simplifications).
You've taken that quotation out of context.
Perhaps. I didn't intend to do so in a way that distorts the meaning.
My understanding was that you argue that type parameters provide polymorphism ("one code for all types") and this issue is about providing type-switches over them ("special code per type") and that adding the second would "ruin" the first. However, interfaces also provide polymorphism and they already have type-switches/assertions.
It doesn't seem a stretch to me to put these two in direct comparison and draw from the experiences we made with one to make predictions about how the other will work out.
But maybe I misunderstood your point?
We don't need either of those things: […]
ISTM you are arguing that my example is not a reasonable function to want to write. With which I obviously disagree. I don't think your counterpoints are coming close to providing the same utility and I don't believe they fit well into the reasoning we applied to the generics design overall.
Your type Uint8 uint8 example would require boilerplate, extra conversions and wrapper functions in cases. And, importantly, if we where fine with that approach, we would not have added type sets (and the ~T operator), as we wouldn't have had a need for generic functions to support operators. One of the main motivations behind that was supporting writing a generic Max function which works with the builtin ordered types. I don't think my ToBigEndian function is particularly different from that.
And having uint8 implement a custom BigEndian interface requires predeclared types to have methods, which again, type sets where created to avoid. We could've put Add/Sub/Less/… methods on them and wouldn't have had a need for generic functions to use operators - and even had been able to write a Max function that works on composite and predeclared types.
Secondly, even if we where okay with predeclared types having methods - we can't put methods on them for every third party concern someone might have. If we put a BigEndian method on them, we'd also have to put a JSON, Varint, Base128Varint, CapnProto,… methods on them. That can't work. The way functional languages do that is by not restricting the implementation of interfaces/type-classes/traits… to the package owning the type. But Go made a conscious decision not to do that.
So, I don't really think those approaches work for Go. They contradict clear precedent set by the language design so far.
Of course, it's also not unreasonable to just say "you can't write that ToBigInt function, then". There are lots of reasonable functions you just can't write in Go because we are lacking the features and we are okay with that. I was just trying to show that type-switches are not only useful for optimization, but that they also are the only way (except reflect) to even write some reasonable code.
While we're talking about use cases for type switches on polymorphic types, here's another use case I've come across: providing default functionality for some known types.
Here's an example derived from some actual code that I wrote for a generic hash data structure:
package hash
type Config[Key, Value any] struct {
// Equal reports whether two keys are exactly equal.
// If this is nil and Key is comparable or has underlying type
// []byte, a default equality function will be provided.
Equal func(Key, Key) bool
// Hash returns a hash sum of the key.
// If this is nil and Key has underlying type []byte or string,
// a default hash function will be provided.
Hash func(Key) uint64
}
// New returns a new Map instance that stores keys
// of type Key associated with values of type Value.
//
// The functions in cfg provide equality and hash functions
// for the Key type.
func New[Key, Value any](cfg Config[Key, Value]) *Map[Key, Value] {
if cfg.Equal == nil {
switch type Key {
case comparable:
cfg.Equal = func(k1, k2 Key) bool {
return k1 == k2
}
case ~[]byte:
cfg.Equal = func(k1, k2 Key) bool {
return bytes.Equal([]byte(k1), []byte(k2))
}
default:
panic("Equal function required")
}
}
if cfg.Hash == nil {
switch type Key {
case ~string:
cfg.Hash = stringHash[Key]
case ~[]byte:
cfg.Hash = bytesHash[Key]
default:
panic("Hash function required")
}
}
// etc
}
var seed = maphash.MakeSeed()
func stringHash[T ~string](x T) uint64 {
var h maphash.Hash
h.SetSeed(seed)
h.WriteString(string(x))
return h.Sum64()
}
func bytesHash[T ~[]byte](x T) uint64 {
var h maphash.Hash
h.SetSeed(seed)
h.Write([]byte(x))
return h.Sum64()
}
We already have a way for code to behave differently depending on the type: interfaces. That's where optimizations should go.
There are fundamental trade-offs here: you can have special code per type, or you can have one code for all types. You can't have both without ruining the benefits of both. Type specialization within type abstraction undermines the point of type abstraction. If you break the abstraction, then you can't reason based on the abstraction, which was the whole point of the abstraction.
All this applies to the original interface types and values too. I'm not saying your reasoning is wrong, because clearly type specialisation does undermine type abstraction to a degree, but I am saying that Go chose its path a long time ago. Allowing type specialisation on type parameters does no more than continue to follow the same path.
That is, we can already specialise on types, whether they're type parameters or not. This proposal is about making type specialisation for type parameters a little more general, ergonomic, and less error-prone.
My understanding was that you argue that type parameters provide polymorphism ("one code for all types") and this issue is about providing type-switches over them ("special code per type") and that adding the second would "ruin" the first. However, interfaces also provide polymorphism and they already have type-switches/assertions.
Type parameters provide parametric polymorphism, and type-variable type switches would be within them and provide ad-hoc polymorphism within them without external declaration, and yes, that would ruin the parametric polymorphism. Specifically, it would ruin a valuable logical property of it called parametricity, which I mentioned above.
Interfaces don't provide parametric polymorphism, they provide ad-hoc polymorphism, and as constraints, they are declared as part of the parametric polymorphic type, so the user can see that the type arguments provide those behaviors, and even inside the parametric polymorphic code, the code is still behaving the same way, it's just that how certain behaviors within that code are implemented can vary along a well-understood contract.
It doesn't cause (a lot of) harm to enable additional ad-hoc polymorphism (interface type switches) for an ad-hoc polymorphic feature (interfaces). It does cause harm to enable ad-hoc polymorphism (type-variable type switches) for a parametric polymorphic feature (type variables) when it isn't declared as part of the parametric polymorphic type to indicate to users how the code will behave.
If this proposal changes to require adding a specification to the type parameter declaration that indicates all the type cases matched against it, then it would be better, because it would be vaguely similar to constraints in that it would indicate that behavior will be different for those types. I would still object, but it would be better.
You can find constrained, opaque type variables in any type system worth its salt. Philip Wadler, the guy the Go Team partnered with on Featherweight Go, who designed generics for Java and type classes for Haskell, figured this out 25 years ago. There is a vast, vast academic literature on the topics of type theory, type systems, polymorphism, types and logic, and so on. If the supporters of this proposal really think they can do better than Wadler and the Go Team, who were informed by decades of theory and practice, then go ask Wadler what he thinks about this proposal. I think he'd tell us to follow Featherweight Go.
I don't think your counterpoints are coming close to providing the same utility and I don't believe they fit well into the reasoning we applied to the generics design overall.
Can you substantiate this?
Your type Uint8 uint8 example would require boilerplate, extra conversions and wrapper functions in cases.
Correct. It would also preserve (what's left of, apparently) the integrity of the parametric polymorphic type abstraction. A prime element of the design of methods in Go is that if you want to add a new method to the method set of an existing type in another package, you have to implement that method set yourself in your own type, preferably with the convenience of struct embedding. That's how methods were designed to work.
And, importantly, if we where fine with that approach, we would not have added type sets (and the ~T operator), as we wouldn't have had a need for generic functions to support operators. One of the main motivations behind that was supporting writing a generic Max function which works with the builtin ordered types. I don't think my ToBigEndian function is particularly different from that.
Type sets were added for constraining type arguments based on available built-in operations in the same way that basic interfaces can constrain type arguments based on available methods. They weren't added for enumerating specific valid type arguments. The example in the proposal is not about leveraging a byte operation, or a slice operation, it's leveraging the knowledge of what the concrete type is to do some things and not others. That's not the same thing.
If there are multiple codes for different types, you just have to put them in different functions. If they can conform to the same signature, then you can abstract them with an interface.
func Concat(s []string) string {
return strings.Join(s, "")
}
func ConcatStringer[S fmt.Stringer](s []S) string {
var buf strings.Builder
for _, str := range s {
buf.WriteString(str.String())
}
return buf.String()
}
And having uint8 implement a custom BigEndian interface requires predeclared types to have methods, which again, type sets where created to avoid.
Again, that isn't why type sets were created, but yes, I'm saying that built-in types having methods like String solves the problem described in the proposal. I haven't seen anyone contradict that. I'd be very interested to see a counterexample.
We could've put Add/Sub/Less/… methods on them and wouldn't have had a need for generic functions to use operators - and even had been able to write a Max function that works on composite and predeclared types.
We could've, and should've. It sounds good to me (with associated function inlining/replacement where appropriate). Then we wouldn't have needed type sets, core types, type unions, and non-basic interfaces, which really complicated the language.
Secondly, even if we where okay with predeclared types having methods - we can't put methods on them for every third party concern someone might have. If we put a BigEndian method on them, we'd also have to put a JSON, Varint, Base128Varint, CapnProto,… methods on them. That can't work. The way functional languages do that is by not restricting the implementation of interfaces/type-classes/traits… to the package owning the type. But Go made a conscious decision not to do that.
Right. Only methods for stdlib concerns and common concerns should be added to stdlib types. I didn't mean to say that uint8 should have every method under the sun. For something custom like MyThinger, the way to do it would be to make your own type that wraps uint8, like I showed. See above regarding method sets.
I was just trying to show that type-switches are not only useful for optimization, but that they also are the only way (except reflect) to even write some reasonable code.
Parameterized types aren't meant for optimizing or otherwise customizing code per type. That's what separate functions, methods, and interfaces are for. Abstracted types are for code where types can be abstracted. As I said before, you can't have it both ways without losing the benefits of both: you don't have the single, parameterized code you could have had; and you don't have the separate, optimized code per type you could have had.
Couldn't Key have Equal and Hash methods in that example?
All this applies to the original interface types and values too. I'm not saying your reasoning is wrong, because clearly type specialisation does undermine type abstraction to a degree, but I am saying that Go chose its path a long time ago. Allowing type specialisation on type parameters does no more than continue to follow the same path.
Interface values are different than type variable values because the language explicitly included features from the start that enabled function behavior to change based on an interface value's concrete type or its method set. Everyone understands this is the case. This is not so for type variables. Users assume (correctly, currently) that a parameterized type means a parameterized implementation. Where ad-hoc polymorphism is permitted within the parameterized, it must be declared as a constraint, where users can see it and reason about it. These are different paths. Interface values going down one path doesn't affect the path of the other. I understand seeing the similarity, and seeing potential in feature parity, but it's not the same.
That is, we can already specialise on types, whether they're type parameters or not.
Only within ad-hoc polymorphism, not parametric polymorphism, and without declaration. See my response to @Merovius just above regarding this.
A generic feature that is currently missing in Go is enabling functions and methods to extend constraints. That might help address similar needs:
package builtin
type Slice[T any]
func (s Slice[string]) Concat() string { // Instantiates with string
return strings.Join(s)
}
func (s Slice[T]) ConcatStringer[T fmt.Stringer]() string { // Constrains T with fmt.Stringer
var buf strings.Builder
for _, sw := range s {
buf.WriteString(s.String())
}
return buf.String()
}
@willfaught First, your tone is borderline condescending. That really disincentivizes me from engaging. Please remain civil.
type-variable type switches would be within them and provide ad-hoc polymorphism
I don't fully agree. But I understand and agree with the core of your argument (that's what I meant when I said "you lose certain guarantees"), so sure.
Interfaces don't provide parametric polymorphism, they provide ad-hoc polymorphism
No, they provide subtype polymorphism. I think this changes this argument a lot, as the likeness-to-likeness argument is no longer between interfaces and type switches, but it's instead between interfaces and type parameters:
It doesn't cause (a lot of) harm to enable additional ad-hoc polymorphism (interface type switches) for an ad-hoc polymorphic feature (interfaces). It does cause harm to enable ad-hoc polymorphism (type-variable type switches) for a parametric polymorphic feature (type variables) when it isn't declared as part of the parametric polymorphic type to indicate to users how the code will behave.
Seems a clear enough argument. And I don't see why the harm in one case would be bigger than the other. I think if you want to make the argument that it is, you should provide some actual example of such harm.
In general, I get the impression that your argument relies a lot on theoretical considerations, while we tend to provide specific examples and real code we want to be able to write. It's not that theory isn't helpful. But Go has always been a very engineering-focused language. Theory can and should inform solutions, but at the end of the day, enabling people to do their job has always been more important for Go than purity of language design.
Type sets were added for constraining type arguments based on available built-in operations in the same way that basic interfaces can constrain type arguments based on available methods. They weren't added for enumerating specific valid type arguments.
I believe they where added to do both. The Pointer method, byteseq, SliceConstraint and Stringish (yes, I know it is invalid, but it was part of the motivation) examples all rely on the idea that constraints can list a specific set of valid types. So I don't believe it is a defensible statement to say this wasn't a significant part of the motivation.
In particular the pointer method example is one that had me begrudgingly accept the necessity of type sets, even if I don't like them.
I don't think you responded to the core argument I made, though. Which is that if we're fine with the boiler plate involved in having to declare a custom type and a method, we wouldn't have needed type sets. We could've written a generic Max as e.g.
type Ordered[T] interface { Less(T) bool }
func Max[T Ordered[T]](a, b T) T {
if a.Less(b) { return b }
return a
}
type MyInt int
func (i MyInt) Less(j MyInt) bool { return i < j }
func main() {
fmt.Println(Max[MyInt](42, 23))
}
We could've, and should've.
But we did not. You might disagree with the decisions made when designing generics - and FTR, I tend to agree with you - but they are the decisions we made and the arguments we made them on are still relevant to inform our way forward, if we want to design a consistent language. In other words, "we could have and should have" doesn't invalidate the argument that we did not and it does not make the reasons we did not disappear. It doesn't change the fact that the alternatives you suggest are in tension with the reasoning to design generics as we did.
Users assume (correctly, currently) that a parameterized type means a parameterized implementation.
If they assume, that assumption is not correct. This is, at the end of the day, valid Go code:
func Contains[T comparable](s []T, v T) bool {
if _, ok := any(v).(string); ok { panic("just because I'm a mean person") }
for _, w := range s {
if w == v { return true }
}
return false
}
That's an important argument @rogpeppe was making: The guarantee of parametricity is already broken, even for type parameters. This proposal doesn't break any further guarantees. And, like it or not, people are already writing code which does use these kinds of type-switches. This proposal aims to at least 1. make such code more correct (by allowing to match on underlying types as well) and 2. make it safer (by adding type information in the case blocks).
I understand and agree that "the guarantee is already broken" is not an argument that "we might as well add more of a bad thing". But it does mean that "we break the guarantee" isn't a reason not to do something which has tangible benefits.
Just a two-cents opinion here to add to the pile.
As a user, I would like to be able to do something akin to the following (appreciating contrived example is contrived):
package main
import "fmt"
type val interface{ ~int | ~string }
func AddOne[T val](input T) T {
switch x := input.(type) { // nope
case ~int: // nope
return T(int(x) + 1) // nope
case ~string: // nope
return T(string(x) + "1") // nope
}
panic("argh!!")
}
func main() {
fmt.Println(AddOne("boo"))
fmt.Println(AddOne(5))
}
...but am scuppered by:
1. the lack of support for type-switch within the constraint type. switch x := any(input).(type) works in some cases, but surely the loss of specificity makes later cases harder to determine.
2. the lack of syntax for case ~int. case int would also fine, if T(x) were allowed to work
3. the lack of assignment back from the switched type to the constrained type
Now, I could understand if this were to fail if val was type val interface { int | string | bool } because the bool case is not covered.
But where all outcomes are covered (as in the above code), there should be no issue with this.
I guess I'm just tired of the proliferation of GetInteger, GetString, GetBool-type APIs throughout my code. I want to be able to just offer up Get[T interface { int | string | bool }] and cover the int, string and bool cases, without having to resort to reflect-based pain.
I can already do this for the Set[T interface { int | string | bool }] API (abeit with the switch x := any(x).(type) approach).
Perhaps the notion of coverage is too hard to determine...
cal you elaborate on "how to write that normally", please?
func TypeCaster[Tout, Tin any](arg Tin) Tout {
return (interface{})(arg).(Tout)
}
// get query retriever with type, deducting from default value
func QueryGet[T any](query url.Values, key string, def T) T {
arg := query.Get(key)
if arg == "" {
return def
}
switch (interface{})(def).(type) {
case string:
return TypeCaster[T](arg)
case int:
res, err := strconv.Atoi(arg)
if err != nil {
return def
}
return TypeCaster[T](res)
case uint:
res, err := strconv.Atoi(arg)
if err != nil {
return def
}
return TypeCaster[T](uint(res))
case uint64:
res, err := strconv.ParseUint(arg, 10, 64)
if err != nil {
return def
}
return TypeCaster[T](res)
case int64:
res, err := strconv.ParseInt(arg, 10, 64)
if err != nil {
return def
}
return TypeCaster[T](res)
case float64:
res, err := strconv.Atoi(arg)
if err != nil {
return def
}
return TypeCaster[T](uint(res))
case bool:
res, err := strconv.ParseBool(arg)
if err != nil {
return def
}
return TypeCaster[T](res)
default:
return def
}
}
@xakepp35 I would probably write that using reflect. Again, reflect still exists and there is nothing wrong with using it, until we get something like this proposal. There is no need to shoehorn generics into code which works easier using reflect.
I disagree! Wasn't the whole idea in the first place about not having to resort to error-prone reflection but having a type-checking compiler create binary code without unnecessary overhead?
The reflect package would still have its usages - there are packages/functions that can't make assumptions on the type of data that comes in.
Since with parametric types (within generic functions) the runtime knows exactly the type of the underlying data when a function is called, it makes sense to me that a switch would be available to distinguish between the different types and handle them differently without having to rely on reflection.
@ungerik I do not understand the point you are trying to make. I don't believe the code @xakepp35 posted is any less error-prone than the reflect based code. It might have a little less overhead, but really, I don't think a significant amount. I also don't think you can do significantly better than the code shown by @xakepp35 with the language as it is today. So, I find it very hard to argue that those goals are served by avoiding reflect here.
Note that I was not arguing against this proposal or a change to the language to write code like that in a safer or clearer way. I was simply trying my best to interpret and answer the question posed. And my answer is that right now, code like that should just use reflect.
reflect is inefficient and only to be used if you can't do any other way
I would probably write the following or this one. It's 27% faster than @xakepp35 and 57% faster than @Merovius code. And It doesn't allocate any memory.
// get query retriever with type, deducting from the default value
func QueryGet[T string | int | int64 | uint | uint64 | float64 | bool](query url.Values, key string, def T) T {
arg := query.Get(key)
if arg == "" {
return def
}
switch v := any(&def).(type) {
case *string:
*v = arg
case *int:
res, err := strconv.Atoi(arg)
if err != nil {
return def
}
*v = res
// ...
case *bool:
res, err := strconv.ParseBool(arg)
if err != nil {
return def
}
*v = res
}
return def
}
The result of the benchmark is the following:
goos: darwin
goarch: arm64
pkg: example
Benchmark_QueryGet-8 59582422 19.76 ns/op 0 B/op 0 allocs/op
Benchmark_QueryGet_UsingTypeCaster-8 42332832 26.81 ns/op 48 B/op 1 allocs/op
Benchmark_QueryGet_UsingReflection-8 27847395 42.08 ns/op 56 B/op 2 allocs/op
PASS
ok example 4.849s
It's good to mention that using any (the second link) as a type parameter has some limitations and we can't use user-defined type (e.g., type Count int).
cal you elaborate on "how to write that normally", please?
@xakepp35 - if that question was directed to me, then any example I give you is going to better-demonstrate my lack of familiarity with reflect than the issue in hand. [EDIT: @Merovius's code is close to what I currently have]
Suffice to say, however, that I believe that, when I tried a switching over int, string, etc, the type-switch did not match the ~int and ~string cases that I was also hoping for. It may well have been me doing it wrong.
type Foo int
func main() {
var foo Foo = 3
switch (any(foo)).(type) {
case int:
fmt.Printf("foo is ~int: %v\n", foo)
default:
fmt.Printf("foo is %T, by default: %v\n", foo, foo)
}
}
# want: foo is ~int: 3
# got: foo is main.Foo by default: 3
I ended up down a rabbit-hole, taking a reflect.ValueOf(&outVar) and switching on various reflect.Kind() and reflect.Elem().Kind() to cover my cases, and I still probably missed something.
Given a type Foo interface { int | string } and a func DoIt[T Foo](T in) T, any attempt to return T("foo") is met with cannot convert string to int (in T), even if the code path wouldn't have returned an int.
type Foo interface{ string | int }
func DoIt[T Foo](in T) T {
switch in.(type) {
case int:
return T(0xdeadbeef)
case string:
return T("newstr")
}
return in
}
func main() {
fmt.Println("Hello", DoIt(1))
}
# cannot use type switch on type parameter value in (variable of type T constrained by Foo)
# cannot convert "newstr" (untyped string value) to type T: cannot convert "newstr" (untyped string constant) to int (in T)
I get why (because the (compiler|linter|?) isn't going to know for all values of an input parameter that the code path won't reach the int case), but it sure would be nice to be able to say it's OK, I've got this (and/or panic if I don't)
I believe @empire's use of a pointer may currently be the best way to return a T. In my reflect-based mess I wrapped the value of a named return value, and got reasonable results (functional, not yet benchmarked). Reviewing @empire's code suggests I may not have needed to reflect on it, and could have assigned directly to *retval, but I've not yet tried.
I have also not yet attempted to rework to use @xakepp35's converter func approach.
At first glance I'm not sure either works with the ~int case.
@jimbobmcgee Correct, if you need to handle ~int and similar cases, you have to resort to reflect.
@Merovius :: I figured as much. Thanks.
Do you know of a "clean" way to switch on ~int using reflect? In my limited-support cases, I've boiled it down to if val.Kind() == reflect.Int, but is that the best approach?
I think val.CanInt() works for the ~int case -- it looks like it uses the switch-Kind, so I assume it is right for the "specialised" int.
I'm not sure switch-Kind approach holds literally, for all types, though. Structs, for example, are val.Kind() == reflect.Struct, regardless of whether it is type A struct{}, type B struct { Foo int }, type C B or type D = C. There's no language support for ~ with struct-types though (as of v1.19), so maybe that doesn't matter, and using an equality check against val.Type().String() would be adequate.
PS. is there a formal term for a "specialised" type, i.e. type Foo int? I don't think it is an alias, that's type Foo = int, but my Google-fu is letting me down...
Do you know of a "clean" way to switch on
~intusing reflect? In my limited-support cases, I've boiled it down to ifval.Kind() == reflect.Int, but is that the best approach?
Yes, that is the correct approach, they mean the same thing.
I think
val.CanInt()works for the~intcase
CanInt returns true for more cases, namely all signed integer types. So it actually is constraints.Signed.
I'm not sure switch-Kind approach holds literally, for all types
Correct, switching on Kind only works for "primitive" types. For composite types, you need to manually write more concrete checks. e.g. for ~*T you have to write if t.Kind() == reflect.Pointer && t.Elem() == reflect.TypeOf(new(T)).Elem(), for ~chan T you have to write t.Kind() == reflect.Chan && t.Elem() == reflect.TypeOf(new(T)).Elem() && t.ChanDir == reflect.BothDir and for func and struct you have to write complex code, iterating over argument/returns and verify that they match the right types and names in the right order (especially for structs, this is fairly error-prone). TBF, ~T constraints for these composite types should be fairly rare in practice.
There was a proposal once to add a way to get the underlying type from a reflect.Type, which would make all of this simpler. But it was rejected due to implementation cost and lack of clear use cases. There might be a case to be made to reconsider that, in light of generics (though it can be useful in non-generic code as well). Perhaps in addition to this proposal.
There's no language support for
~with struct-types though
PS. is there a formal term for a "specialised" type, i.e.
type Foo int?
The spec knows the term "defined type" for Foo. int itself is also a defined type - you can distinguish that by saying "preclared defined type". There is no established term for a defined type that is not predeclared, as far as I know (which is why I used the quoted "primitive" above).
FWIW the basic point I was making is "you can express essentially any constraint over types you like in terms of reflect, so it is always available as a fallback, until something like this proposal is accepted/implemented".
I think discussing the capabilities of reflect beyond that - or the details of how to use it - is a little bit off-topic, as I don't think anyone is trying to argue that reflect existing negates the usefulness of this proposal.
There's no language support for
~with struct-types though
Thanks for this -- I had been trying type A struct{Foo int}; type B interface{ ~A } to no avail, and incorrectly assumed. I do note however, that type A interface { ~struct{Foo int} } does not seem to match type B struct { Foo int; Bar int } and it would be nice if it did (or it would be nice to be able to express the intent), but that is for another discussion/proposal.
I think discussing the capabilities of
reflectbeyond that - or the details of how to use it - is a little bit off-topic
Agreed.
On-topic, you've already made the cases for both supporting case ~T and extending this to switch t := t.(type) -- and I include my vote for both (for what it is worth!) -- so I'll bow out here. Thanks for your time.
This proposal has been added to the active column of the proposals project
and will now be reviewed at the weekly proposal review meetings.
— rsc for the proposal review group
Just a little note/question since this concern got buried in the length of this thread:
- do we want compiler-checked exhaustiveness for unions? (possibly with a way to opt-out?)
Basically, a way to enforce the addition of subcases to switch cases?
The idea being that instead of relying on source code order, it would allow for commutativity of union terms. (possibly important for interface equality)?
That's mostly a concern when type set intersection between union terms is non-empty.
It might be annoying to have to exhaustively write code to handle every case but the advantage is that it will naturally highly discourage complicated interfaces which are checked in EXPSPACE.
Just wondering.
The idea being that instead of relying on source code order, it would allow for commutativity of union terms. (possibly important for interface equality)?
As you mention yourself, the intersection of union terms can be non-empty, so I don't believe that's reasonably possible:
func F[T ~int|int]() {
switch T {
case ~int:
fmt.Println("~int")
case int:
fmt.Println("int")
}
}
func main() {
F[int]()
}
This gets exarcerbated if the cases can be interfaces. The original proposal uses string|fmt.Stringer, which doesn't work, but there is no reason you shouldn't be able to do
func F[T any](v T) string {
switch T {
case fmt.Stringer:
return v.String()
default:
return fmt.Sprint(v)
}
}
Using source-code order is how every other switch statement works and it seems far more reasonable to stay consistent with that.
I don't understand your point.
What is problematic about having a subcase for int in the ~int branch? (no need for the mirror check in the int branch with the aptly-named approximation element)
Because otherwise, there is still the issue of interface{int | ~int}being treated differently from interface{~int | int} while they should be the same interfaces.
This problem is a bit different from checking for Reader and ReaderWriter in the same type switch.
Because otherwise, there is still the issue of
interface{int | ~int}being treated differently frominterface{~int | int}while they should be the same interfaces.
Where are you getting that from? There is no difference between those.
There isn't/shouldn't.
However, the type parameter switch will treat the type parameter differently depending on whether int was satisfied first or `~int was.
That's more obvious an issue with interface{~string | fmt. Stringer} perhaps.
Edit: Oh wait... Yes there is a restriction on basic interfaces in unions... At least for now.
I guess another future-proofing concern. (exhaustive pattern matching and decomposition)
However, the type parameter switch will treat the type parameter differently depending on whether
intwas satisfied first or~intwas.
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?
Edit: Oh wait... Yes there is a restriction on basic interfaces in unions... At least for now.
I guess another future-proofing concern. (exhaustive pattern matching and decomposition)
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 T is constrained on int|~int or on ~int|int. The two interfaces are identical, as they should be.
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.
I think that's fine.
My issue is case satisfaction ambiguity.
For instance, all your examples depend on the coder writing the more specific case first.
This is more an issue if someone wants to add a case to the switch. Now in which position within the switch is it the least ambiguous?
We could eschew the problem with enforced term decomposition.
It has some merits, one being that the order in which the cases are written wouldn't matter . (no forward compatibility issue, could even allow program transformations to increase the performance of these type switches etc).
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.
Your input is acknowledged, however. :)
@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]
do we want compiler-checked exhaustiveness for unions? (possibly with a way to opt-out?)
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 switch type with a default just to make it go away, and then you haven't really achieved anything.
Of course, the fact that you could cover with a default may mean that compile-time failure is worth having -- at least there would be a way to "opt-out".
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:
type T { int | string }
switch T {
case int:
DoSomething()
}
...should not have to become...
switch T {
case int:
DoSomething()
default:
}
The problem is not exactly with source order execution.
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 case in lexical order makes logical sense, and is in keeping with existing switch logic.
(I suppose select is a similar structure which, by rights could appear to evaluate case out-of-order, but we are not discussing select T...)
We could eschew the problem with enforced term decomposition.
You are almost certainly going to have to clarify "term decomposition", because it reads like you want switch to support...
switch T.(doWhatIMeantWhenISaidIt) {
case ~string:
case io.Stringer:
}
...and, if you mean that, then I'd like to propose we broaden this proposal to replacing func main() with func doWhatIMeantWhenISaidIt(), and never writing anything else ever again. :-)
@atdiar Regarding...
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()
}
...I would be very concerned if indentation was a deciding factor in the nesting of switch-case.
If you want to be able to nest decisions on types, you would surely have to do:
switch i:=v.(type) {
case ~string:
switch i := i.(type) {
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()
}
Of course, it may then hold that switch i := i.(type) should be allowed to be evaluated for any type, not just for interface{} and that the result of case ~string be of an anonymous type that also implements all of the not-eliminated types.
Instead, I think the straightforward way to get this over the line would be for...
switch i:=v.(type) {
case ~string:
// i is a string
case fmt.Stringer:
// i is is a fmt.Stringer
}
...and that any extra interfaces are sadly lost (to i), once we reach the case that applies.
You could (should) still be able to do...
switch a := v.(type) {
case ~string:
// a is a string
switch b := v.(type) {
case fmt.Stringer:
// b is a fmt.Stringer
}
case fmt.Stringer:
// a is is a fmt.Stringer
}
...if you had to -- you would just have to juggle your use of a and b as necessary.
A terser syntax, such as case ~string && !fmt.Stringer: might have benefits, although I'm not sure what the re-typed variable becomes in all cases (possibly string for this case; but what for just case !fmt.Stringer:?). Almost certainly for another proposal, though...
@jimbobmcgee
If you add a term to a union, it broadens the set of acceptable type arguments so I don't think it changes backward-compatibility?
switch T {
case fmt.Stringer:
return v.String()
case ~string:
return string(v)
}
The case of the type parameter satisfying both fmt.Stringer and ~string is underspecified.
Ideally, there should be a subcase for fmt.Stringer (that can be a default/catchall subcase for that branch).
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.
Instead of case, one could use subcase as a keyword perhaps if ultimately required.
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.
Wrt readability, that's a syntactic issue most likely. Presumably, something akin to
That code is incorrect, under your proposal. ~string and fmt.Stringer are not disjoint cases - there are types implementing fmt.Stringer, which have underlying type string. Furthermore, you do not cover the case of a type being neither (which is necessary, as ~string|fmt.Stringer is not a valid constraint). In particular:
// vs what you propose which does not handle every case
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 String method if it exists, it uses a simple conversion if possible and it falls back to a reflect based alternative if not. That was the point of the example - to show that the natural code is easy to write and read and understand.
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 default was the escape hatch that allows for such code to be valid without all the combinations being explicitly considered
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.
@atdiar I understand what you meant, but what you meant is wrong.
I'm not sure you do.
A type may satisfy fmt.Stringer... But what if it also satisfies ~string? Is this subcase handled in your code or do we just stop at fmt.Stringer?
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 interface{ ~string; fmt.Stringer }, before realizing that's disallowed and changing it back)
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 you add a term to a union, it broadens the set of acceptable type arguments so I don't think it changes backward-compatibility?
If the switch is required to be compile-time exhaustive (i.e. by which I take to mean "must minimally have a case that is true for every member of the union"), then adding a new type to the union requires adding an extra case to all switches that reference it.
If you don't own that union (i.e. it is in another package or in the stdlib, such as constraints.Ordered), then someone else making that change in that package creates the requirement for you to update your switch (unless you peg/vendor your dependencies).
A default clause may allow you to react to uncovered cases (and therefore opts out of the compile-time check) but at best, you're going to log, panic or return error; at worst you're going to silently fail.
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.
Instead of
case, one could usesubcaseas a keyword perhaps if ultimately required.
Still not sold on needing a nested subcase, over merely nesting a second switch. What if you needed a third level? Would you subsubcase?
That said, on the subject of nesting, I do see the potential need to open up type-switching to more than interface{} types. If each branch of the switch eliminates the unmatched types, writing a nested switch becomes more difficult to do (or at least a little more smelly) if you have to keep casting back to interface{} at each level.
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.
I think you can always have the default clause, I just don't think you can necessarily type-eliminate in its code path. In the same way you can always...
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 default before I understood your concern. I'll leave you to consider.
Some of this may depend on whether we are talking about switch T or switch aValueOfT.(type). I know I have a preference for the latter, so my views might be coloured by that. Luckily for everyone else, I'm just a voice on the internet!!
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 comparable or interfaces with methods as switch cases. Which is a pretty huge restriction.
It just so happens that I also think it would be a bad idea, even if it was computationally feasible.
@Merovius
Well you do embed, simply not the type switch cases but by defining a qconstraint that use embedding.
You are still depending on being able to find the right ordering of case which is obvious here but unlikely to be obvious in general. You would have to define many more new constraint and think about ordering if that is even possible.
Note
I know it would be tedious to write all cases hence, why I mentioned that it could deter writing unnecessarily complex interfaces, which could actually be a benefit. That's just an idea
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
You're right. I was thinking about that too. As long as there is a type parameter switch somewhere, it would freeze the definition of the constraint.
Even if we could add a new case to every type switch, that would still not be backward-compatible.
@atdiar I had to revert an edit of that example back, because I forgot that ~string|fmt.Stringer is disallowed, so the first paragraph is no longer accurate. On the other hand, the argument remains the same and effective in the original form.
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.
Your example is nice. But with more constraints that are overlapping, you would have more constraint intersections to define to be handled in the type switch and I don't know how I would order them. Not sure it is tractable without nesting. (just consider adding a ReaderStringer interface as a case of the type parameter switch for example)
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.
But all things considered, I'm not sure exhaustiveness is that flexible anyway.
@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 a.C would break b.G. So, the backwards compatibility concern is a red herring.
I'm not interested in bickering back and forth at this point, sorry.
The point is that exhaustive checking is not very useful here.
And that although adding a term to a union interface is not backward-incompatible by itself (which I said before), everyone else would have understood that it mixes badly with backward-compatibility.
You just showed one other example of such a case which is great.
What else do you still want to argue about? It's pointless. :)
[edit] And to be fair, if I'm not mistaken, I think you had a good hunch but unfortunately didn't develop it
Seems to me that it is possible to be exhaustive without subcases for a union.
It simply requires to build the intersections between terms of the union in decreasing specificity order:
for a | b | c
we would have:
case a x b x c
case a x b
case a x c
case b x c
case a
case b
case c
where a,b,c are constraints. (case with the same number of embeddings, symbolized here by x, should be cases with interchangeable positions if I'm not wrong.
No need for a default case here. In a real union, a lot of cases would even disappear.
But it needs explaining since it is not necessarily obvious to everyone. It should influence how people write their switch.
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,)
This seems to me that the difficulty of SAT Solving is about Exponential time complexity but here the main issue is Exponential Space complexity which is much more mitigable (unless someone could and would create a union with thousands of basic interface types, but good luck to that person ;)
And that although adding a term to a union interface is not backward-incompatible by itself (which I said before),
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 int32) to I1 in the code below, which will cause the code to fail to compile:
type I1 interface {
int8 | int16
}
type I2 interface {
int8 | int16
}
func f[T I1]() {
g[T]()
}
func g[T I2]() {
}
@rogpeppe That's basically the same example I mentioned here, FWIW.
It simply requires to build the intersections between terms of the union in decreasing specificity order:
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.
This seems to me that the difficulty of SAT Solving is about Exponential time complexity but here the main issue is Exponential Space complexity which is much more mitigable
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.
— rsc for the proposal review group
I'm wondering- while this is out waiting for more insights- if there is any way to implement this as-is? I wish I could replace something like
var instance = struct {
meters *handler[api.Meter]
chargers *handler[api.Charger]
}{
meters: new(handler[api.Meter]),
chargers: new(handler[api.Charger]),
}
type Handler[T any] interface {...}
func Meters() Handler[api.Meter] {
return instance.meters
}
func Chargers() Handler[api.Charger] {
return instance.chargers
}
With something like this:
func Devices[T any]() Handler[T] {
switch type T {
case api.Meter:
return instance.meters
case api.Charger:
return instance.chargers
default:
panic("invalid type")
}
}
...but really don't have any idea how to do this without the proposed type switch?
@andig I don't think I understand your question. But FWIW as long as you don't need ~T, you can get by with doing any(v).(type) for type-switches and var x T = any(v).(T) for assigning back (or returning) generic variables. i.e. you can replace do anything you can do with this proposal with sufficiently many conversions to any and type-assertions, except look into underlying types. That still needs reflect.
It kind of sounds like you want heterogeneous slices though. This proposal wouldn't help you with that, you'd want #57644. You can get by that by using slices of regular interfaces.
Updated above. It seems this cannot work (at compile time), even with this proposal? Please feel free to hide or remove my comments if this gets too OT.
What is the use case for the Devices() function? It doesn't seem like an improvement to me over the existing code. The only difference in calling is that you'd replace
handlers := Meters()
with
handlers := Devices[api.Meter]()
You're still manually picking what you want to get at compile-time. The only difference is that you're giving it a type name instead of calling a different function.
Note that
go handlers := Devices[api.Meter]()
could be spelled as
handlers := Devices[T]()
in parametrized contexts, allowing for useful behavior in complex settings.
type Device interface{ api.Meter | api.Charger }
func Devices[T Device]() Handler[T] {
switch any(*new(T)).(type) {
case api.Meter:
return any(instance.meters).(Handler[T])
case api.Charger:
return any(instance.chargers).(Handler[T])
default:
panic("unreachable")
}
}
This assumes api.Meter and api.Charger are not themselves interfaces with methods. If they are, you have to use any (and yes, the panic then becomes reachable).
[edit] Added some type-assertions, based on a comment on Slack, to make the return statements work [/edit]
Alternatively,
// ...
var v T
switch any(v).(type) {
// ...
would likely reserve room for a T in the function stack instead of doing a new allocation on the heap.
@antichris I would expect neither of them to allocate anything. But either way, performance micro-optimizations seems off-topic on this issue.
Thank you! https://github.com/golang/go/issues/45380#issuecomment-1666901132 should do the trick. Sorry for the OT- let's return to main road :)
(Originally posted in #57644)
Just to add another use case for switches on type parameters, I'd love to see this to be able to implement something like this:
type MyType[T constraints.Integer | constraints.Float | string | []byte] struct {
Value T
}
func (m *MyType[T]) UnmarshalText(data []byte) (err error) {
switch type T {
case constraints.Signed:
var i int64
i, err = strconv.ParseInt(string(data), 10, 64)
m.Value = T(i)
case constraints.Unsigned:
var u uint64
u, err = strconv.ParseUint(string(data), 10, 64)
m.Value = T(u)
case constraints.Float:
var f float64
f, err = strconv.ParseFloat(string(data), 64)
m.Value = T(f)
case string, []byte:
m.Value = T(data)
}
return err
}
This would be a lot more compact than the following, which I find myself implementing decently often currently:
func (m *MyType[T]) UnmarshalText(data []byte) (err error) {
switch v := any(&m.Value).(type) { // note having to cast to any first...
case *int:
err = parseInt(data, v)
case *int8:
err = parseInt(data, v)
case *int16:
err = parseInt(data, v)
case *int32:
err = parseInt(data, v)
case *int64:
err = parseInt(data, v)
case *uint:
err = parseUint(data, v)
case *uint8:
err = parseUint(data, v)
case *uint16:
err = parseUint(data, v)
case *uint32:
err = parseUint(data, v)
case *uint64:
err = parseUint(data, v)
case *uintptr:
err = parseUint(data, v)
case *float32:
err = parseFloat(data, v)
case *float64:
err = parseFloat(data, v)
case *string:
*v = string(data)
case *[]byte:
*v = data
}
return err
}
func parseInt[T constraints.Signed](data []byte, v *T) error {
i, err := strconv.ParseInt(string(data), 10, 64)
*v = T(i)
return err
}
func parseUint[T constraints.Unsigned](data []byte, v *T) error {
u, err := strconv.ParseUint(string(data), 10, 64)
*v = T(u)
return err
}
func parseFloat[T constraints.Float](data []byte, v *T) error {
f, err := strconv.ParseFloat(string(data), 64)
*v = T(f)
return err
}
If the type switch can ensure all the option is in the case, then it would be perfect, for example:
func printType[T int | string | bool](v T){
switch type v {
case int:
println("int")
case string:
println("string")
}
// compiler throw an error here because not all possible types are present
// to fix, either add `case bool:` or `default:`
}
Edit: this probably only suitable for clear type only case, such as interface { ~bool }, interface{ A; B } shouldn't be applied, but interface{ bool | int; A } should work
@zyxkad see https://github.com/golang/go/issues/41716#issuecomment-702241484
@antichris, I don't think that comment applies here. The example given by @zyxkad doesn't have any underlying-type constraints. (It would apply if the constraint were instead ~int | ~string | ~bool.)
That said, since the ordinary switch statement in Go never requires a default case, I don`t see why this form should require one either. The increase in bugs caught does not seem worth the reduction in orthogonality.
Hi everyone, are you familiar with C++ templates? I think what you need is specialization in Go generics, not syntax like type switch.
Just like this:
type Stringish interface {
string | fmt.Stringer
}
func Concat[S Stringish](x []S) string {
return strings.Join(x, "")
}
//Here is specialization
func Concat[](x []fmt.Stringer) string {
var buf strings.Builder
for _, s := range x {
buf.WriteString(s.String())
}
return buf.String()
}
Of course, the Concat[] format might be confused with slices, so a new syntax might be needed.
This appears contrary to the concept of generic programming, which is "one code for many different types". However, this seems to facilitate "different code for different types" while concealing it behind the same function name. This practice resembles function overloading, a feature that Go deliberately avoids.
It seems to me that if you wanted to go down the route of multiple method definitions, something like this would make more sense from a Go point of view:
type MyType[T constraints.Integer | constraints.Float] {
Value T
}
// note constraint on T
func (m *MyType[T constraints.Signed]) UnmarshalText(data []byte) (err error) {
i, err := strconv.ParseInt(string(data), 10, 64)
m.Value = T(i)
return err
}
func (m *MyType[T constraints.Unsigned]) UnmarshalText(data []byte) (err error) {
u, err := strconv.ParseUint(string(data), 10, 64)
m.Value = T(u)
return err
}
func (m *MyType[T constraints.Float]) UnmarshalText(data []byte) (err error) {
f, err := strconv.ParseFloat(string(data), 64)
m.Value = T(f)
return err
}
This allows you to implement the same function for different values of the type parameter even when the function arguments don't make direct use of the type parameter, which would be important when implementing interfaces.
However I do have some concerns that make me feel that this would not be the right approach:
1. It's more verbose than a switch, since you need to repeat the function signature for each implementation.
2. It's harder to share code between implementations - with a switch you can have type-agnostic code before and after the switch, but with specialised methods you would have to move any shared code to separate methods and ensure that all type-specific methods call the shared methods correctly (although this could be remedied by having the exported method be type agnostic, and using unexported specialised methods only for the parts of the code that need to be type-specific).
3. It's unclear how to handle overlapping types - should the methods be prioritised in order of declaration, similarly to how a switch works? Or should overlapping types for different implementations not be allowed? The latter would probably result in making things harder for the developer, since Go doesn't provide a full set of set operations for type constraints. You couldn't express things like "use this implementation for time.Duration, but this other implementation for all other integers".
4. It would force the developer to ensure that every possible type has an implementation, a restriction that was already discussed and rejected for the type switch.
Regarding your 3rd point, I think the onus is on the programmer to define the intersection types derived from the overlap as their own specific case.
The onus is on the compiler to find out whether such overlaps exists which is not very difficult. (it's just checking whether one type has any definition constraint that conflicts with satisfying another type, in which case the intersection is empty).
It should be tractable to prove that an intersection is not non-empty with the way the language is designed (no general user-defined type refinements).
I don't agree it would necessarily be straightforward. Consider a case where I want to implement an UnmarshalText for all integers, but if the type T already implements TextUnmarshaler, I just want to use the existing implementation. The natural thing to write would be:
func (m *MyType[T encoding.TextUnmarshaler]) UnmarshalText(data []byte) (err error) {
// use the type's own UnmarshalText
}
func (m *MyType[T constraints.Integer]) UnmarshalText(data []byte) (err error) {
// custom implementation for integers
}
However, since encoding.TextUnmarshaler is a non-type constrained interface, it would possible for a user to define their own int-derived type which implements the interface, causing an overlap between the types. This would either have to be checked at the call site (forbidding instantiating MyType with an integer-derived type implementing TextUnmarshaler), or Go would have to forbid essentially any combination of standard and type-constrained interfaces for different specialisations - or even different specialisations for different non-type constrained interfaces - which is a huge restriction!
In principle the developer could define just the Integer version of the method, and then check if the type implements TextUnmarshaler inside the method, but if you have multiple specialisations you would have to do that check for every single one, which is a lot of repeated code. (Implementing that check itself would also still require casting the value to any in lieu of type parameter switches or type assertions on generic variables.)
And to repeat my point above, it would still not be possible to have one implementation for time.Duration and one for every other integer since it's not currently possible to express something like ~int & !time.Duration (any int except time.Duration) as a type constraint. With a switch this would be very natural to write by just having time.Duration as the first case.
For what it's worth, you wouldn't need overloading for what [@arvidfm](https://github.com/arvidfm) suggested if we allowed refined method constraints (that is, the ability to add additional methods based on a type argument satisfying additional constraints). Because we have interface type switches. That also answers the questions about handling overlaps and resolution-order (which are the main arguments against overloading), as you'd just use the source order in which the cases are mentioned:
type MyType[T constraints.Integer | constraints.Float] {
Value T
}
func (m *MyType[T]) UnmarshalText(data []byte) error {
switch m := m.(type) {
case interface{ unmarshalSigned([]byte) error }:
return m.unmarshalSigned(data)
case interface{ unmarshalUnsigned([]byte) error }:
return m.unmarshalUnsigned(data)
case interface{ unmarshalFloat(data) error }:
return m.unmarshalFloat(data)
default:
panic("unreachable")
}
}
// note constraint on T
func (m *MyType[T constraints.Signed]) unmarshalSigned(data []byte) error {
i, err := strconv.ParseInt(string(data), 10, 64)
m.Value = T(i)
return err
}
func (m *MyType[T constraints.Unsigned]) unmarshalUnsigned(data []byte) error {
u, err := strconv.ParseUint(string(data), 10, 64)
m.Value = T(u)
return err
}
func (m *MyType[T constraints.Float]) unmarshalFloat(data []byte) error {
f, err := strconv.ParseFloat(string(data), 64)
m.Value = T(f)
return err
}
Of course this is even more verbose and less convenient. But it makes clear that in terms of implementation complexity and expressive power at least, all of these are more or less equivalent.
So, personally, I would still prefer the type-switches from this proposal, if we can do them. The best argument so far against them seems to be "it allows to use different code for different types, which goes against the philosophy of generics" and obviously any solution to the kinds of problems this proposal is trying to address is going to do that - that's the point.
(That being said, personally I'd like us to add refined method constraints in addition to type switches. They are clearer for some other use cases that currently use optional interfaces)
It's less problematic for such cases where you can add a specificity order. time.Duration is more specific than ~int.
It's not difficult to imagine that named types will be satisfied first.
Where it can seem more involving is when dealing with types implementing interfaces.
Let's say {~string | fmt.Stringer}
Since there is overlap, i.e. no provable non-empty intersection, the compiler should hint us that it should be decomposed further, by adding another case:
interface{
~string
fmt.Stringer
}
In a way, all cases are considered disjoint by default or forced to be by construction, compiler helping.
Do you see anything that would make this problematic?
It's less problematic for such cases where you can add a specificity order. time.Duration is more specific than ~int. It's not difficult to imagine that named types will be satisfied first.
There can be more than one defined type involved (also, nit: it's ~int64).
I think we should be very careful with throwing around heuristics like this. C++ concepts have this problem, where a function can be overloaded with different constraints, resolving overlaps by determining if "one constraint is more specific than the other". This requires C++ compilers to solve NP-complete problems - it is easy to write a relatively short program that will cause gcc to consume functionally infinite amount of memory and CPU time.
Where it can seem more involving is when dealing with types implementing interfaces. Let's say
{~string | fmt.Stringer}
We currently do not allow using interfaces containing methods in a union and we do that for a reason.
If we did - at least if we did so naively - almost everything we talk about here would require solving an NP-complete problem to type-check Go programs. I proposed a talk about that for GopherCon Australia.
That's also, FWIW, why I don't think this proposal should make progress until we've decided on if and how we want to lift that restriction at some point.
I don't really want to be running in circles to previous conversation but which np-complete problem do you see?
The only np-complete problem (and this is not always a deal-breaker by the way) would be to find whether a Go interface is satisfiable is inhabited, i.e. A type set is non empty.
That's not a problem that needs solving since we just need to verify whether a type satisfies a set of constraints which is linear in the number of constraints.
I'm a bit confused why you'd think it requires to prove a given interface can exist.
I think you'll have to wait for my talk - I have just as little interest in re-hashing that conversation as you. For now, suffice it to say that it's been a while since I wrote that proof and at the time, I said that more thought would be needed. I've thought some more since then. Hence my proposed talk.
Of course, this is still somewhat open ended. There are things we might do to alleviate or solve those problems (or we might just ignore them, as C++ does). But we should be careful to just assume the compiler can easily check if two type sets overlap or if one constraint is more specific than another.
And both this proposal and refined method constraints significantly influence the structure of the constraint language we have to cope with (as they implicitly construct conjunctions from existing constraints), which heavily influences its tractability.
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?
What if it's a union? Suffice to check for every member of the union? If at least one member satisfies, we have overlap?
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)
for the sake of this technical problem that really needs solving
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
If a
casestatement has multiple cases, they must not be interfaces containing methods or the predeclared identifiercomparableand they must not embed any such interfaces.
(matching the current restriction on union elements). In particular
default case or add a superfluous panic("unreachable"), unfortunate as that is.A and B, interface{ A ; B } is always a valid (covering the single-constraint branches) and given A and B without methods or comparable, interface{ A | B } is valid (covering the multi-constraint branches).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 case branches might then violate that structure (e.g. any case increments the nesting depth) - which might be surprising and/or inconvenient. I think eliminating multi-constraint branches altogether might make it a bit more likely that no problems prop up, but even that I don't feel super confident about.
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.
This is the same issue people have with dependent types/ unrestrained refinement types.
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? ~~I'll comment over there.~~
[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:
In theory, checking for C may seem exponential in the numbers of constraints being combined (o(kN), EXPSPACE in the WORST case only).
This seems to be a case of expansion of a boolean formula into DNF form.However, in practice, the restriction on the domains of many constraints (which makes them incompatible with each other) simplify things. (e.g. if a is int and e string, aeg and aeh can be eliminated).
The checking should also be parallelizable.So it shouldn't be a big worry, I think.
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:
1. allow defining a new type parameter in the switch
2. the cases can contain any constraint spec(s)
3. if there is a single constraint spec the new type parameter is scoped to that block and conversions between the new and old type parameter are legal
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
Would this code be valid under your suggestion?
func ElemIndex[T comparable](x []T, e T) int {
switch type S T {
case byte:
return bytes.Index(([]S)(x), S(e))
default:
for i, g := range x {
if g == e {
return i
}
}
return -1
}
}
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
case fmt.Stringer, ~float64 equivalent to case fmt.Stringer | ~float64?
If so, should the type parameter really be undefined?
It's different. The case is hit for either of those constraints. If you had case int, uint you could also write it as case int | uint but fmt.Stringer | ~float64 is not a valid constraint. I'd be fine if that didn't make it. I put it in because it's how you can use .(type) switches.
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 type keyword is used in declaration and symbols used for accessors.
So instead of switch type S T {...} I am proposing switch [S T] {...}.
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 []T to a []S even when T and S have the same constraints. So I don't think either version of the type switch would make your example work by itself.
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 , form, I think), with no real preference for the exact syntax. I don't feel personally convinced by @jimmyfrasche's arguments.
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:
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.
FWIW there's no need for unsafe for that case, even now: https://go.dev/play/p/n0yqq6EGPfy
It's even possible (though awkward and probably inefficient) to do it with only a single type check and no possibility of panic: https://go.dev/play/p/IcIpvbdGooe
This is precisely the kind of case that this proposal is designed for: it's common to have several
values all defined in terms of the same type parameter. Your suggestion doesn't help with that common
case at all.
For the record, this is what it would look like using this proposal:
func ElemIndex[T comparable](x []T, e T) int {
switch type T {
case byte:
return bytes.Index(x, e)
default:
for i, g := range x {
if g == e {
return i
}
}
return -1
}
}
@arvidfm wrote:
@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(".")) }
But this does (and this is what this proposal is aimed at replacing):
func F[T any](list []T) int {
return bytes.IndexByte(any(list).([]byte), '.')
}
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 iter.Seq:
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 Map, even derived ones, and have it Just Work™. For iter.Seq in particular, supporting derived types would have been nice because then all my functions could have returned a custom Seq-derived type with methods like Collect() for converting to a slice or Count() for getting the total number of elements.
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 iter.Seq before calling into the API, not allowing derived types, or having to use reflection.
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")
}
}
}
This issue has already been given to the regular proposal review committee (which placed it on hold). On August 6 I accidentally added the LanguageChangeReview label meaning that it was under the purview of the language change committee. Removing that label to send back to the regular committee again (where it remains on hold).
I ran into this today. The contrived version of my use case is in https://go.dev/play/p/rbQis0cmvN7 . Allowing type switch statements to have case ~int: would be symmetric with interface types like in my example so that is the approach I favor if this proposal goes back on the table.
FWIW, I actually thought case int: would just match my custom type since it had already matched the polymorphic function but I'm in favor of the enhanced case syntax because that allows preserving the existing behavior while allowing the new behavior.
A related thing I run into is not wanting to know what specific type S or T are but just wanting to know whether S and T are the same type. Sometimes that means being able to skip an intermediary step because you can reuse some storage or call some methods directly, etc.
@jimmyfrasche I believe that you can already check if two types are the same with almost no runtime overhead:
```go
func isEqualT, U any bool {
type empty[T any] struct{}
return (any(empty[T]{})) == (any(empty[U]{}))
}
func main() {
println(isEqualint, int)
println(isEqualint, string)
println(isEqualint32, rune)
}
Putting this proposal on hold until we have more experience with generics.
This was in May 2021. is it maybe time to reconsider this?
For me there is one compelling case* where this is more than just a convenience. Given func foo[V constraint](), if I want to test whether V satisfies some interface, currently the only way I have of doing this is to create var v V then cast any(v) and do a type check on that.
However, if V is an interface type, then var v V is nil, any(v) is an untyped nil value, and there's no way - short of reflection - for me to detect whether V satisfies some interface. This is a case where having a type switch on parametric types would make a meaningful difference. Yes, I could use reflection; however that is not an acceptable solution due to performance issues. And AFAIK there is literally no other way for me to test whether a type parameter satisfies an interface when the type parameter resolves to an interface type.
(*I realize there are other cases, such as testing the underlying type of the parameter. I haven't run into a situation where that has mattered to me so it's not on my radar.)
if V is an interface type, then var v V is nil, any(v) is an untyped nil value, and there's no way - short of reflection - for me to detect whether V satisfies some interface.
func F[T any]() {
switch any(new(T)).(type) {
case *io.Reader:
fmt.Println("T is io.Reader")
case *any:
fmt.Println("T as iny")
}
}
[edit] Ah, I see that perhaps you want to an "interface type-assertion", i.e. not check if T is exactly an interface, but just implements it. i.e. have an func ImplementsReader[T any]() bool so that e.g. ImplementsReader[io.ReadWriter]() == true, but ImplementsReader[io.Writer]() == false. That's fair enough, I can't think of a way to do that either.
From https://github.com/golang/go/issues/45380#issuecomment-1372967143:
If [a constraint] is exported, adding a new case to it is already a backwards incompatible change, similar to removing a method from a basic interface:
```
package atype C interface { ~int }
// --
package b
func FT ~int {}
Adding a new case to
a.Cwould breakb.G. So, the backwards compatibility concern is a red herring.
By that logic, adding a field to a struct would be a breaking change. Consider:
package a
type S struct { X int }
// --
package b
func F(x struct { X int }) {}
func G(x a.S) { F(x) }
It is a breaking change to add a field to S.
I assume that no one wants that. It should be possible to add a field to a struct without a major version bump.
The "problem" is this rule of the spec:
A value x of type V is assignable to a variable of type T if ... V and T have identical underlying types but are not type parameters and at least one of V or T is not a named type.
The solution is to ignore this case when determining backward compatibility. The Go 1 Compatibility Guarantee doesn't mention it, but the apidiff documentation does (under "Using an Identical Type Externally"). We can also describe it as a rule, like the rule that says to use keyed struct literals for structs outside the current package. Here the rule is: if you're given a type name, use it.
That means that there is an important difference between
var V interface { int | bool }
and
type T interface { int | bool }
var V T
In the first, adding to the interface is a breaking change. In the second, it is not.
This might all seem a bit subtle, and maybe a bit arbitrary. But it is consistent for all types, and it is pragmatic: just as we want the freedom to add fields to structs, so we want the freedom to add types to unions.
From https://github.com/golang/go/issues/45380#issuecomment-1372967143:
If [a constraint] is exported, adding a new case to it is already a backwards incompatible change, similar to removing a method from a basic interface:
```
package atype C interface { ~int }
// --
package b
func FT ~int {}
Adding a new case to
a.Cwould breakb.G. So, the backwards compatibility concern is a red herring.By that logic, adding a field to a struct would be a breaking change. Consider:
```
package atype S struct { X int }
// --
package b
func F(x struct { X int }) {}
func G(x a.S) { F(x) }
`` It is a breaking change to add a field toS`.I assume that no one wants that. It should be possible to add a field to a struct without a major version bump.
The "problem" is this rule of the spec:
A value x of type V is assignable to a variable of type T if ... V and T have identical underlying types but are not type parameters and at least one of V or T is not a named type.
The solution is to ignore this case when determining backward compatibility. The Go 1 Compatibility Guarantee doesn't mention it, but the apidiff documentation does (under "Using an Identical Type Externally"). We can also describe it as a rule, like the rule that says to use keyed struct literals for structs outside the current package. Here the rule is: if you're given a type name, use it.
That means that there is an important difference between
var V interface { int | bool }
and
type T interface { int | bool } var V T
In the first, adding to the interface is a breaking change. In the second, it is not.This might all seem a bit subtle, and maybe a bit arbitrary. But it is consistent for all types, and it is pragmatic: just as we want the freedom to add fields to structs, so we want the freedom to add types to unions.
I've made the same mistake but I think it is backward incompatible either way.
It just doesn't necessarily keep code from compiling. But it might change the running behavior of programs.
Do we call that change of contract backward incompatible or not, I am not completely sure. Probably 'incompatible'.
A type assertion is sufficient for this even though unlikely. A type switch could start selecting the more specific branch potentially.
For struct fields, if someone iterates through them via reflection, they might get different results?
Extending a union is mostly in append-only way API-wise. Requires to expose a new augmented type to be used alongside the old one.
Now if we want to add support for interface constraints, it requires to have the intersection case explicitly added and in a certain order so that the test falls through from higher to lower specificity.
Definitely subtle.
@jba FWIW I think at some point this discussion is a bit off-topic here. But
Here the rule is: if you're given a type name, use it.
I don't think this is enough. I don't actually think there is a solution. Because while, sure, we might want to be able to add cases to unions, it is also natural to want to nest constraints. Consider
func Sort[E cmp.Ordered](s []E)
func SumTop10[E constraints.Integer](s []E) E {
Sort(s)
var sum E
for _, v := range s[:min(10, len(s))] { sum += v }
return sum
}
If a case is added to constraints.Integer, without simultaneously being added to cmp.Ordered, this breaks. But we certainly don't want to discourage people from writing code like this - and it does conform to your rule.
I didn't want to go there (and apidiff doesn't try), but you can have those sort of cross-type linkages even without generic constraints. For example, two structs that happen to have the same fields in the same order. Someone might convert one to the other, and then adding a field to just one will break that conversion. (I get into this a little in the apidiff README under "Whole-Package Compatibility." Some of the points there apply across packages too.)
So while there is no solution, and my rule doesn't cover every case, the way forward is not despair but pragmatism. We must be able to add fields to structs, and we must be able to add types to unions. I don't disagree with you about the facts on the ground, but I think it's more helpful to say "adding a type to a union is not a breaking change (but see footnote)."
To be clear, I've made the argument that in a strict interpretation, there are no backwards compatible API changes 10 years ago already. The context was exactly a discussion at the time around tooling like apidiff for general consumption. And my argument at the time and since, has always been a pragmatic one: that this can not be automated and every case needs to be looked at individually. You aren't really telling me anything new here and what's happening here isn't me dying on the hill of nitpicking breakages.
I just think that in this particular case, the problem is far more real than e.g. the case of adding struct fields. It obviously depends on how much a union is intended and understood for third party use. But, personally, I would say that "adding a case is generally breaking (but see footnote)" instead of saying "adding a case is generally non-breaking (but see footnote)".
Speaking of which, cmp.Ordered is a good example of that: it is clearly meant for third-party consumption, so it clearly acknowledges that the problem is real and carves out an exception. I'd still argue that if we added a case to it, we should first look at what code it actually breaks in practice.
[edit] FWIW I just saw that part of the reason for re-warming this issue seems to be #57644. To me, this is one particular distinction relevant to the discussion: I think when you talk about unions as types, it is clear that you want to be able to add cases. But when you talk about them as constraints, it is far less clear. It's just another way in which using the same concept for both tends to muddy the waters, in my opinion.
My reason is doggedOwl's comment reminded me this discussion exists and I've recently been working on something where type switches would be extremely helpful (and yes, what I want is an "interface type-assertion" as you put it @Merovius).
@firelizzard18 Sorry, I meant specifically the kind of off-topic discussion about adding cases to unions. Of course your comment wasn't related to that.