proposal: spec: generic methods for Go

11 min read Original article ↗

A change of view.

Background

For clarity, in the following we use the term concrete method (or just method when the context is clear) to describe a non-interface method declared like a function but with a receiver; and we use the term interface method to describe the name and signature of a method of an interface.

Per the current spec, a concrete method is a function with a receiver. Syntactically this is not quite true: while functions can be generic, methods cannot. They cannot declare new type parameters themselves; but they can have a receiver of a generic type (and thus have method-local names for the type parameters of the receiver type).

A reason for this discrepancy is that we have historically viewed the primary role of methods as a means to implement an interface: permitting type parameters on concrete methods would imply that we must also permit type parameters on interface methods. Go doesn't support such generic interface methods because we don't know how to implement (calls of) them, or at least we don't know how to implement them efficiently. Specifically, because Go doesn't require a concrete type to declare the interfaces it implements, and instead this is a dynamic property, it cannot be known at compile time which of the infinite possible instantiations of concrete methods will be needed at run time. This shortcoming has long been known and was discussed at length in the original Type Parameters Proposal.

But concrete methods are not just a means for implementing interfaces. A method is a function associated with a type, and accessed through the namespace of that type. Therefore methods are useful for organizing code even if they don't ever implement an interface. Furthermore there is a syntactic benefit: x.a().b().c() may naturally be read left to right, whereas c(b(a(x))) is evaluated inside out. Both these aspects of methods are also well known.

For these reasons, Go users have requested generic methods for a long time and the idea is widely popular. There are at least two proposals filed on the issue tracker:

  • #49085 (Allow type parameters in methods, Oct. 2021, with > 900 positive emojis)
  • #50981 (Add generics to methods, Feb. 2022)

So far we have resisted adding generic concrete methods because it always implied that we also needed generic interface methods. The Go FAQ even states that "we do not anticipate that Go will ever add generic methods". Perhaps a change of view is in order: concrete methods are a language feature that is useful in itself, irrespective of interfaces. If a concrete method is a function with a receiver, a generic concrete method can be a generic function with a receiver. The fact that such methods may not be invoked via an interface is an orthogonal aspect: if an interface syntactically can’t include a method with type parameters, then a generic concrete method naturally plays no role in satisfying that interface because there can’t be an interface method with matching type parameters.

Note that the original Type Parameters Proposal discussed this alternative view as well, as expressed in the following paragraph:

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

We may have reached some clarity on this point: generic concrete methods are useful by themselves, even if they don't implement interface methods.

Proposal

We propose that concrete method declarations should look exactly like function declarations, but with receivers. Specifically, the syntax of a method declaration should accept type parameters as do function declarations. The syntax of function declarations is as follows:

FunctionDecl = "func" FunctionName [ TypeParameters ] Signature [ FunctionBody ] .

The syntax of method declarations should be changed from (old):

MethodDecl = "func" Receiver MethodName Signature [ FunctionBody ] .

to (new):

MethodDecl = "func" Receiver MethodName [ TypeParameters ] Signature [ FunctionBody ] .

Alternatively, to the same effect, the syntax can express directly what we say in prose: a method (declaration) is a function (declaration) with a receiver, and combine the two productions. This removes a syntax production and emphasizes the similarities:

FunctionDecl = "func" [ Receiver ] identifier [ TypeParameters ] Signature [ FunctionBody ] .

The scope of an identifier denoting a type parameter of a generic method begins after the name of the method and ends at the end of the method body. This matches existing rules for type parameters of functions and method receivers. Constraints for method type parameters may refer to type parameters declared with the method receiver because they are in scope.

Calling a generic concrete method works as expected: either, type arguments are provided as needed, or type inference determines any missing type arguments, exactly like for generic function calls.

Method expressions and method values continue to work as expected: if the method is generic, the resulting function is generic and the existing rules for using generic functions apply.

The grammar will need a small adjustment: currently type arguments may only be supplied to operand names which are (possibly package-qualified) identifiers for generic functions and types. Per the syntax for operands (old):

Operand     = Literal | OperandName [ TypeArgs ] | "(" Expression ")" .  
…  
OperandName = identifier | QualifiedIdent .  
TypeArgs    = "[" TypeList [ "," ] "]" .

Methods can be attached to arbitrary user-defined types, and values of such types may be denoted by various expressions, not just qualified identifiers. To permit instantiation of generic methods, type arguments must move from the syntax for operands to the syntax for primary expressions, resulting in the updated productions (new):

Operand     = Literal | OperandName | "(" Expression ")" .

PrimaryExpr = Operand |
              Conversion |
              MethodExpr |
              PrimaryExpr Selector |
              PrimaryExpr Index |
              PrimaryExpr Slice |
              PrimaryExpr TypeAssertion |
              PrimaryExpr Arguments |
              PrimaryExpr TypeArgs .

(Notably, type arguments are already parsed as part of primary expressions in the current implementation. This is necessary because an instantiation such as T[int] and an index expression a[i] are syntactically indistinguishable. In the language specification, the syntactic overlap between these two is maintained solely for the sake of documentation.)

Methods of interfaces are not changed. Importantly, a generic concrete method does not match against an interface method with the same name and signature because the interface method syntactically cannot have matching type parameters.

Generic methods also won't be accessible via reflection (by name or index) for the same reason that uninstantiated generic functions are not accessible: the reflect package doesn't have a mechanism to instantiate a generic value or type (and it is unclear how one would provide such a mechanism).

This is the entirety of the proposal.

Examples

Given a (non-interface) receiver base type S, we may associate a generic method m with it:

type S struct { … }  
func (*S) m[P any](x P) { … }

Given a variable s of type S (or a value of type *S), we can call m as follows:

var s S  
s.m[int](42)    // explicit type argument int  
s.m(x)          // type argument P is inferred from x

A receiver base type may itself be generic:

type G[P any] struct{ … }  
func (*G[P]) m[Q any](x Q) { … }

Calling m requires a variable whose type is an instantiation of G, but there is no other difference in the way m is called.

An interface I with a method m:

type I interface {  
	m(string)  
}

may be implemented by a suitably instantiated generic type G (this is true in current Go):

type G[P any] struct{ … }  
func (G[P]) m(P) { … }

var g G[string]  
var _ I = g		// valid because G[string].m has signature m(string) which matches m(string) of I

But I is not implemented by a type H with a generic method m:

type H struct{ … }  
func (H) m[P any](P) { … }

var h H  
var _ I = h		// invalid because H.m has signature m[P any](P) which doesn't match m(string) of I

Here's another, less abstract example, related to the Go library. A Reader type with a generic Read method:

type Reader struct{ … }  
func (*Reader) Read[E any]([]E) (int, error) { … }

does not implement io.Reader, even though it might if there were some way to instantiate the method as (*Reader).Read[byte] (which there is not, and we are not proposing it).

Method expressions (and method values) work as expected. Given the Reader type from above, the method expression Reader.Read produces a generic function with type parameters and signature:

[E any](*Reader, []E) (int, error)

If the type itself is generic, it must be instantiated (per existing Go rules for generic types) before it can be used in a method expression. For instance, given a generic List type with a generic formatting method format:

type List[E any] struct { … }  
func (*List[E]) format[F any](E, F) string { … }

the method expression List[string].format will result in a function with type parameters and signature

[F any](string, F) string

In particular, List.format is not a valid method expression (List is not instantiated), nor does it produce the generic function signature

[E any, F any](E, F) string	// not the signature of List.format

Implementation

Specification

The change to the language specification is small and fully backward-compatible. The syntax changes have been outlined above. The corresponding prose changes are similarly straightforward.

Whenever there is a language change, updating the specification is a tiny part of the work needed to get documentation updated in general. This includes existing documents traditionally maintained by the Go team (which we plan to keep fresh as much as possible), to 3P documentation beyond our control (external blog post, articles, books, etc.). These will become partly out of date and may or may not be updated, but that is not avoidable if we want to make changes to the language.

Compiler

The parser already accepts type parameters for methods for robustness but complains with an error. Removing that error is a trivial change. The parser also already handles index expressions and instantiations together. If any changes are needed at all, they will be minimal. The type checker will need various adjustments, most likely involving the removal of restrictions, the details of which will only become apparent by doing the actual work.

The compiler back-end changes are likely more significant. That said, method calls via non-interface receivers can be resolved statically (at compile time): because the type of the receiver is not an interface, its type is statically known, and thus the called method is also statically known. Conceptually, such method calls can be rewritten into function calls. If the method itself is generic, the method call can be rewritten into a call of a corresponding generic function.

For instance, given the type G, method m, and instance x of G:

type G[P any] struct{ … }  
func (G[P]) m[Q any](x Q) { … }

var g G[string]

the method call

can be translated into a function call f(s) of the generic function

func f[Q any](g G[string], x Q) {  
	G[string].m[Q](g, x)		// using a call of method expression G[string].m  
}

or

func f[Q any](g G[string], x Q) {  
	g.m[Q](x)			// using a call of method value g.m[Q]  
}

This is not to say that an implementation will be trivial. But the principal translation mechanism is understood.

The import/export data format will need to be updated to allow for type parameters on methods. This is likely the most disruptive change because of the many different exporters and importers that exist, some of which are used for language tools, reside in different repositories, and all must remain in sync with the compiler's export format. Carefully staged updates are needed in order to not break existing infrastructure. We have done import/export data format changes in the past, and while tedious, they can be done.

Libraries and tools

No change of the existing libraries is required, but future (versions of these) libraries may want to use generic methods.

The impact on tools may be significant as they will need to handle the new language feature. The go/types API's Signature type already provides accessors for receiver and ordinary type parameters, so it's possible that no API changes are needed there, but go/types clients cannot rely on the (current) fact that only one of type parameter lists is present. Judging from past experience, it may take one or two release cycles for all tools to catch up. It is difficult to judge the amount work involved without actually doing the work.

Other considerations

This proposal adds a new feature to the language by removing a restriction; thus it is fully backward-compatible with existing Go. Importantly, it also doesn't preclude the implementation of generic interface methods at some point, should we find an acceptable implementation solution that doesn't impose a cost when the feature is not used.

Generic functions and types make it possible to write more complex code and adding generic methods will further increase that capability. But used appropriately, generic methods can simplify and generalize situations where currently more complex work-arounds are needed (see #49085 for an example).