Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: spec: generics: type switch on parametric types #45380

Open
rogpeppe opened this issue Apr 4, 2021 · 99 comments
Open

proposal: spec: generics: type switch on parametric types #45380

rogpeppe opened this issue Apr 4, 2021 · 99 comments

Comments

@rogpeppe
Copy link
Contributor

@rogpeppe rogpeppe commented Apr 4, 2021

In the discussion for #45346, this comment mentioned the possibility of adding a type switch on parametric types. Given that it's threatening to derail the discussion there, I'm creating this proposal to talk about it.

Proposal: type switching on type parameters

This proposal assumes that #45346 is accepted.

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
    }

Example

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.

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

Replying to @candlerb #45346 (comment)

func Sort[T Lessable[T]](list []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.

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

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

  1. It is slightly unfortunate to have both
  2. If we never add "sum-types", the parameter type switch would be better
  3. If we do add "sum-types" and only want one, the approximate type switch would be better

Of course, we can always eat the cost of "slightly unfortunate" and add both, when the time comes.

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

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".

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

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.

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

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 :)

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

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
    }
}

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).

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

I don't think that's quite right - ~float64 can't be assigned to float64 without 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.Max be 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.

@zephyrtronium
Copy link
Contributor

@zephyrtronium zephyrtronium commented Apr 4, 2021

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).

@thepudds
Copy link

@thepudds thepudds commented Apr 4, 2021

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.

@zephyrtronium
Copy link
Contributor

@zephyrtronium zephyrtronium commented Apr 4, 2021

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.

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))).)

@urandom
Copy link

@urandom urandom commented Apr 4, 2021

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

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

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

@zephyrtronium

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 :)

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

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 :)

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

I don't think that's quite right - ~float64 can't be assigned to float64 without 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.

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:
        ...
    }
}
@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

@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.

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

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?

@zephyrtronium
Copy link
Contributor

@zephyrtronium zephyrtronium commented Apr 4, 2021

@Merovius

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

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

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
Copy link
Contributor

@zephyrtronium zephyrtronium commented Apr 4, 2021

@rogpeppe

@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.

@randall77
Copy link
Contributor

@randall77 randall77 commented Apr 4, 2021

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.
    }
}
@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

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?

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))
}
@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

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?

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.

@randall77
Copy link
Contributor

@randall77 randall77 commented Apr 4, 2021

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.

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 4, 2021

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 4, 2021

@zephyrtronium

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.

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

@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.

@Merovius
Copy link

@Merovius Merovius commented Apr 17, 2021

@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.

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

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?

@Merovius
Copy link

@Merovius Merovius commented Apr 17, 2021

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?

see here

@Merovius
Copy link

@Merovius Merovius commented Apr 17, 2021

Why should we be unable to switch exactly on the interface type?

You can

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

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.

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

Why should we be unable to switch exactly on the interface type?

You can

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 17, 2021

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.

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

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.

@ohir
Copy link

@ohir ohir commented Apr 17, 2021

@atdiar

how do you check that a random value is of a given type [...] with mere if

if v, ok := x.(T); ok {; ... }

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 17, 2021

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.

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 17, 2021

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.

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

For io.Reader and io.Closer.. My point is to not match on implementation but on the type of the boxed value when needed.

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

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.

I stand corrected. Meant switch and wrote select.

And also you're right about type switches as switch statement equivalents and if/elseif.
:)

@Merovius
Copy link

@Merovius Merovius commented Apr 17, 2021

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.

@ohir
Copy link

@ohir ohir commented Apr 17, 2021

@atdiar

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.


@atdiar

... Bug of mine, ignore.

There is [ Hide -> as off-topic ] item in the dot menu. It would be prudent to make use of it.

@atdiar
Copy link

@atdiar atdiar commented Apr 17, 2021

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.

@Merovius
Copy link

@Merovius Merovius commented Apr 17, 2021

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.

@atdiar
Copy link

@atdiar atdiar commented Apr 18, 2021

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?

@rogpeppe
Copy link
Contributor Author

@rogpeppe rogpeppe commented Apr 18, 2021

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.

@atdiar
Copy link

@atdiar atdiar commented Apr 19, 2021

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.

@ianlancetaylor
Copy link
Contributor

@ianlancetaylor ianlancetaylor commented May 4, 2021

Putting this proposal on hold until we have more experience with generics.

@JAicewizard
Copy link

@JAicewizard JAicewizard commented May 31, 2021

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
@Merovius
Copy link

@Merovius Merovius commented May 31, 2021

@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.

@JAicewizard
Copy link

@JAicewizard JAicewizard commented May 31, 2021

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet