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: allow methods on generic structs which meet subset of type constraint #65394

Open
2 of 4 tasks
jordan-bonecutter opened this issue Jan 30, 2024 · 31 comments
Open
2 of 4 tasks
Labels
generics Issue is related to generics LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Milestone

Comments

@jordan-bonecutter
Copy link

jordan-bonecutter commented Jan 30, 2024

Go Programming Experience

Experienced

Other Languages Experience

JS, C, Python

Related Idea

  • Has this idea, or one like it, been proposed before?
  • Does this affect error handling?
  • Is this about generics?
  • Is this change backward compatible? Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit

Has this idea, or one like it, been proposed before?

No, not to my searching

Does this affect error handling?

No

Is this about generics?

Yes. This proposal allows adding methods on a subset of generic types.

Proposal

Consider a generic type, such as:

// Validator is a function which returns true if the argument is valid and false otherwise.
type Validator[T any] func(T) bool

I may want to write the following function:

// Ignore returns a new validator which does not attempt to validate values who are equal to value.
// Otherwise, it uses the existing validator.
func (v Validator[T comparable]) Ignore(value T) Validator[T] {
  return func(t T) bool {
    if t == value {
      return true
    }

    return v(t)
  }
}

However, this is not allowed because I cannot further constrain T after Validator has been defined. I propose allowing further restrictions on T if and only if the further restriction is a subtype of the original type. A is a subtype of B if the set of types described by A is completely contained within B.

This will be helpful when writing methods which are not critical to the functionality of a type (i.e., it still makes sense to have validator functions where T is not comparable) but some subset of values of the type benefit from extra methods.

Here is another use case that could be useful:

type SliceWrapper[T any] []T

func (s SliceWrapper[T constraints.Ordered]) Sort() SliceWrapper[T] {
  ...
}

Language Spec Changes

Methods on generic types have only been permitted to omit constraints on the generic type. They may now either specify or omit it, if omitted it is by default the value from the type definition.

Not all methods defined on a generic type will be available to every instance, they will only be available if the extra constraints are met.

Informal Change

No response

Is this change backward compatible?

Yes, the type constraint may still be omitted as before.

Orthogonality: How does this change interact or overlap with existing features?

The goal of this change is to improve the usability of generic utility types. Its success should be measured by its usage.

Would this change make Go easier or harder to learn, and why?

In general, I don't think this increases the complexity of the language too much. However, I could see some possible confusion from this change. If a new go user sees a method defined on a generic type which their instance does not meet it could be confusing as to why this method is not available on their instance. Although, if the users understands type constraints this concept is no more complicated.

Cost Description

Not every instance of a generic type will have the same method set, and the compiler will need to check this.

Changes to Go ToolChain

No response

Performance Costs

Compile time costs: negligible if not used. Run time cost: better than interface smuggling.

Prototype

No response

@jordan-bonecutter jordan-bonecutter added LanguageChange Suggested changes to the Go language Proposal v2 An incompatible library change labels Jan 30, 2024
@gopherbot gopherbot added this to the Proposal milestone Jan 30, 2024
@leaxoy
Copy link

leaxoy commented Jan 31, 2024

Duplicated with #64846

@apparentlymart
Copy link

apparentlymart commented Jan 31, 2024

Another interesting (I think) variation of the Sort example in the proposal is to be able to write a generic type that only implements sort.Interface if instantiated with a comparable element type:

type SliceWrapper[T any] []T

func (s SliceWrapper[T]) Len() int {
    return len(s)
}

func (s SliceWrapper[T]) Swap(i, j int) {
    s[i], s[j] = s[j], s[i]
}

func (s SliceWrapper[T constraints.Ordered]) Less(i, j int) bool {
    return s[i] < s[j]
}

The Less method of sort.Interface would exist only on the subset of types where T matches constraints.Ordered, and therefore only those would be acceptable to sort.Sort/sort.Stable.

With that said, this particular example of the phenomenon is perhaps not super compelling because we already have slices.Sort that gets the same effect without implementing any interfaces. 🤷‍♂️ I wonder if there are other commonly-used interfaces where this idea of only some instantiations of the type implementing the interface would be useful. 🤔

(I'm assuming in the above that the intent is that SliceWrapper[NotOrdered] would just not have any Less method at all, and so attempting to pass it to something that expects sort.Interface would fail at compile time. My intuition here is coming by analogy to impl blocks in Rust, where each one can have a different set of constraints on what traits are implemented by each of the generic type arguments.)

@jordan-bonecutter
Copy link
Author

jordan-bonecutter commented Jan 31, 2024

@apparentlymart yeah, the Rust-style impl blocks would offer similar functionality (the impl block allows for its own set of constraints independent of the constraints on the type itself.)

In general, I think the use case for this is "covered" by, instead of definig a method on the type, defining a function which further constrains the type (which is similar to the example you brought up with slices.Sort.) However, in many cases this will lead to defining functions that should be methods. Par example:

func Ignore[T comparable](value T, v Validator[T]) Validator[T] {
  return func(t T) bool {
    if t == value {
      return true
    }

    return v(t)
  }
}

This covers the case from my example, but ideally ignore should be a method on Validator, not a function (I guess this comes to a larger philosophical point of when should something be a function, when should it be a mehtod but I think most would agree the above would have been cleaner as a method.)

@apparentlymart
Copy link

apparentlymart commented Feb 2, 2024

FWIW, I remember this debate about whether functions like this "should be methods" coming up a few times while the type parameters proposal was being discussed, and the consensus eventually settled on it being fine to handle these cases as free functions.

I don't have any direct citations to share unfortunately -- it was a while ago and the discussions were quite scattered -- but if the main goal of this proposal is to allow these functions to be implemented as methods instead then it's probably best to try to argue this from the perspective of offering "new information" relative to the original discussions, which would unfortunately mean doing some archaeology to find the original discussions. 😖

(Personally I've found myself writing a bunch of these "functions that ought to be methods" already and I broadly agree with you that it feels weird for them to be separated from the type they relate to just because they have a different constraint, but I haven't really been able to muster an argument that goes beyond my personal taste for API design, which is of course not the same as everyone else's taste. 🤷‍♂️ )


Edit: Thanks to the later link to #49085, I was reminded that there's a section in the Type Parameters proposal about this.

However, I think this new proposal is considerably less broad than what that section was discussing: it doesn't add any new parameters on a per-method basis, and instead only allows further-constraining the type parameters that are already declared on the generic receiver type.

@seankhliao seankhliao added the generics Issue is related to generics label Feb 2, 2024
@seankhliao
Copy link
Member

The proposed syntax won't work, it already has a meaning https://go.dev/doc/faq#types_in_method_declaration
It also looks like a form of function overloading which Go has strongly rejected in the past.

Otherwise it looks like a mix of specialization #45380 and methods #49085

@seankhliao seankhliao changed the title proposal: allow methods on generic structs which meet subset of type constraint proposal: Go 2: allow methods on generic structs which meet subset of type constraint Feb 2, 2024
@jordan-bonecutter
Copy link
Author

The proposed syntax won't work, it already has a meaning https://go.dev/doc/faq#types_in_method_declaration It also looks like a form of function overloading which Go has strongly rejected in the past.

Otherwise it looks like a mix of specialization #45380 and methods #49085

The syntax doesn't have a meaning. The link you sent has the code

type S[T any] struct { f T }

func (s S[string]) Add(t string) string {
    return s.f + t
}

I propose:

type S[T any] struct { f T }

func (s S[T string]) Add(t string) string {
    return s.f + t
}

If you try to compile code like this, go thinks you forgot a comma in a set of distinct type constraints:

./main.go:5:13: syntax error: unexpected string, expected ]

It is similar to #49085, but different in that we are only allowing type parameters in the receiver argument, not in the other method args. Also I suppose somewhat similar to #45380 but doesn't involve a type switch.

@apparentlymart
Copy link

apparentlymart commented Feb 2, 2024

One thing I took as implied, which therefore made this feel different to me than either specialization or overloading, is that a generic type can still have only one method of each name, but that some of the names are defined conditionally based on the constraints met by the type parameters.

So, in particular:

  • It would not be valid to define both an unconstrained and a constrained version of the same method name, or a more-constrained and less-constrained version of the same method name, making this not useful for specialization.
  • Each concretization of the generic type either has the method or it doesn't. The selected method doesn't vary based on the parameters of the receiver type. Therefore this is not "overloading" in the sense of the same name meaning different things depending on operand type.
  • The rule is based only on the receiver type and not on arbitrary other argument types, which perhaps makes it clearer how this should interact with interface implementation, as I was exploring in earlier comments.

However, I'm only assuming that's what this proposal was intended to mean, I suppose. Please correct me if I misunderstood, @jordan-bonecutter.

@jordan-bonecutter
Copy link
Author

That is exaclty what I had in mind, you said it better than I could. Done this way to have minimal impact on type system, especially the inner workings of interface implementation. Thx @apparentlymart

@Merovius
Copy link
Contributor

Merovius commented Feb 8, 2024

I don't believe this will happen any time soon, but I'm generally in favor. In fact, I had a draft of this myself. I haven't published it yet, because I was trying too hard to get it perfect. Which serves me right. So, a long comment from someone having thought about this for a while. I call this feature "refined method constraints".

Optional interfaces

One omission from the proposal text is that this is a mechanism present in the Featherweight Generic Go paper (FGG). This is relevant in two ways.

First, it's evidence of feasibility. We have actual programming language research telling us that this extension to Go's type system is sound. It's not a real proof, as the FGG type system is a subset of "real" Go and the generics implementation they discuss differs a bit from what we ended up in practice. But it's still evidence.

Second, it gives us use cases. In the FGG paper, this is used to solve the expression problem. However, as that is a very abstract problem and many people will probably not consider such an abstract problem important, it might be more illustrative to talk about it as the problem of optional interfaces.

For example, say I want to write an HTTP middleware that wraps responses with some logging:

type LogResponseWriter struct {
    http.ResponseWriter
}

func (w LogResponseWriter) WriteHeader(code int) {
    log.Printf("Response code is %d", code)
    w.ResponseWriter.WriteHeader(code)
}

If a ResponseWriter implements http.Flusher, though, that method is not available in a LogResponseWriter. We have two options:

  1. Implement the Flush method on LogResponseWriter using a type-assertion.

    func (w LogResponseWriter) Flush() {
        if f, ok := w.ResponseWriter.(http.Flusher); ok {
            f.Flush()
        }
        // else, does nothing, which violates the contract of http.Flusher
    }

    This is not type-safe, as it pretends we can flush, even if we don't. This can break use cases that rely on it for correctness (like Server-sent events).

    It also doesn't generalize well for cases where the method can't be implemented without having it present in the wrapper. http.Hijacker is an example. Such cases always need a side-channel (like the error return) to dynamically signal this violation of type-safety to the caller.

  2. Add a separate type, and have a wrapping-function use type-assertions to determine which type to return:

    type logResponseWriter struct {
        http.ResponseWriter
    }
    
    func (w logResponseWriter) WriteHeader(code int) {
        log.Printf("Response code is %d", code)
        w.ResponseWriter.WriteHeader(code)
    }
    
    type logFlushResponseWriter struct {
        logResponseWriter
        http.Flusher
    }
    
    func (w logFlushResponseWriter) Flush() {
        log.Println("Flushing buffered data")
        w.Flusher.Flush()
    }
    
    func Wrap(w http.ResponseWriter) http.ResponseWriter {
        lw := logResponseWriter{w}
        if f, ok := w.(http.Flusher); ok {
            return logFlushResponseWriter{lw, f}
        }
        return lw
    }

    This is now type-safe, but the code required to implement this (and the runtime of Wrap) increases exponentially with the number of optional interfaces. We need a separate type and branch for each combination of optional interfaces, so 2^n types if we have n optional interfaces. http.ResponseWriter also has CloseNotifier, Hijacker and Pusher, for example.

Refined method constraints would give us a type-safe solution that only requires a linear amount of code for each wrapper:

type LogResponseWriter[W http.ResponseWriter] struct {
    Wrapped W
}

func (w LogResponseWriter[W]) Header() http.Header {
    return w.Wrapped.Header()
}

func (w LogResponseWriter[W]) Write(p []byte) (int, error) {
    return w.Wrapped.Write(p)
}

func (w LogResponseWriter[W]) WriteHeader(code int) {
    log.Printf("Response code is %d", code)
    w.Wrapped.WriteHeader(code)
}

// this is needed as the proposal text requires the constraint to be a subset of the constraint on the type. See below.
type FlushResponseWriter interface { http.ResponseWriter; http.Flusher }

func (w LogResponseWriter[W FlushResponseWriter]) Flush() {
    log.Println("Flushing buffered response")
    w.Wrapped.Flush()
}

type PushResponseWriter interface { http.ResponseWriter; http.Pusher }

func (w LogResponseWriter[W PushResponseWriter]) Push(target string, opts *http.PushOptions) error {
    err := w.Wrapped.Push(target, opts)
    if err == nil {
        log.Printf("Pushed %q", target)
    } else {
        log.Printf("Pushing %q: %v", target, err)
    }
    return err
}

Another such case is io/fs, which uses optional interfaces extensively.

This shows that refined method constraints are relevant not just if you are interested in generics, but they solve a problem that has existed in Go for a long time (my blog post about it is from 2017, but the problem has been known for longer than that).

Of course, in practice, this isn't quite a home-run. In particular, it requires the concrete type of an interface to be statically known and net/http doesn't expose any concrete http.ResponseWriters, for example. But if we had the facility, future APIs could be explicitly designed to take advantage of this.

Specialization

I disagree with @apparentlymart that this is not useful for specialization. In fact, it is strictly more powerful than #45380 due to the fact of how interface type-assertions work.

For example, #45380 proposes enabling this code (ignoring for a second the fact that Stringish can't be expressed in Go):

type Stringish interface {
	string | fmt.Stringer
}

func Concat[S Stringish](x []S) string {
    switch type S {
    case string:
        return strings.Join(x, "")
    case fmt.Stringer:
        var buf strings.Builder
        for _, s := range x {
             buf.WriteString(s.String())
        }
        return buf.String()
    // note: in practice, we would probably have to require a default case,
    // as exhaustiveness checking is infeasible.
    // default:
    //     panic("unreachable")
    }
}

Refined method constraints would give us the power to express the same program:

func Concat[S Stringish](x []S) string {
    switch c := any(switcher[S]{}).(type) {
    case interface{ caseString([]S) string }:
        return c.caseString(x)
    case interface{ caseStringer([]S) string }:
        return c.caseStringer(x)
    default:
        panic("unreachable")
    }
}

type switcher[S Stringish] struct{}

func (switcher[S string]) caseString(x []S) string {
    return strings.Join(x, "")
}

func (switcher[S fmt.Stringer]) caseStringer(x []S) string {
    var buf strings.Builder
    for _, s := range x {
        buf.WriteString(s.String())
    }
    return buf.String()
}

Ultimately, such a transformation can be made for any type-parameter switch. So this proposal is as powerful as #45380. Also solving the expression/optional interface problem makes it strictly more powerful.

That being said, obviously actually writing code like this is a bad idea. Readability is incredibly harmed. So I don't think this proposal should supersede #45380. Instead, if we want refined method constraints, I would suggest also implementing #45380, to give us a more natural way to write this code, so we won't resort to this abomination.

Feasibility

As @apparentlymart pointed out, refined method constraints really don't have all that much in common with #49085. Only that either refers both to "generics" and "methods". But refined method constraints don't require runtime code generation or any other form of rank 2 polymorphism.

Currently, if you write

type X[T constraints.Integer|constraints.Float|constraints.Comple] struct {}

func (X [T]) Add(a, b T) T {
    return a + b
}

the compiler already has to generate different implementations of Add, depending on what the actual type-argument to X is. It is allowed code, so this is obviously possible to implement within the design constraints we have in place.

Refined method constraints are not all that much different. Because we can't pass around uninstantiated generic types, any instantiation of a generic type will always have a statically known type argument. The compiler can just list all potential methods of a generic type at instantiation time, remove the ones with refined method constraints that the type argument does not satisfy, and use the same monomorphization heuristic it uses today to generate specialized or generic code.

Restricting to sub constraints

The proposal text contains a restriction that the refined method constraint must be "a subtype" of the constraint on the type. That is, this code should only be allowed, if satisfying C1 implies satisfying C2:

type X[T C2] struct{}
func (X [T C1]) M() {}

Checking that is feasible. It is the same check we currently have to do, to verify that a generic call with a type-parameter is allowed:

func F[T C2]() {
    G[T]() // only allowed if satisfying C2 implies satisfying C1
}
func G[T C1]() {}

This shows that type-checking a method declaration with refined method constraints is not harder than what we already have to do. That being said, I think we might want to relax this restriction in the long term.

As the ResponseWriter example demonstrates, it is a bit cumbersome having to define extra interfaces for the concrete intersections (and anonymous interfaces in the method definition are verbose - especially with the line-breaks introduced by gofmt). And the restriction isn't really necessary, as the comparison to generic function calls with type-parameters shows. At worst, the intersection of the constraint on the type and the refined constraint on the method is empty:

type X[T fmt.Stringer] struct{}
func (X [T int]) M() {} // empty: int does not implement fmt.Stringer

But we already allow defining generic functions with empty constraints - such functions can just never be instantiated. Similarly, the compiler would just never add M to the method set of X, for any actual instantiation.

Ultimately, if the type has constraint C1 and the method has constraint C2, we can either

  1. require C2 ⇒ C1, in which case the programmer can always (and might have to) replace C2 with interface{ C1; C2 }, as we did above with FlushResponseWriter. Or
  2. not require C2 ⇒ C1 and instead implicitly interpret the method body to be constrained by interface{ C1; C2 }. That is, implicitly do the explicit transformation required from the programmer in the first case.

This equivalency shows that there is at least no implementation-difficulty in doing either. Personally, I would opt for the brevity of not requiring the explicit composition. But it's also fine to start with the explicit version and then later relax the restriction, if we get too annoyed by it.

@jordan-bonecutter
Copy link
Author

@Merovius hadn't thought about relaxing the sub constraint constratint. I think what you said is sensible, the only hitch with implicitly creating a interface is that some interfaces, while syntactically possible, are invalid. You touched on this in your comment, but worth explicitly stating. Consider:

type Foo interface {
  Foo()
}

func Bar[T interface{Foo | int}]() {}

The interface constraining T is invalid because interfaces may either be defined as type sets or method sets, not both. I think to really allow the compiler to create such an interface (or check for constraints where the refined method constraint is not a sub constraint) such interfaces must be allowed, which is a bigger ask. I think it's more sensible to only allow sub constraints in the refined method constraint until/unless interfaces allow for such behavior.

@Merovius
Copy link
Contributor

Merovius commented Feb 8, 2024

I think what you said is sensible, the only hitch with implicitly creating a interface is that some interfaces, while syntactically possible, are invalid.

Note that the "implicit interfaces" are always of the form interface{ A; B }, where A and B are semantically valid interfaces. Doing that is always valid. Problems would only arise, if the interfaces that are implicitly created take the form of union-elements, i.e. interface{ A | B } with A and B being arbitrary. That is not always valid. But it's not relevant here.

Specifically, a programmer writing

type X[T A] struct{ /* … */ }
func (X[T B]) M() { /* … */}

with some A and B and being told (under your restricted proposal) that the code is not allowed, because "B is not a subtype of A" can always replace it with

type X[T A] struct { /* … */ }
func (X[T interface{ A; B }] M() { /* … */ }

to pacify the compiler. So we might as well not require them to do that and just do it implicitly.

That is, FWIW, a real hint at the close relationship between refined method constraints and #45380., which also constructs implicit interfaces of exactly the same form, that are then applied to the body of the switch-cases. Which is why I think these two proposals are natural extensions of each other - they have the same implications on implementation and ask much of the same questions.

I think the real question, ultimately, is if we think that maybe it's prefarable that the programmer has to write interface{ A; B }. I can see the argument, that by requiring to be explicit here, it is at least obvious what the full constraint on the type parameter is, just from looking at the method.

And the argument for the other side really is convenience. In your examples, the constraints on the type definition are always any, which hides the inconvenience - interface{ any; A } is equivalent to A, so you never had to do that replacement. But as soon as you start using non-trivial constraints on the type, you'll notice quickly that you get annoyed with having to define those interfaces:

type X[T fmt.Stringer] struct { /* … */ }

// unreadable mess
func (X [T interface{
    comparable
    fmt.Stringer
}]) M() {
    // …
}

// pollutes namespace with wordy identifiers and is boilerplate
type ComparableStringer interface {
    comparable
    fmt.Stringer
}
func (X [T ComparableStringer]) M() {
    // …
}

// is far nicer to write and would mean the same thing
func (X [T comparable]) M() {
    // …
}

It's a classical "explicitness vs. boilerplate" tradeoff. Personally, I believe that in the long run, we'll find the less restrictive version more convenient and even more readable. But as I said, I'm also okay with starting with the more restrictive version and considering the relaxation later.

@apparentlymart
Copy link

apparentlymart commented Feb 8, 2024

Unfortunately I wasn't able to find the actual issue for it in my quick search just now (I'm forgetting what words were used to describe it) but FWIW there is another active proposal calling for type switch cases that specify multiple interface types to implicitly generate a hidden interface type for the symbol to have, using the intersection of all of the given interfaces:

switch t := something.(type) {
    case Interface1, Interface2:
        // In here t would support the intersection
        // of methods from those interfaces.
}

Edit: here it is: #65031

This raised concerns from folks who maintain the libraries used by tools like gopls of it being challenging for there to be a symbol whose type isn't associated with a specific declaration.

The idea for allowing constrained type arguments to not be strict subtypes of the original constraint seems to create a similar concern. I'm not sure whether to conclude from this that it's infeasible on those grounds or that this is another example use-case that might help justify that additional complexity in the tooling libraries. 🤔

@Merovius
Copy link
Contributor

Merovius commented Feb 8, 2024

@apparentlymart I think that is different. AIUI the main issue with the type-switch for tooling is that the constraint on a single type parameter changes over the scope of that identifier. That is, in

func F[T A]() {
    var v T // pos X
    switch type T {
    case B:
        _ = v // pos Y
    }
}

the constraint on T is different, whether you look at pos X or pos Y. That poses a challenge.

But that's notably a problem that refined method constraints don't have. Because in this case, the constraint on the type-parameter in the method body is still determined in the signature and does not change over its entire scope. It might change between different methods, but notably, those are different identifiers with different scopes and potentially different names, so they are already handled differently in tooling. That is, this is valid right now and if tooling can handle this, it should also be able to handle refined method constraints:

type X[T any] struct{}
func (X[A]) M() {
    var x A
    _ = x
}
func (X[B]) N() {
    var x B
    _ = x
}

I definitely don't see how it would make a difference for tooling, whether or not the constraint is implicitly expanded or not. That is, while I might believe that there is an issue with a single (conceptually equal) type parameter having different constraints in different methods, I don't believe that this depends on how they are expressed.

[edit] okay, reading the issue you linked, I do see that it's about a slightly different problem than what I thought. I still think it's different and I do think this would be workable, but I can see that there might be subtleties that I'm overlooking. I'll get back to this when I have more time [/edit]

@mdempsky
Copy link
Contributor

mdempsky commented Mar 6, 2024

I think this proposal has the same problem with ambiguous method promotion as in #43621. For example:

type Fooer interface { Foo() }

// MaybeFooer[T] has a Foo method, only if the type arguments are comparable.
type MaybeFooer[T any] struct{}
func (MaybeFooer[T comparable]) Foo() { ... }

// IntFooer embeds MaybeFooer[int], so it has a promoted Foo method.
type IntFooer struct { MaybeFooer[int] }

func F[T any]() {
  var x struct {
    MaybeFooer[T] // may have Foo method at depth 0
    IntFooer      // Foo method at depth 1
  }

  // Here we can't assume MaybeFooer[T] has a Foo method.
  // So if we allow x.Foo, it would need to resolve to x.IntFooer.Foo.
  //
  // But reflect.ValueOf(x).MethodByName("Foo") will instead find
  // x.MaybeFooer.Foo, unless we store more runtime type information
  // to discern this case.
}

Does anyone have suggestions on how to handle this case? (@Merovius ?) Otherwise, I think we need to decline this proposal. (Caveat: even with a solution here, I'm not sure we would accept the proposal.)

@jordan-bonecutter
Copy link
Author

Excuse my ignorance, but I don't see the problem here. For each generic instantiation of F, x will have a distinct type. Depending on the value of T this type will either use MaybeFooer's implementation of IntFooer's implementation. Because these cases have different types, they will also have different reflect.Type values which will store such information accordingly. If the instances of x had the same type I could see this being an issue, but their types will be different so to my understanding this shouldn't be a problem. Please enlighten me if that's not true though, would love to solve this problem.

@ianlancetaylor
Copy link
Contributor

@jordan-bonecutter That would require us to use separate compilation for each instantiation of F, which we don't do. Or it would require us to do a dynamic lookup for each call of the Foo method, which we also don't do.

Also note that method lookups are purely by name, not type. It may be possible to construct a case in which the Foo methods have different types. That would be extra confusing.

I don't know that it is impossible to solve these problems. But it's certainly problematic.

@jordan-bonecutter
Copy link
Author

If they are not separately compiled, how do we know what the type of x is? Certainly this differs across invocations, no? If the type of x is always the same I could see how this would be a problem, but even without using monomorphism the type of x should change to my understanding.

@ianlancetaylor
Copy link
Contributor

Yes, the type of x naturally is different when different type arguments are used. But the code remains the same. For more details, see https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-gcshape.md.

@jordan-bonecutter
Copy link
Author

What a wild way to implement generics...

But it seems like it is already the case that runtime information is stored for types defined inside the generic code (here). Shouldn't it already contain information about the callsites for methods on these types?

@jimwei

This comment was marked as spam.

@Merovius
Copy link
Contributor

Merovius commented Mar 7, 2024

@mdempsky Yes, that seems like a problem. The only solution I see right now is to disallow calling Foo in the generic body. Is there a problem with that? If we limit it to the case where embedding a generic type would conditionally make a call ambiguous, it wouldn't be breaking (as currently there are no such cases), would it? That is

type x[T any] struct {
    MaybeFooer[T]
    IntFooer
}
func F[T any]() {
    var y x[T]
    y.Foo() // disallowed: method call depends on type argument
}
func G[T comparable]() {
    var y x[T]
    y.Foo() // allowed: calls y.MaybeFooer.Foo()
}
func H[T ~[]byte]() {
    var y x[T]
    y.Foo() // possibly allowed? If so, would call y.IntFooer.Foo()
}

The H case is interesting and potentially problematic. It requires the compiler to be able to prove that all types in the type set are not comparable, which treads on dangerous territory at least. So perhaps we'd have to be conservative and disallow that as well.

@mdempsky
Copy link
Contributor

mdempsky commented Mar 7, 2024

The only solution I see right now is to disallow calling Foo in the generic body. Is there a problem with that?

Disallowing the "Foo" selector when there's a conditionally defined method at the shallowest embedding depth seems technically feasible. It feels like an awkward exception case to explain to users though.

I'm wary of allowing G or H though. In particular, I'm concerned that more complex instances of G (e.g., using multiple type parameters) might require sophisticated solving logic to prove MaybeFooer[...].Foo is defined.

@Merovius
Copy link
Contributor

Merovius commented Mar 7, 2024

ISTM that allowing G should be pretty straight forward. In particular, it doesn't seem any harder than to prove that a generic function call with the same type parameters and constraints and arguments is allowed. That is

type T[A, …, Z any] struct{}
func (T[A Ca, …, Z Cz]) M() {} // a method with arbitrarily refined constraints

func G[A Ca, …, Z Cz]() {} // a function with the same constraints

func F[A Da, …, Z Dz]() { // a generic function body with some other constraints
    var x T[A, …, Z]
    x.M() // is allowed iff G[A, …, Z]() is allowed
}

So it seems easy to discern whether a given method is defined for a set of defined type parameters: It's the same logic we already have to apply to generic functions. And if we can decide whether a generic method is defined, we can also resolve it to a concrete embedding depth, ISTM, and thus decide whether that depth is ambiguous.

I agree that these rules are somewhat hard to explain, but I can imagine that this analogy ("calling the generic method is allowed if and only if an equivalent generic function call would be allowed") can be used both to define the semantics and explain them. And that the compiler should also be able to give fairly good error messages for the ambiguous case.

As I said, I agree that H is more problematic. Specifically because it has to be allowed if a conditionally defined method doesn't exist. So it negates one side of the implication, which does smell of NP-completeness. I'm okay with settling on "we'd probably have to disallow it", for now.

@mdempsky
Copy link
Contributor

mdempsky commented Mar 7, 2024

When I said I was wary of allowing G, I meant more that I'm concerned about tricky generalizations of G. It seems like the sort of thing that could end up needing a SAT solver.

I agree that G on its own could be handled like you say.

@Merovius
Copy link
Contributor

Merovius commented Mar 7, 2024

I was trying to make an argument for why I don't think it does. I think I'd need a better example to understand the concern, it really seems straight forward to me.

I mean, it seems simple to built a list of all potentially existing methods (plus their depth), it seems straight forward to decide for each, whether it actually always exists in this generic function body and it then seems straight forward to decide whether there are conflicts (with the existing algorithms to check for conflicts). I really don't see the problem.

@mdempsky
Copy link
Contributor

mdempsky commented Mar 7, 2024

type Mer interface { M() }

type T[_ any] struct{}
type (T[_ Mer]) M() {}

type T1[X any] struct { T[X] }
type T2[X any] struct { T[X] }
type T3[X any] struct { T[X] }

type U[A, B, C any] struct { T1[A]; T2[B]; T3[C] }

U[A, B, C] satisfies Mer iff exactly one of A, B, and C satisfies Mer. I'm concerned about the possible existence of more sophisticated test cases that amount to SAT solving.

@Merovius
Copy link
Contributor

Merovius commented Mar 8, 2024

My mental model of how to determine if a selector x.M is allowed in a generic function body is:

  1. First, collect all possible methods and their depths. i.e. pretend that there are no refined method constraints. If, under this assumption, there are multiple M at the minimum depth, return an error.
  2. If the promoted M at minimum depth has no refined constraints, or has refined constraints that are satisfied (i.e. would be allowed like a generic function call), resolve the selector to that.
  3. Otherwise, return an error.

[edit] (A perhaps simpler way to phrase this would be "resolve the selector, pretending all constraints are satisfied. Then check if the constraints on the resolved method are satisfied") [/edit]

This seems like a clearly polynomial algorithm (at least as long as "determine if a generic function call is allowed" is polynomial).

  • It would return an error for your U.M example in step 1: U.T1.M, U.T2.M and U.T3.M all have the same minimum depth.
  • It would reject my F example in step 3: The minimum depth Foo is x.MaybeFooer.Foo, with the refined constraint comparable and any does not satisfy comparable.
  • It would allow my G example: The minimum depth Foo is x.MaybeFooer.Foo and comparable is satisfied by T. So x.Foo resolves to that.
  • It would reject my H example in step 3: ~[]byte does not satisfy comparable.

I think there are cases where reflect.(Type|Value).MethodByName will return different results than the selector expression-check, but I think only in the way that the selector will be rejected in a generic function body, even though the type argument has that method. For example, x[[]byte].Foo would be valid on its own and resolve to x.IntFooer.Foo, even though it is disallowed in H.

To me, that seems fine. It is a case where type-checking a generic function body is more conservative than type-checking the monomorphized function body (even "for all type arguments"). Fundamentally, it doesn't seem all that different from this example, or maybe even this one.

I think it's not unlikely that I'm overlooking something and this algorithm still leads to Bad Outcomes™ for some examples. I'd be curious to see those.

@matfax
Copy link

matfax commented Mar 29, 2024

Would this proposed feature allow something like this?

type ValueToken[T comparable] struct {
	Value T
}

func (t ValueToken[string]) IsApple() bool {
	return "apple" == t.Value
}

func (t ValueToken[string]) IsString() bool {
	return true
}

func main() {
	token := ValueToken[string]{Value: "apple"}
	fmt.Println("IsString:", token.IsString())
	// IsString: true
	fmt.Println("IsApple:", token.IsApple())
	// invalid operation: "apple" == t.Value (mismatched types untyped string and string /* with string declared at ./prog.go:17:20 */)
}

It's not technically a subset/covariance, just a struct-generic type-specific method. It's not a type parameter for the method itself, like in #49085. It could probably be done once generic types can be switched, just using a syntax that appears more consistent with other languages and omits the switch boilerplate.

It's just confusing to me that the above example could be done 1:1 without generics, by replacing ValueToken[string] with StringToken and string for T (comparable). Even the error message is nonsensical because both types are labeled as strings; string is comparable; T is comparable. At the moment, this method would have to be implemented like this:

func (t ValueToken[T]) IsApple() bool {
    return t.Value == any("apple").(T)
}

This was the closest issue I could find, I apologize if it's unrelated.

@gophun
Copy link

gophun commented Mar 29, 2024

@matfax
In your code func (t ValueToken[string]) is essentially equivalent to func (t ValueToken[T]), with the only difference being the naming of the type parameter T. However, using string as the name for T is misleading since it shadows the built-in string type. You can find more information about this in the Go FAQ: https://go.dev/doc/faq#types_in_method_declaration

Also, this kind of specialisation that you're asking for is unrelated to this issue.

@Merovius
Copy link
Contributor

@matfax Your example would do the same thing, because the way you wrote it means something different - you are using string as the identifier for the type parameter, instead of T, thus introducing a new defined type distinct from the predeclared string. The way it would be written under this proposal would be

type ValueToken[T comparable] struct {
	Value T
}

func (t ValueToken[T string]) IsApple() bool {
	return "apple" == t.Value
}

func (t ValueToken[T string]) IsString() bool {
	return true
}

func main() {
	token := ValueToken[string]{Value: "apple"}
	fmt.Println("IsString:", token.IsString())
	// IsString: true
	fmt.Println("IsApple:", token)
	// IsApple: true
}

Note that in the receiver, the type parameter list now gives both a name and a constraint. Currently, that is not valid - this proposal would make that valid code.

It's just confusing to me that the above example could be done 1:1 without generics

Yes, it's not a particularly useful example for this proposal. You would want to include some instantiations that are not string to demonstrate its effect.

Here is an example that makes a bit more sense:

type Op int

const (
    OpAdd Op = iota
    OpSub
    OpMul
    OpDiv
)

func (o Op) String() string {
    switch o {
    case OpAdd:
        return "+"
    case OpSub:
        return "-"
    case OpMul:
        return "*"
    case OpDiv:
        return "/"
    default:
        panic("invalid op")
    }
}

type BinaryExpr[T any] struct {
    Left T
    Right T
    Op Op
}

func (e BinaryExpr[T fmt.Stringer]) String() string {
    return e.Left.String() + " " + e.Op.String() + " " +e.Right.String()
}

type Number interface {
    constraints.Integer | constraints.Float | constraints.Complex
}

func (e BinaryExpr[T Number]) Eval() T {
    switch e.Op {
    case OpAdd:
        return e.Left + e.Right
    case OpSub:
        return e.Left - e.Right
    case OpMul:
        return e.Left * e.Right
    case OpDiv:
        return e.Left / e.Right
    default:
        panic("invalid op")
}

func main() {
    e1 := BinaryExpr[time.Duration]{2*time.Second, time.Second, OpAdd}
    fmt.Println(e1.String()) // "2s + 1s"
    fmt.Println(e1.Eval()) // "3s"
    e2 := BinaryExpr[int]{2, 1, OpMul}
    fmt.Println(e2.String()) // compile error: e2 has no method "String" (int does not satisfy fmt.Stringer)
    fmt.Println(e2.Eval()) // 3
}

@matfax
Copy link

matfax commented Mar 29, 2024

However, using string as the name for T is misleading since it shadows the built-in string type.

I see. That explains why the first function call is executed.

Here is an example that makes a bit more sense:

Thank you for clarifying. I'm glad that someone is already on this and that my use case would be covered one day. I'm working on an AST parser, so your example fits perfectly.

Edit: By the way, my initial code also executes by creating a separate type for the specific generic and using this inplace. So it could just be syntactic sugar that avoids such confusion.

type StringToken ValueToken[string]

@ianlancetaylor ianlancetaylor changed the title proposal: Go 2: allow methods on generic structs which meet subset of type constraint proposal: spec: allow methods on generic structs which meet subset of type constraint Aug 6, 2024
@ianlancetaylor ianlancetaylor added LanguageChangeReview Discussed by language change review committee and removed v2 An incompatible library change labels Aug 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
generics Issue is related to generics LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Projects
None yet
Development

No branches or pull requests