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: Go 2: generic interface method receivers #54347

Closed
willfaught opened this issue Aug 9, 2022 · 7 comments
Closed

proposal: Go 2: generic interface method receivers #54347

willfaught opened this issue Aug 9, 2022 · 7 comments
Labels
FrozenDueToAge LanguageChange Proposal v2 A language change or incompatible library change
Milestone

Comments

@willfaught
Copy link
Contributor

Author background

  • Would you consider yourself a novice, intermediate, or experienced Go programmer?
    Experienced

  • What other languages do you have experience with?
    JavaScript, HTML, CSS, Java, C, Haskell, Scheme, C#, Python, Ruby, PHP, Node

Related proposals

  • Has this idea, or one like it, been proposed before?
    I don't think so

  • If so, how does this proposal differ?
    N/A

  • Does this affect error handling?
    No

  • If so, how does this differ from previous error handling proposals?
    N/A

  • Is this about generics?
    Yes

  • If so, how does this relate to the accepted design and other generics proposals?
    It enables explicit generic receivers in interface methods. I don't believe the accepted generics design does anything similar.

Proposal

  • What is the proposed change?

Generic interface method receivers

Non-generic operations, like addition (+) for int, have concrete types for operands and results. Constraints can abstract out the concrete types for generic use: interface { ~int | ~uint }.

Non-generic functions, like func(int, int) int, have concrete types for parameters and results. Type variables can abstract out the concrete types for generic use: func[T any](T, T) T.

Non-generic method implementations, like func (Foo) Bar(Foo) Foo, have concrete types for the receiver, parameters, and results. Method implementations are abstracted by interface methods, but interface methods cannot use type variables to abstract concrete types in parameters or results, and match those to the receiver type. This means that interface methods are strictly less expressive of an abstraction than constraints for operations or type variables for functions. I propose we enable interface methods to specify generic receiver types to establish expressive parity between operations, functions, and interface methods.

Specifying interface method receivers:

var _ interface { M() } // Matches any receiver type
var _ interface { R M[R any]() } // Same as above
var _ interface { (R) M[R any]() } // Same as above
var _ interface { (r R) M[R any]() } // Same as above

var _ interface { R M[R C](R) }
var _ interface { R M[R C]() R }
var _ interface { R M[R C](R) R }

var _ interface { NonTypeVariableType M() } // Invalid because it's pointless

Now we can do:

type Number interface {
    N Add[N any](N) N
}

type MyInt struct {
    n int
}

func (x MyInt) Add(y MyInt) MyInt {
    return MyInt{n: x.n + y.n}
}

type MyFloat struct {
    n float64
}

func (x MyFloat) Add(y MyFloat) MyFloat {
    return MyFloat{n: x.n + y.n}
}

func Add[N Number](a, b N) N {
    return a.Add(b)
}

var _ MyInt = Add(MyInt{1}, MyInt{2}) // OK
var _ MyFloat = Add(MyFloat{1}, MyFloat{2}) // OK

var _ = Add(MyInt{1}, MyFloat{2}) // Compiler error: types don't match

