-
Notifications
You must be signed in to change notification settings - Fork 17.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
proposal: spec: allow methods on generic structs which meet subset of type constraint #65394
Comments
Duplicated with #64846 |
Another interesting (I think) variation of the 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 With that said, this particular example of the phenomenon is perhaps not super compelling because we already have (I'm assuming in the above that the intent is that |
@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 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.) |
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. |
The proposed syntax won't work, it already has a meaning https://go.dev/doc/faq#types_in_method_declaration 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:
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. |
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:
However, I'm only assuming that's what this proposal was intended to mean, I suppose. Please correct me if I misunderstood, @jordan-bonecutter. |
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 |
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 interfacesOne 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
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 SpecializationI 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. FeasibilityAs @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 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 constraintsThe 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 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 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 Ultimately, if the type has constraint
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. |
@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 |
Note that the "implicit interfaces" are always of the form Specifically, a programmer writing type X[T A] struct{ /* … */ }
func (X[T B]) M() { /* … */} with some 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 And the argument for the other side really is convenience. In your examples, the constraints on the type definition are always 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. |
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. 🤔 |
@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 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] |
I think this proposal has the same problem with ambiguous method promotion as in #43621. For example:
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.) |
Excuse my ignorance, but I don't see the problem here. For each generic instantiation of |
@jordan-bonecutter That would require us to use separate compilation for each instantiation of Also note that method lookups are purely by name, not type. It may be possible to construct a case in which the I don't know that it is impossible to solve these problems. But it's certainly problematic. |
If they are not separately compiled, how do we know what the type of |
Yes, the type of |
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? |
This comment was marked as spam.
This comment was marked as spam.
@mdempsky Yes, that seems like a problem. The only solution I see right now is to disallow calling 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 |
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. |
ISTM that allowing 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 |
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. |
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. |
|
My mental model of how to determine if a selector
[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).
I think there are cases where 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. |
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 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. |
@matfax Also, this kind of specialisation that you're asking for is unrelated to this issue. |
@matfax Your example would do the same thing, because the way you wrote it means something different - you are using 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.
Yes, it's not a particularly useful example for this proposal. You would want to include some instantiations that are not 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
} |
I see. That explains why the first function call is executed.
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] |
Go Programming Experience
Experienced
Other Languages Experience
JS, C, Python
Related Idea
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:
I may want to write the following function:
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:
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
The text was updated successfully, but these errors were encountered: