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 type parameters in methods #49085

Open
mariomac opened this issue Oct 20, 2021 · 310 comments
Open

proposal: spec: allow type parameters in methods #49085

mariomac opened this issue Oct 20, 2021 · 310 comments
Labels
generics Issue is related to generics LanguageChange Suggested changes to the Go language LanguageChangeReview Discussed by language change review committee Proposal
Milestone

Comments

@mariomac
Copy link

mariomac commented Oct 20, 2021

According to the Type parameters proposal, it is not allowed to define type parameters in methods.

This limitation prevents to define functional-like stream processing primitives, e.g.:

func (si *stream[IN]) Map[OUT any](f func(IN) OUT) stream[OUT]

While I agree that these functional streams might be unefficient and Go is not designed to cover this kind of use cases, I would like to emphasize that Go adoption in stream processing pipelines (e.g. Kafka) is a fact. Allowing type parameters in methods would allow constructing DSLs that would greatly simplify some existing use cases.

Other potential use cases that would benefit from type paremeters in methods:

  • DSLs for testing: Assert(actual).ToBe(expected)
  • DSLs for mocking: On(obj.Sum).WithArgs(7, 8).ThenReturn(15)

Edited by @ianlancetaylor to add: for a summary of why this has not been approved, please see https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#no-parameterized-methods .

@gopherbot gopherbot added this to the Proposal milestone Oct 20, 2021
@fzipp
Copy link
Contributor

fzipp commented Oct 20, 2021

The document also explains what the problems are. So what are your solutions to these?

@zigo101
Copy link

zigo101 commented Oct 20, 2021

This proposal is good to define an io.ImmutableWriter {Write(data byteview)(int, error)} interface:
https://github.com/go101/go101/wiki/A-proposal-to-avoid-duplicating-underlying-bytes-when-using-strings-as-read-only-%5B%5Dbyte-arguments

@ianlancetaylor ianlancetaylor changed the title Proposal: Allow type parameters in methods proposal: spec: allow type parameters in methods Oct 20, 2021
@ianlancetaylor
Copy link
Contributor

This proposal is a non-starter unless someone can explain how to implement it.

@ianlancetaylor ianlancetaylor added the WaitingForInfo Issue is not actionable because of missing required information, which needs to be provided. label Oct 20, 2021
@deanveloper
Copy link

deanveloper commented Oct 20, 2021

@ianlancetaylor from the generics proposal

Or, we could decide that parameterized methods do not, in fact, implement interfaces, but then it's much less clear why we need methods at all. If we disregard interfaces, any parameterized method can be implemented as a parameterized function.

I think this solution makes the most sense. They could then (under the hood) be treated a regular function. The reason why this would be useful is that methods do not only serve the purpose of implementing interfaces; methods also serve as a means of organization for functions that operate on particular structures.

It may be a bit of a challenge about how type-parameterized methods would appear in "reflect", though.

@zigo101
Copy link

zigo101 commented Oct 21, 2021

The problem would be simpler if the parameter type possibility set is known at compile time,

It may be a bit of a challenge about how type-parameterized methods would appear in "reflect", though.

One new problem I'm aware of is there might be many methods have the same name for a certain type.
So the Type.MethodByName might return a slice value (assume the parameter type possibility set is known at compile time).
Any other new problems?

@seankhliao seankhliao added the generics Issue is related to generics label Oct 21, 2021
@AndrewHarrisSPU
Copy link

AndrewHarrisSPU commented Oct 21, 2021

One new problem I'm aware of is there might be many methods have the same name for a certain type.

If we're ultimately talking about multiple dispatch, the languages that really cater towards this do a massive amount of overloading. One language I find fun and interesting with this style is Julia, where things like + or * or show have hundreds of overloads when booting the REPL. From a software engineering perspective, there are tradeoffs - I absolutely trust Go to compile long into the future, and to have fewer surprises about packages ... Remarkably and IMHO related to robustness, Go doesn't have programmers defining function overloads in source code - I'm not convinced generics should change this.

Particularly for stream-to-stream conversion, I do think that the List Transform example is useful. We have to provide a concrete T1 -> T2 conversion function, but in a sense we have to figure out how to convert T1 to T2 in any kind of system.

I think it's also often possible to have a higher-order function that generates conversion functions, while more specialized conversion functions are also naturally expressible in Go. Example: a very generic color conversion API might specify an interface with ToRGB() and FromRGB() methods, and this can go pretty far. We can express 8-bit to 16-bit RGB conversion here through the interfaces, the same as e.g. HSV or LAB conversions, but there's a faster bit-shifting path. With a sense of a generic default, something like bufio.Scanner seems plausible - where the default just works, but we can optionally provide a better color conversion the same way we can provide a different SplitFunc.

@DeedleFake
Copy link

DeedleFake commented Oct 21, 2021

@deanveloper

Even just that would allow for, for example, an iterator implementation, though it does require wrapping it in another type because of the lack of extension functions:

type Nexter[T any] interface {
  Next() (T, bool)
}

type NextFunc[T any] func() (T, bool)

func (n NextFunc[T]) Next() (T, bool) {
  return n()
}

type Iter[T any, N Nexter[T]] struct {
  next N
}

func New[T any, N Nexter[T]](next) Iter[T, N] {
  return Iter[T, N]{next: next}
}

func (iter Iter[T, N]) Map[R any](f func(T) R) Iter[R, NextFunc[R]] {
  return New(NextFunc[R](func() (r R, ok bool) {
    v, ok := iter.next.Next()
    if !ok {
      return r, false
    }
    return f(v), true
  })
}

// And so on.

Usage is still awkward without a short-form function literal syntax, though, unfortunately:

s := someIter.Filter(func(v int) bool { return v > 0 }).Map(func(v int) string { return strconv.FormatInt(v, 10) }).Slice()
// vs.
s := someIter.Filter(func(v) -> v > 0).Map(func(v) -> strconv.FormatInt(v, 10)).Slice()

@batara666
Copy link

This will add so much complexity

@deanveloper
Copy link

@batara666 can you explain why? adding type parameters to methods doesn’t seem like it’d add that much complexity to me.

@Merovius
Copy link
Contributor

Merovius commented Oct 27, 2021

I think before we can think about if and how to do this, we should first address the "no higher level abstraction" restriction of generics, i.e. the inability to pass around a generic type/function without instantiation. The reason is that if we allowed additional type parameters on methods, we'd also de-facto allow to pass around generic functions without instantiation:

type F struct{}

func (F) Call[T any] (v T) { /* … */ }

func main() {
    var f F // f is now de-facto an uninstantiated func[T any](T)
}

Therefore, to allow additional type-parameters on methods, we also have to answer how to pass around uninstantiated generic functions.

Moreover, if we'd allow passing around uninstantiated generic types/functions, we could already build the abstractions given as motivations in the proposal-text.

So, given that solving "no higher level abstraction" is a strictly easier problem to solve, while providing most of the benefit of solving "no additional type parameters on methods", it seems reasonable to block the latter on solving the former.

Lastly, I'd urge everyone to consider that these limitations where not left in the generics design by accident. If they where really that easy to solve, the solution would have been in the design to begin with. It will take some time to solve them.

@ianlancetaylor ianlancetaylor removed the WaitingForInfo Issue is not actionable because of missing required information, which needs to be provided. label Nov 3, 2021
@zigo101
Copy link

zigo101 commented Nov 12, 2021

The "type" concept almost means a memory layout.
If we could use the 1.18 constraint concept as general types,
then many problems will be solved.

A value of a subset type could be passed/assigned to a superset type.
A function with a superset type parameter could be used as a function with a subset type parameter.

For Go, the change would be too large.
It is best to experiment the idea on a new language.

@AndrewHarrisSPU
Copy link

I was (far too) pleased (with myself) when I figured out this is possible: https://gotipplay.golang.org/p/1ixYAwxwVss

The part about lifting values into type system 'symbols' feels like a bit DIY and tricky, not sure there isn't something better here. I did feel like the dispatch() call is technically interesting. Inferring from the function/method supplied to dispatch() wasn't obvious to me at first. Without overloading methods, just different instantiations of the dispatch() function, it is plausible to arrive at the correct dispatch over a set of concrete implementations.

@ianlancetaylor
Copy link
Contributor

I'm putting this proposal on hold until we have more familiarity with the current generics implementation.

@mariomac
Copy link
Author

mariomac commented Dec 4, 2021

Playing with a new library that intensively uses generics, I provided an equivalence implementation between a Method and a Function:

https://github.com/mariomac/gostream/blob/bf84997953f02b94e28da0d6c4d38585d2677df2/stream/str_to_str.go#L5-L14

At the end, the difference is only where the parameter is placed (as a receiver, or as a first argument), but the function allows you map to a Stream of different type and with the method you can only generate streams of the same type.

With the code from the above link, I verified that this compiles:

type Mapper[IT, OT any] func(Stream[IT], func(IT)OT) Stream[OT]
var _ Mapper[int, float64] = Map[int, float64]

Simplifying, and obviating some internals from Go that I might ignore, I could see the generic Mapper type as a "single-method generic interface", that is implemented by the Map function, and it can be instantiated into a Mapper instance with concrete types.

In order to overcome the No parametrized methods issue pointed by @fzipp, from my partial view, I think that the example issue can be approached the same way as Java does: using interface{} behind the scenes and panic if the customer did a bad assignment (also the compiler could warn about the unsafe operation). Then for example the code from the example:

func CheckIdentity(v interface{}) {
	if vi, ok := v.(p2.HasIdentity); ok {
		if got := vi.Identity[int](0); got != 0 {
			panic(got)
		}
	}

Would be translated to something equivalent to:

func CheckIdentity(v interface{}) {
	if vi, ok := v.(p2.HasIdentity); ok {
		if got := vi.Identity(0).(int); got != 0 {
			panic(got)
		}
	}

Then the third line would panic if the v interface does not implement Identity[int]. The same way that Go does currently when you try to cast an identity{} reference to a wrong type.

In this case, we are translating the error check from the compile time to the runtime, but anyway this is what we actually have now if lack of parametrized methods forces us to continue using unsafe type castings.

@ianlancetaylor
Copy link
Contributor

In your rewrite of CheckIdentity what do we gain by using a type parameter with the method? If the code is not type checked at compile time, we can just return an interface type, which already works today.

@deanveloper
Copy link

deanveloper commented Dec 4, 2021

I think that the example issue can be approached the same way as Java does: using interface{} behind the scenes and panic if the customer did a bad assignment

This is a bad idea in my opinion - Type erasure is one of the most annoying limitations of generics in Java. I think it would go against the grain of the simplicity that Go aims for.

@mccolljr
Copy link

mccolljr commented Dec 7, 2021

I don't really have a horse in this race, but I find this proposal interesting and wanted to put down my thoughts.

Based on what I see here: https://go.godbolt.org/z/1fz9s5W8x
This:

func (x *SomeType) Blah() { /* ... */ }

And this:

func Blah(x *SomeType) { /* ... */ }

compile to nearly identical code.

If we have a type S:

type S struct {
    /* ... */
}

...and S has a method DoThing with a type parameter T:

func (s S) DoThing[T any](arg T) { /* ... */ }

...then we effectively have a generic function with the signature:

func DoThing[T any](s S, arg  T) { /* ... */ }

Of course, if we have a generic type G:

type G[T any] struct {
    /* ... */
}

...and G has a method DoStuff with a type parameter U:

func (g G[T]) DoStuff[U any](arg U) { /* ... */ }

...then we effectively have a generic function with the signature:

func DoStuff[T, U any](g G[T], arg U) { /* ... */ }

In order to use either of these "functions", all of the type parameters have to be known.

That means that in the case of S, the only way to refer to S.DoThing is to instantiate it: S.DoThing[int], S.DoThing[float64], etc.
The same is true for G, with the additional requirement that G is also instantiated: G[int].DoThing[float64], etc.

Within this limited context, it seems to me like it wouldn't be a huge leap to allow type parameters on methods - it ends up referring to what is essentially a generic function, and we know things about generic functions:

  1. They can't be used unless all type parameters are known statically at compile time, and
  2. Each unique instantiation results in a unique function, semantically speaking (the actual implementation of course may choose to use a single function and internally use type switching, etc, etc)

The mechanism by which this interacts with interface definitions/implementations is less clear to me. Though, I think it is reasonable to say that a generic interface can't be implemented directly - it must be instantiated first. I'm not as sure of this, but it seems that it might also be true that an interface can only be implemented by a fully instantiated type.

Even in code like this:

type GenericInterface[T any] interface {
    Foo() T
}

type GenericStruct[T any] struct {
    Bar T
}

func (g GenericStruct[T]) Foo() T {
    return g.Bar
}

func MakeGeneric[U any]() GenericInterface[U] {
    return GenericStruct[U]{}
}

It seems like, within the context of MakeGeneric[T], we could consider both GenericInterface[T] and GenericStruct[T] to be instantiated with the some specific type T, which is the type value given to the type parameter U in MakeGeneric[U]. The determination that GenericStruct[T] implements GenericInterface[T] in this context is different from making a general statement that "GenericStruct[T] implements GenericInterface[T] for all T", which is what I would think of as "implementation without instantiation"

One area that seems complex is interfaces whose methods have type parameters.

For example, if we had:

type Mappable[T any] interface {
    Map[U any](func(T) U) []U
}

What would it mean to "instantiate" Mappable[T]?
Can you use a type assertion such as blah.(Mappable[int]?
If Mappable[T].Map had the signature Map[U comparable](func(T) U) []U, would a type with a method
Map[U any](func(T) U) []U be treated as implementing Mappable[T]?
This kind of interface seems to introduce a lot of ambiguity that would be difficult to resolve in a satisfactory manner.

It seems much simpler to disallow that kind of interface entirely, and just require something like:

type Mappable[T, U any] interface {
    Map(func(T) U) []U
}

I think that could still be just as useful, depending on how interface implementation is handled when the underlying type has methods with generic parameters.

As an example:

// Slice[T] provides slice operations over a slice of T values
type Slice[T any] []T

// Map[U] maps a Slice[T] to a Slice[U]
func (s Slice[T]) Map[U any](func (T) U) Slice[U]

type Mappable[T, U any] {
    Map(func (T) U) Slice[U]
}

// In order for this assignment to be valid:
// 1. Slice[int] must have a method named Map ✅
// 2. Slice[int].Map must have the same number of arguments ✅
// 3. Slice[int].Map must have the same number of returns ✅
// 4. Slice[int].Map must have the same types for each argument and return ???
var _ Mappable[int, float64] = Slice[int]{1,2,3}

It seems reasonable to me to say that Slice[int] implements Mappable[int, float64], since the method Map on Slice[int] can be instantiated & called with a U set to float64.

In this case, assuming that methods with type parameters are allowed, I would think the compiler could do something like:

  1. Notice that Mappable[int, flloat64] is implemented for Slice[Int] when Slice[int].Map is insantiated with float64
  2. Generate the code for that instantiation of Slice[int].Map, and
  3. Use the pointer to that particular instantiation of Slice[int].Map in the vtable

If you're calling the method an the interface object, then you only have access to that one particular instantiation of
the Slice[int].Map method. If use a type assertion to get back the original Slice[int] type, then you can of course call any number of Map variants on it, because the compiler knows what the concrete type is again.

To summarize:

Given that it is a feature of go that interface implementation can be tested at runtime via type assertions, reflection, etc, I don't see any way around banning generic methods on interface definitions. However, because methods are more or less sugar for functions, it seems to me it would be possible to allow generic parameters on methods of concrete types, and to allow these methods to participate in implementation of fully instantiated interface types.

@Merovius
Copy link
Contributor

Merovius commented Dec 7, 2021

Given that it is a feature of go that interface implementation can be tested at runtime via type assertions, reflection, etc, I don't see any way around banning generic methods on interface definitions. However, because methods are more or less sugar for functions, it seems to me it would be possible to allow generic parameters on methods of concrete types, and to allow these methods to participate in implementation of fully instantiated interface types.

I don't think you are solving the problems from the design doc, though:

type IntFooer interface { Foo() int }
type StringFooer interface { Foo() string }
type X struct{}
func (X) Foo[T any]() T { return *new(T) }

func main() {
    var x X
    x.(StringFooer) // How does this work? Note that we can't use runtime code generation
    reflect.ValueOf(x).MethodByName("Foo").Type() // What is this type?
}

This fulfills all your criteria, it uses no parameterized interfaces and X is a concrete (non-parameterized) type. In particular, answering these questions here is the minimum required to make this feature useful.

It is very easy to look at the proposal text and think "this would be a useful feature to have, I obviously would like it in the language". But because it's such an obvious feature to put in, it would be great if people ask themselves why the Go team didn't put it in in the first place. Because there are reasons and these reasons need answering.

@alvaroloes
Copy link

I would like to drop an idea here which I think can be useful for the "type parameters in methods" topic. Maybe it has an obvious flaw I haven't seen or it has already been considered or discussed, but I couldn't find anything about it. Please, let me know if so.

With the current proposal, we can't have type parameters in methods but, couldn't we achieve the same effect if we put the type parameters in the type definition?

I mean, instead of doing this:

type Slice[T any] []T

func (s Slice[T]) Map[U any](func (T) U) Slice[U]

Do this (move the U type parameter from the method "Map" to the struct):

type Slice[T any, U any] []T

func (s Slice[T,U]) Map(func (T) U) Slice[U]

@Merovius Wouldn't this solve the issue you mentioned in the above comment? Your example would end up like this:

type IntFooer interface { Foo() int }
type StringFooer interface { Foo() string }
type X[T any] struct{}
func (X) Foo() T { return *new(T) }

func main() {
    var x X[int] // You are forced to specify the type parameter here with the current proposal. I guess it could be inferred it this were an initialization instead of a declaration only
    x.(StringFooer) // This would fail, as it doesn't conform to the interface
    x.(IntFooer) // This would pass
    reflect.ValueOf(x).MethodByName("Foo").Type() // "Foo(X) int"
}

I think this would work with the current proposal without changes.

As I said, I could be missing something obvious here. Let me know if that's the case.

@Merovius
Copy link
Contributor

Merovius commented Dec 7, 2021

@alvaroloes

couldn't we achieve the same effect if we put the type parameters in the type definition?

You can easily do this, but it's not the same effect. People who want this specifically want the type-parameter of the method to be independent of the type itself, to implement higher-level abstractions.

@alvaroloes
Copy link

I see, thanks. After thinking it twice, I now see that what I propose would be very limiting as, after instantiating the type, you could not call the method with different type parameters (for example, call "map" with a function that return strings one time and then another time with a function that return ints).

All right, I know there was something obvious here. Thanks for the response!

@mccolljr
Copy link

mccolljr commented Dec 7, 2021

I don't think you are solving the problems from the design doc, though:

I'm sure that's true, I probably would benefit from reviewing it again. To be fair, though, I wasn't trying to put together a concrete proposal - I understand why this is a complex topic and why it isn't in the first pass at generics, and why it may never be added to the language. I don't have a horse in this race beyond the fact that I find this interesting. My intent was to think out loud about what restrictions might make this more concretely approachable. Also, for what it's worth, I think that disallowing parameterized methods on interface types could be seen to addresses some of the problems put forth in the proposal.

type IntFooer interface { Foo() int }
type StringFooer interface { Foo() string }
type X struct{}
func (X) Foo[T any]() T { return *new(T) }

func main() {
    var x X
    x.(StringFooer) // How does this work? Note that we can't use runtime code generation
    reflect.ValueOf(x).MethodByName("Foo").Type() // What is this type?
}

[...] answering these questions here is the minimum required to make this feature useful.

I do not disagree. I feel as if you may have misinterpreted my intent. I am not saying "this is so easy, look at how we can do it" - I am saying "here is an interesting constraint that might make this more approachable, and which could perhaps be used as the basis for additional discussion"

I'm willing to brainstorm this, but again I am not proposing a concrete solution as much as attempting to provide a possible set of constraints for discussion. If that exercise shows that thia feature would too complex, that is a totally acceptable outcome in my opinion.

Obviously we cannot use runtime code generation, I don't recall proposing that nor do I think it is necessitated by anything said above. Given that, here are some possible (not exhaustive or comprehensive) directions the compiler could choose:

For x.(StringFooer)

  • The program could encode the types that a generic method/function/etc has been instantiated with. This would allow x.(StringFooer) to correctly select the implementation of Foo that applies. Of course, if X.Foo is never explicitly instantiated with string, then it could be surprising to a user that this fails. Perhaps the cost of that potential confusion is unacceptable. This failure could of course be solved by adding var _ StringFooer = X{} somewhere in the code, and perhaps the panic message could indicate that the failure was due to uninstantiated generic methods rather than uninstantiated

  • The compiler could generate a fallback implementation using interface{} or some minimum interface that it can use in these situations. In the case of type sets, the fallback could use a type switch. Perhaps if type switching on ~ types is implemented this becomes easier.

For reflect

  • Similar to above, the compiler could generate metadata about which instantiation were generated and this could be introspectable from reflect. A public IsGeneric flag could be added to the descriptor for methods and calls to method values could validate the given types against the instantiation list to verify the proper code had been generated.

  • Similar to above, the compiler could simply generate fallback implementations for generic methods (functions etc).

It is very easy to look at the proposal text and think "this would be a useful feature to have, I obviously would like it in the language". But because it's such an obvious feature to put in, it would be great if people ask themselves why the Go team didn't put it in in the first place. Because there are reasons and these reasons need answering.

This is exactly what I attempted to do here. I wrote this at 1am on the last legs of a cup of coffee, and it seems I failed to consider some scenarios in my comment. A simple "How would this address X and Y" would have accomplished the same effect without this lecture at the end.

@Merovius
Copy link
Contributor

Merovius commented Dec 7, 2021

To be clear, this is what the proposal says about this question:

We could instantiate it at link time, but in the general case that requires the linker to traverse the complete call graph of the program to determine the set of types that might be passed to CheckIdentity. And even that traversal is not sufficient in the general case when type reflection gets involved, as reflection might look up methods based on strings input by the user. So in general instantiating parameterized methods in the linker might require instantiating every parameterized method for every possible type argument, which seems untenable.

Or, we could instantiate it at run time. In general this means using some sort of JIT, or compiling the code to use some sort of reflection based approach. Either approach would be very complex to implement, and would be surprisingly slow at run time.

Or, we could decide that parameterized methods do not, in fact, implement interfaces, but then it's much less clear why we need methods at all. If we disregard interfaces, any parameterized method can be implemented as a parameterized function.

So while parameterized methods seem clearly useful at first glance, we would have to decide what they mean and how to implement that.

The solution you suggest seem a variation on the second option here.

@andig
Copy link
Contributor

andig commented Mar 29, 2024

Fwiw, here's one thing I'd like to do if I had type parameters, note the second method which does not depend on any type parameter of Settings. Maybe it would be possible to enable a subset of use cases?

func (s *Settings) String(key string) (string, error) {
	if s == nil {
		return "", nil
	}
	return settings.String(s.Key + key)
}

func (s *Settings) StringAs[T encoding.TextUnmarshaler](key string) (T, error) {
	var t T
	s, err := settings.String(key)
	if err == nil {
            err = t.UnmarshalText([]byte(s))
	}
	return t, err
}

@Merovius
Copy link
Contributor

@andig Why not

func (s *Settings) UnmarshalInto(key string, p encoding.TextUnmarshaler) error {
    v, err := s.String(key)
    if err != nil {
        return err
    }
    return p.UnmarshalText([]byte(v))
}

It's a little bit more to type at the call-site, but I don't see why you need generics at all here.

off-topic, but I'll also note that your example probably wouldn't work, you'd need

type TextUnmarshalPointer[T any] interface {
    *T
    encoding.TextUnmarshaler
}

func (s *Settings) StringAs[T any, PT TextUnmarshalPointer[T]](key string) (T, error) {
	var t T
	s, err := settings.String(key)
	if err == nil {
            err = PT(&t).UnmarshalText([]byte(s))
	}
	return t, err
}

@kodeyeen
Copy link

kodeyeen commented Jul 27, 2024

func CheckIdentity(v interface{}) {
	if vi, ok := v.(p2.HasIdentity); ok {
		if got := vi.Identity[int](0); got != 0 {
			panic(got)
		}
	}
}

We could instantiate it at link time, but in the general case that requires the linker to traverse the complete call graph of the program to determine the set of types that might be passed to CheckIdentity. And even that traversal is not sufficient in the general case when type reflection gets involved, as reflection might look up methods based on strings input by the user.

Whoever is going to call a method by user input string? And if there is someone, just don't allow to call parametrized methods (panic, for example) as it is not instantiated. That would be fair and logical.

So in general instantiating parameterized methods in the linker might require instantiating every parameterized method for every possible type argument, which seems untenable.

Why instantiate for every possible type argument. For example, in the example with p3.CheckIdentity just instantiate Identity[int] methods for all types that have Identity method (only with int parameter).

@aarzilli
Copy link
Contributor

Whoever is going to call a method by user input string?

Anyone using text/template.

@kodeyeen
Copy link

Whoever is going to call a method by user input string?

Anyone using text/template.

Oh, didn't know.
But what about last sentence? Is it possible?

@ianlancetaylor ianlancetaylor added LanguageChangeReview Discussed by language change review committee and removed v2 An incompatible library change Proposal-Hold labels Aug 6, 2024
@adonovan
Copy link
Member

adonovan commented Aug 28, 2024

(@ianlancetaylor points out that my suggestion below is substantially similar to the one proposed by @jpap in #49085 (comment). I post it here in case it sheds light on the state of the problem or the details of the technical challenges.)

This proposal is a non-starter unless someone can explain how to implement it.

If there were no interfaces, this feature would be straightforward to implement. The problem: generic methods mean that the method set of a type is potentially infinite, and the code for all the methods is not generated unless it is needed. For a concrete type C, we cannot know at build time whether code for a given method C.f[X] is needed: there are infinite possibilities of X and they cannot be approximated by even whole-program static analysis. We need a way to query and make available the method when the demand arises, namely, when we execute an x.(I) operation.

For now let's restrict ourselves to interfaces without generic methods, as this still gives us the expressiveness benefits described above. (Is this a reasonable restriction? I haven't yet thought about what it would take to relax it, or even whether it is fundamentally a harder problem.)

Consider:

     type C struct{}
     func (C) f[T any](T) {}

     type I interface { f(int) }

     var x any = C{}
     i := x.(I)
     i.f(0)

For security reasons, Go doesn't use run-time code generation, and we don't want to change that. But we could compile each generic method C.f[T] to object code of a generalized form in which each T value (or value whose type contains T, such as *T or []T) is represented by a "box" that pairs the value along with a vtable-like descriptor of its intrinsic operations: rtype, hash, eq, arithmetic, a[i], etc. Every operation within C.f on a T-derived value would be compiled to an indirect operation through this descriptor.

(Obviously this would be less efficient than the expanded code we would get for C.f[int] or any other specific instance known at build time. In particular, non-escaping locals would have to be heap allocated since their size is not statically known. I think Ian's original generics proposal years back described something like this as a possible implementation strategy of generics in general.)

Now consider the implementation of the x.(I) operation. It must query the itable of x, which is C-as-any, for a method f(int). Let's assume this method was not created at build time, so it is not found. The operation would then look at the types (C, I) and deduce that C can implement I so long as method C.f[T] is instantiated at T=int. So, for each such generic method, we obtain a descriptor such as T=int. The function that is then inserted into the itable for C-as-I would be a generic (assembly) adaptor function that is responsible for turning a call to I.f(int) into a call to the general C.f[T] implementation, passing the
T=int descriptor as the final argument. (Is such an adaptor function implementable?)

Since one adaptor function must work for all types, it's clear that just a method address in the itable is not enough: the itable needs to be a set of pairs of (method address, descriptor), and this means that the calling convention for interface methods would need to change. Instead of simply indexing the itable and CALLing the address, the call would need to add the method type descriptor found in the itable to the end of the argument list before the CALL.

This means all methods (generic or not) called via an itable would get an extra argument, whether they like it or not, which they would ignore. (Only the special adaptor needs to look at the descriptor.) The compiler would need to add this (hidden, ignored) parameter to all methods, but since it is ignored, it should be safe to continue to use the method as a func value in a method expression such as _ = Type.method.

Obviously this scheme has a number of serious limitations and costs:

  • it would work (for now) only for non-generic interface types;
  • the generated code for generic method bodies not instantiated at build time would be much slower, perhaps surprisingly so (and it may not be possible to work around with explicit var _ I = C.f[int] declarations);
  • the compiler would need to translate function bodies in two very different ways, adding complexity;
  • the itables would be twice as big;
  • the cost of constructing itables would be higher;
  • the cost of a failed x.(I) type assertion would be larger (or are misses memoized?);
  • the call sequence for an interface method would have to load two values out of the itable and push one on the stack before the CALL; and
  • the compiler would have to reserve an extra unused parameter slot for methods

Any one of these (the first three in particular) may be prohibitive. However, I think it would be a correct implementation of
type-parameterized methods. Perhaps others can think of optimizations.

@jpap
Copy link
Contributor

jpap commented Aug 29, 2024

Since I was @, allow me a few brief comments:

If there were no interfaces, this feature would be straightforward to implement.

What about reflect? I'd be happy to constrain the use of generic methods under reflect, to those method signatures generated at build time; but that was one of the push-backs I received when I was trying to find and propose a solution path. See footnote.

The problem: generic methods mean that the method set of a type is potentially infinite, and the code for all the methods is not generated unless it is needed.

The set is only unbounded when you consider reflection. Otherwise you could bound the types in the way I had previously proposed, using a pre-link collation and instantiation step. That was also rejected outright, citing elongated build times and toolchain implementation complexity; though I'm sure there'd be clever ways to reduce the build time, perhaps with the help of the build cache which is already extensively relied upon by the toolchain.

For security reasons, Go doesn't use run-time code generation, and we don't want to change that.

The reason code generation was previously deemed undesirable was because of the runtime overhead, implementation complexity, and longer time-to-first-run of a JIT compiled method compared to its non-JIT cousins. Of course, that's not even an option on some sandboxed platforms.

[Most of your example snipped]
... Let's assume this method was not created at build time, so it is not found. ...

But why wasn't it instantiated at build time? ;) At build time, we could determine that:

  1. Interface I has at least one call site for I.f(int).
  2. Type C with generic method f implements interface I.
  3. Therefore we should instantiate C.f(int) and bake it into the binary.

Note that this procedure may instantiate some generic methods that are ultimately not used by the program. That's the bound I reference above.


Footnote:

I previously proposed that it would be far simpler to just disallow generic method instantiations at runtime via reflect, for those method types that are not already instantiated at compile time, that already exist in the binary. That is, declare generic methods not instantiated at build time as unavailable (an error condition) at runtime.

Perhaps we just need to think about reflection not just about "type reflection", but as a reflection of the program structure that is baked into the binary at compile time. 🤷‍♂️

@zephyrtronium
Copy link
Contributor

But why wasn't it instantiated at build time? ;) At build time, we could determine that:

  1. Interface I has at least one call site for I.f(int).
  2. Type C with generic method f implements interface I.
  3. Therefore we should instantiate C.f(int) and bake it into the binary.

Plugins add new types to the program dynamically, defeating any sort of whole-program analysis. Beyond that, how does this procedure deal with these situations?

  • I has a method F[U any](U) called in a function g[U any](I, U), and C has a method F[U any](U). Do we instantiate C.F for every type?
  • I has a method F[U any](U) called in a function g[U any, J I](J, U), and C has the same method, all in package A. Now there's only a call site for I.f((U) if g is instantiated with I itself for the second type parameter. Do we do the work anyway when compiling package A in case package B performs that instantiation?
  • I[U any] has a method F(U) called in a function g[U any, J I[U]](c C) J, and C has a method F[U any](U). Noting that C itself does not have any type parameters, let's say the body of g looks something like
    j, _ := any(c).(J)
    return j
    Now we don't even have a call site for C.F, but we still need to decide whether C implements I[U] for every type U.
  • I has a method F[U, V any](U, V) called in a function g[U, V any](I, U, V) and C has F[U any](U, U). Now C only implements I sometimes. Detecting when it does and thereby when the instantiation is needed is a problem that scales nonlinearly with the number of type parameters. If proposal: spec: support user-defined variance (allow type parameters as type bounds) #67513 is accepted, it becomes much harder, if it remains tractable.

@adonovan
Copy link
Member

[@jpap] ... for methods that already exist in the binary ...

This approach leads to very surprising behaviors: templates that work only when some third-party library is linked in to the program, for example. Reflection routinely manufactures types whose rtype is not linked into the executable, and this is widely relied on; I don't think it would be wise for generic methods to carve out an exception to the rule.

@jpap
Copy link
Contributor

jpap commented Sep 4, 2024

Plugins add new types to the program dynamically, defeating any sort of whole-program analysis.

Generic functions can't be exported across a plugin boundary: see this comment. The effect of issue #52937 was that generic functions are completely skipped from export into the shared object output.

I don't see why it would be unreasonable to do the same thing for generic methods: just disallow types having generic methods from being exportable across the plugin boundary.

I would prefer to have situations as described above manifest as a compile-time error, instead of the silent elision that CL 406358 introduced.

Beyond that, how does this procedure deal with these situations?

Thanks for the examples. If you have any more, it'd be helpful to explicitly write these out in full to make sure we're on the same page. I've made best effort below, but please let me know if you meant something different.

  • I has a method F[U any](U) called in a function g[U any](I, U), and C has a method F[U any](U). Do we instantiate C.F for every type?

What we'd need to do is determine a bound for all the types that could flow through such generic functions g, or similarly nested calls. We can use static analysis to build trees of all such nested calls, then take the types at the root of each tree and propagate them down to each of the call sites.

type C struct{}

func (C) f[T any](T) {}
func (C) F[U any](U) {}

type I interface {
    f(int)
    F[U any](U)
}

func g[U any](i I, u U) {
    i.F(u)
}

var x any = C{}
i := x.(I)
i.f(0)
g(i, 42)   // call site that implies g[int] => I.F[int]
g(i, "42") // call site that implies g[string] => I.F[string]
  • I has a method F[U any](U) called in a function g[U any, J I](J, U), and C has the same method, all in package A. Now there's only a call site for I.f((U) if g is instantiated with I itself for the second type parameter. Do we do the work anyway when compiling package A in case package B performs that instantiation?

There are no call sites in package a with concrete types, so we can't instantiate any generic methods there. But we can resolve the call sites for the full program to determine the type bound, as discussed previously, so that we instantiate a.C.F[int] as a pre-link step, based on the fact that bfunc in package b has a nested call site for type parameter U => int.

package a

type C struct{}

func (C) f[T any](T) {}
func (C) F[U any](U) {}

type I interface {
    f(int)
    F[U any](U)
}

func G[U any, J I](j J, u U) {
    j.F(u) // Your question: do we instantiate when compiling pkg a?
}

package b

import "a"

func bfunc() {
    var x any = C{}
    i := x.(I)
    a.G(i, 42) // call site that implies a.G[int, I] => I.F[int]
}
  • I[U any] has a method F(U) called in a function g[U any, J I[U]](c C) J, and C has a method F[U any](U). Noting that C itself does not have any type parameters, let's say the body of g looks something like
    j, _ := any(c).(J)
    return j
    Now we don't even have a call site for C.F, but we still need to decide whether C implements I[U] for every type U.

Similar to the previous example, where we track types entering functions possibly nested, we could track types returned from functions as well. In this example, we know that type J, which is an alias for I[U any], was returned from function g, and that there were subsequent call sites for types int and string. So we would instantiate generic methods on C for these types.

If we didn't have any generic call sites for method F, and only a call to method f, then we don't make the above method instantiations. Does C still implement I in this case? Yes: but we didn't need any instantiations for C.F.

type C struct{}

func (C) f[T any](T) {}
func (C) F[U any](U) {}

type I interface {
    f(int)
    F[U any](U)
}

func g[U any, J I[U]](c C) J {
    j, _ := any(c).(J)
    return j
}

j := g(C{}) // No call site here; j has type J equivalent to I[U any]

// But eventually we might see some call sites...
j.F(42)
j.F("42")

// Or maybe we don't have the above call sites,
// but only the non-generic method call on f.
j.f(42)

This comment suggests to me that #67513 is unlikely to be accepted.

Nevertheless, I don't see how this scales non-linearly when following the same procedure as outlined: you just look for the combination of types used at each call site.

It is a different problem to determine whether C satisfies I at the type assertion, which is a run-time check. Some comments in the example below discuss further.

type C struct{}
func (C) f[T any](T) {}
func (C) F[U any](U, U) {}

type I interface 
	f(int)
	F[U, V any](U, V)
}

func g[U, V any](u I, u U, v V) {
    u.F(u, v)
}

var x any = C{}
i := x.(I)

// The following call sites mean we would instantiate
// C.F for string and int.
g(i, "42", "42")
g(i, 42, 42)

// The following call site is valid for a variable that
// implements I, but C cannot.  We do not instantiate
// a method for these types on C (it is impossible), but
// we also won't ever try to jump to such a method on C
// (which doesn't exist), because we'd panic at an interface
// conversion before we get here.
g(i, 42, "42")

@jpap
Copy link
Contributor

jpap commented Sep 4, 2024

[@jpap] ... for methods that already exist in the binary ...

This approach leads to very surprising behaviors: templates that work only when some third-party library is linked in to the program, for example. Reflection routinely manufactures types whose rtype is not linked into the executable, and this is widely relied on; I don't think it would be wise for generic methods to carve out an exception to the rule.

I'm not arguing for an exception per se, I'm arguing for a redefinition of what reflection is. ;-)

But seriously, templates and reflection are dynamic run-time paradigms and I'd be happy with the compromise that some generic methods would not always be available, and that would require error checking at run-time. I've written a lot of templates, and anything with complex code segments ends up being a nightmare to maintain, and usually gets refactored out into a helper function library. Though I have a need for generic methods in general, I can't imagine them being critical for template logic... or if they do become helpful, maybe that'd end up being in a helper function where the static analysis discussed here would do the right thing on instantiations.

Separately, I did try and start a discussion about using boxed instantiations that are exclusively built for reflection (and perhaps templates by extension). But there were concerns about implementation complexity, and the interest in that solution path unfortunately dried up...

@Merovius
Copy link
Contributor

Merovius commented Sep 4, 2024

@jpap

I don't see why it would be unreasonable to do the same thing for generic methods: just disallow types having generic methods from being exportable across the plugin boundary.

I don't see how that would prevent you from passing a type with a generic method as an any to/from a plugin.

@jpap
Copy link
Contributor

jpap commented Sep 4, 2024

I don't see why it would be unreasonable to do the same thing for generic methods: just disallow types having generic methods from being exportable across the plugin boundary.

I don't see how that would prevent you from passing a type with a generic method as an any to/from a plugin.

We would just need to make sure that, in the host, any plugin types with generic methods do not satisfy any host interfaces with generic methods.

I'm not familiar with how plugin types are loaded off hand, but presumably the types above can be loaded in such a way so that generic methods are elided so that otherwise problematic interface conversions fail in the host, or they are tagged as such. Being able to discern the reason for the failure (e.g. via tagging) is a good idea so that an error string (or panic) is not mysterious.

@qm3ster
Copy link

qm3ster commented Oct 7, 2024

I have read the issue thread and I still don't understand why adding just the ability to implement generic methods on concrete types (allow generic funcs to specify a receiver) with absolutely no changes towards allowing interfaces to ask for such methods to be implemented, even at the syntax level, would block any further decision.

Is it about generic methods possibly fulfilling the requirement for a concrete method in an interface if the concrete method is a valid instantiation of the generic method?

interface I {
  Meth(string) string
}
type S struct {}
func (s *S) Meth[T any](v T) T {
    return v
}
var _ I = &S{} // <- is this the blocking question?

Because if in the future it starts fulfilling the requirement it would break resolution?

I am new to Go and would really appreciate if someone could elucidate.

@Merovius
Copy link
Contributor

Merovius commented Oct 7, 2024

@qm3ster I'm not sure what you are basing the question on. Is there a specific comment that made that claim, that you didn't understand?

The FAQ says about this option:

  1. Define that generic methods cannot be used to satisfy interfaces at all.

Interfaces are an essential part of programming in Go. Disallowing generic methods from satisfying interfaces is unacceptable from a design point of view.

This isn't about compatibility or keeping options open.

@qm3ster
Copy link

qm3ster commented Oct 8, 2024

That line is exactly about keeping options open to me.
The "unacceptable" situation of disallowing generic methods from satisfying interfaces is the status quo, by nature of disallowing generic methods being declared in general.
What I am trying to determine for myself is whether the incremental step is impossible today because:
(1. Allowing declaring generic methods with no interaction with interface methods whatsoever)
-> (2. Will lead to code that declared some breaking in some resolution scenario I can't think of)
-> (3. Meaning they can never start fulfilling interfaces due to go backwards compatibility guarantees)
-> (4. Making the "unacceptable from a design point of view" situation permanent)

@Merovius
Copy link
Contributor

Merovius commented Oct 8, 2024

The "unacceptable" situation of disallowing generic methods from satisfying interfaces is the status quo, by nature of disallowing generic methods being declared in general.

This is too literal an interpretation of the sentence. The solution declared unacceptable is adding generic methods while not having them participate in interface-satisfaction. Note that the premise of the section is that we add generic methods, that there are four options to treat their interaction with interfaces and that next sentence is

None of these choices are good ones, so we chose “none of the above.”

That is the status quo. What you describe is option 4.

@markusha
Copy link

markusha commented Nov 4, 2024

Wanted to suggest a way to "implement" the generic methods for interfaces but it looks like jpap has already suggested something similar. Oh well. Here're my thoughts on the matter.

  1. For an interface's generic methods disallow the use of "any" constraint for type parameters.
type HasIdentity interface {
    Identity[T int64 | float64](T) T // ok
//  Identifoo[T any](T) T // not allowed
}

This should prevent the compiler from using every type there is in the code for generating the required methods as it will be explained later.

  1. For a concrete type's generic methods there should be no restrictions for constraints.
type Foo struct {}
func (foo Foo) Identity[T any](v T) T { return v } // ok
  1. Generic methods that are invoked on variables of concrete types should behave just like regular generic functions.
foo := Foo{}
// the same as calling Identity[T any](foo Foo, v T) T with T = int
// and the same body as the method
v := foo.Identity(123)
  1. Types' generic methods should only be able to satisfy interfaces' generic methods. Types' non-generic methods should only be able to satisfy interfaces' non-generic methods.
  2. When the whole program code is analyzed by the compiler we should know exactly which types satisfy which interfaces and which types satisfy which constraints.

5a. Every generic method in every interface should be turned into several non-generic methods with their type parameters substituted for every possible concrete types according to their constraints. For the HasIdentity example above we'll have:

type HasIdentity interface {
    Identity[int64](int64) int64
    Identity[float64](float64) float64
}

5b. For every type that has generic methods and that satisfies some interface the corresponding constraints from the interfaces' generic method type parameters should be used to generate all possible method variations for this type. So for the Foo struct above we know that according to its generic Identity method's signature it should satisfy the interface HasIdentity so we take the corresponding type parameter constraint from HasIdentity's method (int64 | float64) and use it to generate the needed methods:

type Foo struct{}
func (foo Foo) Identity[int64](v int64) int64 { return v }
func (foo Foo) Identity[float64](v float64) float64 { return v }

So in the end we get the following:

var a any = Foo{}
// the HasIdentity's method table has 2 entries
// which map to the corresponding 2 entries in the Foo's method table
h := a.(HasIdentity)
i := h.Identity(123)
f := h.Identity(1.0)

Can't say much about the plugins for now. Considering all of their restrictions, the need to keep the zoo of toolchains and the fact that the plugin package essentially recommends not to use plugins does anyone use them at all?

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