This opens the door for a lot of things (like interface { T Max[T any](T) T), but I'll save those for another time.

  • Who does this proposal help, and why?

Go users who want their methods to be able to be abstracted with generics, e.g.

type A ...
func (A) Foo(A) A ...

type B ...
func (B) Foo(B) B ...

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

var a, b Fooer = A{}, B{}
var _, _ = a.Foo(A{}), b.Foo(B{})

Built-in types and user-declared types should have parity in terms of features and expressiveness. It should be possible to describe built-in types and the types of their operations in terms of user-declared types and methods. Enabling this makes the language consistent and facilitates comprehension and learning the language. Since the equivalent of operations for user-declared types is methods, method receiver types should be able to be generic as well when abstracting over method types with interface methods. If int can have int + int = int, and be abstracted with constraints.Integer, then MyInt should be able to have func (MyInt) Add(MyInt) MyInt, and be abstracted with type Number interface { N Add[N any](N) N }.

  • Please describe as precisely as possible the change to the language.
  1. Enable the syntax described in the first code block.
  2. Extend the type checker to account for the extra parameter (receiver) type.
  3. Instantiate the receiver type with the value type when matching interface methods against the value's methods.
  • What would change in the language spec?
  1. Addition of the new grammar.
  2. Specification of including the receiver type in the method matching semantics for interface assignability.
  • Please also describe the change informally, as in a class teaching Go.

You can now have interfaces that specify a generic receiver type, and use that receiver type variable in parameter and result types.

  • Is this change backward compatible?

Yes.

  • Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit.
    Show example code before and after the change.
  • Before
type Number interface {
	Add(any) any
}

type MyInt int

func (MyInt) Add(y any) any { return "surprise" }

func Add[N Number](x, y N) any {
	return x.Add(y)
}
  • After
type Number interface {
	N Add[N any](N) N
}

type MyInt int

func (MyInt) Add(y MyInt) MyInt { return "surprise" } // Compiler error

func (x MyInt) Add(y MyInt) MyInt { return MyInt(x + y) } // OK

func Add[N Number](x, y N) N {
	return x.Add(y)
}
  • Orthogonality: how does this change interact or overlap with existing features?

It expands the expressiveness of interfaces to match that of functions and operations.

  • Is the goal of this change a performance improvement?

No.

  • If so, what quantifiable improvement should we expect?

N/A.

  • How would we measure it?

N/A.

Costs

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

Easier, because operations could then be conceived of (whether true in actuality or not) as just generic methods.

  • What is the cost of this proposal? (Every language change has a cost).

Spec update. Implementation. go/ast update. Doc update.

  • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?

Assuming all those use go/ast and go/types, then none after go/ast and go/types are updated. Perhaps if someone wrote their own type checker, then it would fail for the new cases enabled by this change.

  • What is the compile time cost?

Parse time would be minimal.

Type checking would be a little more involved, since it permits new constraints and matches that aren't already permitted. I imagine it's minimal.

  • What is the run time cost?

Incorporating the runtime receiver type when checking whether a value implements an interface. Probably pretty cheap.

  • Can you describe a possible implementation?

I think I've already sketched it as best as I can without being familiar with the implementation specifics of these things.

  • Do you have a prototype? (This is not required.)

No.

@gopherbot gopherbot added this to the Proposal milestone Aug 9, 2022
@seankhliao
Copy link
Member

I think what you actually want is #43390

@seankhliao seankhliao added LanguageChange v2 A language change or incompatible library change labels Aug 9, 2022
@willfaught
Copy link
Contributor Author

That proposal is different. It proposes enabling generics on method implementations, but not on interface methods. I actually do think interface methods should be able to be generic, separate from parameterized receiver types, but that seems like it might be a separate issue, and I tried to be careful to leave that out of this proposal.

@atdiar
Copy link

atdiar commented Aug 9, 2022

There is also conversation on #49085

@willfaught
Copy link
Contributor Author

That proposal seems equivalent to #43390. It deals with parameterized methods, and the author isn't asking for non-generic methods to match generic interface methods. Specifically, the author isn't asking for func (si *stream[IN]) Map[OUT any](f func(IN) OUT) stream[OUT] to be able to match something like interface { T[A] Map[B any](func(A) B) T[B] }.

I don't think this proposal requires method values to be instantiated in the sense of stream.Map[T], although perhaps that depends on how interface assignability does/would work.

@Merovius
Copy link
Contributor

Merovius commented Aug 31, 2022

Why not use generic interfaces? e.g. you can write

type Number[N any] interface {
    Add(N) N
}

func Add[N Number[N]](a, b N) N {
    return a.Add(b)
}

This seems to fill the same niche as what you propose, without adding any extra syntax. AFAICT all your examples can be rewritten in that way by moving the receiver type to a type parameter on the interface.

It's also slightly more powerful, because it means you can have heterogenous types for the arguments. For example

package main

import (
	"fmt"
	"time"
)

// Number is something we can add an A(rgument) to, to get a R(esult).
type Number[A, R any] interface {
	Add(A) R
}

func Add[R any, N Number[A, R], A any](a N, b A) R {
	// Order of type parameters is to make it possible to partially instantiate, until we get better inference.
	return a.Add(b)
}

func main() {
	fmt.Println(Add[time.Time](time.Now(), time.Hour))
}

@Merovius
Copy link
Contributor

Merovius commented Aug 31, 2022

Some more thoughts: Would this be allowed and if so, what would it mean?

type X interface {
    R M1[R ~int](v R)
    R M2[R ~string](v R)
}

I would assume that any type implementing this would have to be required to implement the constraints of the receiver type of all methods in the interface. In that case, there would be no practical use for doing this kind of thing, as this interface can never be implemented. So maybe it should be disallowed - but I'm not sure we can practically do that, as it would require calculating whether the intersection of the constraints type sets are empty.

So, perhaps it should just be disallowed to use different constraints on those receiver types - if one method specifies a receiver with a constraint, then all do and the constraints must be the same. The receiver is already guaranteed to be the same, of course. But then, that's syntactically redundant.

Then there is the question of whether such interfaces should be allowed to be used as values - and if so, what that would mean. e.g.

type Number interface {
    N Add[N any](N) N
}

type MyInt int

func (n MyInt) Add(m MyInt) MyInt { return n+m }

type MyString string

func (n MyString) Add(m MyString) MyString { return n+m }

func main() {
    // is this allowed?
    var n Number = MyInt(0)
    fmt.Println(n.Add(MyInt(1)))
    // what about this?
    n = MyString("Hello ")
    fmt.Println(n.Add(MyString("World"))
    // and about this?
    if rand.Intn(2) == 1 {
        n = MyInt(42)
    }
    fmt.Println(n.Add("World"))
}

I guess we can't allow to use these as variables. But that seems kind of restrictive.

Generic interfaces answer these questions:

  • The first question is answered as I line out there. By passing the receiver type as a type argument, we ensure that there is only one set of constraints and all methods use the same set of "receiver parameters". The redundancy problem then goes away, as we only specify the parameter and the constraint once, as the type parameter to the interface.
  • The second question gets answered by the fact that you can't use an uninstantiated generic type. type Number interface { N Add[N any](N) N } is effectively equivalent to the uninstantiated type Number[N any] interface { Add(N) N }, so it can't be used as a type. But you can explicitly instantiate it, say by writing var n Number[int], which then answers the question of what you can and can not call on it. This instantiation operation has no correspondence in your proposal.

So, I really think generic interfaces are the better solution for this problem.

@willfaught
Copy link
Contributor Author

Why not use generic interfaces?

That's clever. I didn't know you could parameterize a constraint with a type parameter.

I would assume that any type implementing this would have to be required to implement the constraints of the receiver type of all methods in the interface.

My old generics design didn't require explicit type parameter declarations, so no constraints were permitted:

var _ interface { $A M($A) }
var _ interface { $A M() $A }
var _ interface { $A M($A) $A }

With the current generics design, where type parameters and their constraints must be declared, the constraint would have to be required to be any. I don't see value in allowing any other kind of constraint for an interface receiver type variable.

Then there is the question of whether such interfaces should be allowed to be used as values - and if so, what that would mean.

Right, methods with receiver type variables can't be used on values that aren't arguments. Also from my generics design:

var myInt = MyInt{1} // OK
var myFloat = MyFloat{2} // OK

var num1 Number = myInt // Makes sense
var num2 Number = myFloat // Makes sense

var num3 /* ??? */ = num1.Add(num2) // Invalid because the return type is unknown, because Number hides MyInt
// An interface method with a receiver type variable in a parameter can only be used when its interface is used as a constraint

You could permit use of methods that don't have a receiver type variable, though:

type Number interface {
    N Add[N any](N) N
    String() string
}

var myInt = MyInt{1} // OK
var myFloat = MyFloat{2} // OK

var num1 Number = myInt // Makes sense
var num2 Number = myFloat // Makes sense

fmt.Println(num1.String()) // Fine
fmt.Println(num1.Add(num2)) // Compile error

Understanding when you can use which methods is a little complicated, and perhaps not worth the trouble.

But that seems kind of restrictive.

It's no more restrictive than not allowing non-basic interface types for values. It's the same issue.


I think I agree with you. Number[N] does accomplish the same thing, and it has the added benefit of being usable on values as well. It feels a little "loose" to have to parameterize a type parameter's constraint with itself, which I didn't know was possible, but apparently that's what the designers intended to enable, so that's the idiomatic way to do it in Go. This design is more like Haskell's type system, where type parameters and constraints don't "touch" each other.

Thanks for your thoughtful response.

@golang golang locked and limited conversation to collaborators Sep 1, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
FrozenDueToAge LanguageChange Proposal v2 A language change or incompatible library change
Projects
None yet
Development

No branches or pull requests

5 participants