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: method signature overloading #22051

Closed
cznic opened this Issue Sep 26, 2017 · 23 comments

Comments

Projects
None yet
8 participants
@cznic
Contributor

cznic commented Sep 26, 2017

Just a random idea, probably not even original. Not being able to evaluate if it's of any value, it's presented here in the form of a Go2 proposal to hopefully get feedback from the language designers/experts.

Method declarations

Currently a method receiver is Receiver = Parameters ., further restricted by

That parameter section must declare a single non-variadic parameter, the receiver. Its type must be of the form T or *T (possibly using parentheses) where T is a type name. The type denoted by T is called the receiver base type; it must not be a pointer or interface type and it must be defined in the same package as the method. The method is said to be bound to the base type and the method name is visible only within selectors for type T or *T.
...
For a base type, the non-blank names of methods bound to it must be unique. If the base type is a struct type, the non-blank method and field names must be distinct.
...
The type of a method is the type of a function with the receiver as first argument.

Proposed is to change the above to

That parameter section must declare one or more non-variadic parameters, the receivers. Their types must be of the form T or *T (possibly using parentheses) where T is a type name. The type denoted by T is called the receiver base type; it must not be a pointer or interface type. If there's only a single receiver, its type must be defined in the same package as the method. The method is said to be bound to the sequence of the base types of the receivers and the method name is visible only within selectors for a sequence of types where each of the types must be of the form T or *T.
...
For the sequence of base types, the non-blank names of methods bound to it must be unique within a package. If any of the base types is a struct type, the non-blank method and field names must be distinct and field names of all struct typed receivers must be unique with respect to the sequence of receivers.
...
The type of a method is the type of a function with the receivers as first arguments.

and add

Methods with multiple receivers may not be exported.

Example declarations, all legal in any combination of their presence below.

func (w W) foo() {}
func (x X) foo() {}
func (x X, y Y) foo() {}
func (x X, z Z) foo() {}
func (w W, x X) foo() {}

Alternatively, field names not conflicting with a method name are permitted to be non unique, but then the field name cannot be used directly in the selector. Possible disambiguation via the type name containing the desired field. No syntax for this is proposed though. (TBD)

Alternatively, the last receiver of a methods with multiple receivers may be permitted to be variadic with the same semantics as of the last variadic parameter in function signature. (TBD)

Selectors

Amend the current specification such that it applies to methods with single receivers except that

The selector of a method with multiple receivers must be parenthesized.

Like in (x, y).foo().

Note that the selector of a method with a single receiver may be parenthesized, both x.foo() and (x).foo() are valid already.

Method sets

A sequence of types is not a type. Methods with multiple receivers do not constitute a method set.

Method expressions

A method with multiple receivers cannot be used as a method expression. (TBD)

Method values

A method with multiple receivers cannot be used as a method value. (TBD)

Calls

The current mechanism of implicitly converting receiver x to &x where appropriate is extended to all receivers of a method.

Backward compatibility

Yes, full.

Previous proposal

#21659.

@gopherbot gopherbot added this to the Proposal milestone Sep 26, 2017

@gopherbot gopherbot added the Proposal label Sep 26, 2017

@OneOfOne

This comment has been minimized.

Contributor

OneOfOne commented Sep 26, 2017

Honestly the original proposal was much better, this one is just over-complicated and adds horrible syntax.

@ianlancetaylor ianlancetaylor changed the title from proposal: method signature overloading to proposal: Go 2: method signature overloading Sep 27, 2017

@ianlancetaylor

This comment has been minimized.

Contributor

ianlancetaylor commented Sep 27, 2017

Methods and interfaces are closely tied. I don't see the point of providing a new way to define methods that does not affect interfaces. Or, to put it a different way, these really don't seem like methods, so why make them look like methods?

@cznic

This comment has been minimized.

Contributor

cznic commented Sep 27, 2017

@OneOfOne

Honestly the original proposal was much better, this one is just over-complicated and adds horrible syntax.

The method declaration syntax is not changed by the proposal. Only the non-syntax restriction in the specs text, single, becomes one or more. Multiple return variable declaration probably looks alien at first as well when comming from languages having only a single return value.

The method call syntax is currently Expression "." Identifier ... and the proposal changes that to ( Expression | "(" ExpresionList ")" ) "." Identifier .... Expression lists are used all over the code in function calls already, I don't think they're 'horrible syntax'.

@cznic

This comment has been minimized.

Contributor

cznic commented Sep 27, 2017

@ianlancetaylor

Methods and interfaces are closely tied. I don't see the point of providing a new way to define methods that does not affect interfaces.

Well, there are languages with methods attached to types but having no concept of interfaces. Affecting interfaces is avoided in the proposal because it seems to me more complicated to design and specify. It'd be a waste of effort without first having understanding and/or consensus about the underlying issue - multiple method receivers. For the same reason method expressions and method values are forbidden for now, even though in those cases it's simpler to specify. All of that can be a refinement of this proposal or a standalone follow-up proposal. But I think discussing the first principles should come first.

Or, to put it a different way, these really don't seem like methods, so why make them look like methods?

Currently a method declaration is

"func" Parameters identifier Parameters ( Result | Parameters ) Block .

The proposal does not change that so I'm not sure why lifting a restriction, loosely said, of 'sequence of one', to just 'sequence' can make it not look like a method.

I'm sometimes writing code like this

func (t *T) fooAA(a, b A) { ... } // edit: typo, was 'b AA'
func (t *T) fooAB(a A , b B) { ... } // edit: typo, was aA
func (t *T) fooBA(a B, b A) { ... }
func (t *T) fooBB(a, b B) { ... } // edit: typo, was 'b BB'

func (t *T) foo(a, b I) {
	switch x := a.(type) {
	case A:
		switch y := b.(type) {
		case A:
			t.fooAA(x, y)
		case B:
			t.fooAB(x, y)
		}
	case B:
		switch y := b.(type) {
		case A:
			t.fooBA(x, y)
		case B:
			t.fooBB(x, y)
		}
	}
}

especially when the bodies of fooAA, fooAB, ... are not just a few lines. Under the proposal the equivalent code is just

func (t *T, a, b A) foo() { ... }
func (t *T, a A, b B) foo() { ... }
func (t *T, a B, b A) foo() { ... }
func (t *T, a, b B) foo() { ... }

Another example where I'd probably consider using the feature is in some evaluator-like code (poor example but you get the idea)

func (a, b int) add() interface{} { return a+b }
func (a int, b float64) add() interface{} { return float64(a)+b }
func (a float64, b int) add() interface{} { return (b, a).add() } // edit: typo, was `a float64, a int`.
func (a, b float64) add() interface{} { return a+b }

and then, schematically

var a, b, c, d interface{} = 1, 2.3, 4.5, 6

fmt.Println((a, b).add())

// or even

fmt.Println(((a, b).add(), (c, d).add()).add())
@SCKelemen

This comment has been minimized.

Contributor

SCKelemen commented Sep 28, 2017

If we are considering function overloading, are we also going to consider pattern matching? In my opinion, they are related: The same concept implemented differently.

@bcmills

This comment has been minimized.

Member

bcmills commented Sep 28, 2017

I'm sometimes writing code like this

Can you give some more-concrete examples? I'm having trouble understanding what problem you're trying to solve with this proposal.

@cznic

This comment has been minimized.

Contributor

cznic commented Sep 28, 2017

@bcmills

An example that may possibly avoid manually written, big, nested type switches - a run-time evaluator in ql.

A perhaps less relevant, non-evaluator-like example in a persistence-ready B-tree: db. Note the (*BTree).cat vs .catX, .clrD vs .clrX, .copy vs .copyX and so on. I tried with those methods attached to {btDPage,btXPage} but benchmarks seem to indicate having it all as just methods of *BTree is better. (The intended optimizations are not yet finished, so I cannot yet prove that for this example. The code was not planned to be publisehd yet, but it was actually this very code that triggered the idea so I think it's possibly a better illustration of the underlying thoughts.)

@leaxoy

This comment has been minimized.

leaxoy commented Oct 3, 2017

but how to distinct func a(b interface{}) and func a(b string)

@cznic

This comment has been minimized.

Contributor

cznic commented Oct 3, 2017

I don't understand the question within the context of this proposal, which is about methods. Methods always have a concrete receiver type, or a sequence of types under this proposal. Can you please elaborate?

@bcmills

This comment has been minimized.

Member

bcmills commented Oct 3, 2017

@cznic You could address the nesting problem by hoisting the switch from types to values of type reflect.Type, but then you'd need extra type-assertions to recover the type information from the switch.


Still, if nesting for type-switches is the only use-case this addresses, I would argue that it would be clearer to add multiple-assignment type switches than method overloads:

		a, b := o.get2(execCtx, ctx)
		if a == nil || b == nil {
			return
		}

		switch x, y := a.(type), b.(type) {
		case (idealComplex, idealComplex):
			return idealComplex(complex64(x) + complex64(y)), nil
		case (idealFloat, idealFloat):
			return idealFloat(float64(x) + float64(y)), nil
		default:
			return invOp2(x, y, op)
		}

As @SCKelemen notes, that would move type-switches a bit closer to general pattern-matching, although I would argue that type-switches would still remain much simpler. For me, the defining characteristic of pattern-matching is the unpacking of value-constructors, but since Go (IMO wisely) does not have tuple types, matching multiple assignments still would not “unpack” values.

(If we were to allow value-switches on composite literals, that would be an entirely different matter, but that seems overkill for @cznic's use-case.)

@cznic

This comment has been minimized.

Contributor

cznic commented Oct 4, 2017

@bcmills

You could address the nesting problem by hoisting the switch from types to values of type reflect.Type, but then you'd need extra type-assertions to recover the type information from the switch.

Well, I consider using reflection in this case as the last resort only. Even thoug in many other cases its use makes perfect sense, no doubt.

Still, if nesting for type-switches is the only use-case this addresses, I would argue that it would be clearer to add multiple-assignment type switches than method overloads:

That idea removes most, if not all of the pain of the nested type switches, very interesting.

Let me use a simplified version of your code and present three snippets next to each other. (The simplification is only removing type conversions that are not important here.)

Using valid Go:

func (o *binaryOperation) eval(ctx *ctx) (interface{}, error) {
	a, b := o.get2(execCtx, ctx)	// returns (interface{}, interface{})
	if a == nil || b == nil {
		return
	}

	switch o.op {
	case '+':
		switch x := a.(type) {
		case complex64:
			switch y := b.(type) {
			case complex64:
				return x + y, nil
			}
		case float64:
			switch y := b.(type) {
			case float64:
				return x + y, nil
			}
		}
	}
        return invOp2(a, b)
}

Using multiple-assignment type switches:

func (o *binaryOperation) eval(ctx *ctx) (interface{}, error) {
	a, b := o.get2(execCtx, ctx)	// returns (interface{}, interface{})
	if a == nil || b == nil {
		return
	}

	switch o.op {
	case '+':
		switch x, y := a.(type), b.(type) {
		case (complex64, complex64):
			return x + y, nil
		case (float64, float64):
			return x + y, nil
		}
	}
        return invOp2(a, b)
}

Using this proposal:

type value interface {
	add(value) value
}

func (x, y complex64) add() value { return x + y }
func (x, y float64) add() value   { return x + y }

func (o *binaryOperation) eval(ctx *ctx) (value, error) {
	a, b := o.get2(execCtx, ctx)	// returns (value, value)
	if a == nil || b == nil {
		return
	}

	switch o.op {
	case '+':
		return (a, b).add()
	}
        return invOp2(a, b)
}

The last snippet actually uses one of the preliminary ideas of how method sets interact with multiple-receiver methods, which is not part of the proposal (yet). I hope the mechanism is obvious, but I'm far from sure if that would be the right/correct/possible/acceptable mechanism. I haven't yet thought about it enough to know better.

Note that there's a catch: return (a, b).add() would have to produce a run-time panic if the type sequence for (a, b) would be, for example, (complex64, float64), so it is not completely equal to the previous two snippets. (As a side note, something like if _, _, ok = (a, b).value; !ok { return invOp2(a, b) } put right after the get2 call comes to mind, to make the code work the same.)

The run-time panic can be guaranteed/statically proved to not occur when two more methods are added:

func (x complex64, y float64) add() value { return x + complex(y, 0) }
func (x float64, y complex64) add() value { return (y, x).add() }
@bcmills

This comment has been minimized.

Member

bcmills commented Oct 4, 2017

The last snippet actually uses one of the preliminary ideas of how method sets interact with multiple-receiver methods, which is not part of the proposal (yet). I hope the mechanism is obvious, but I'm far from sure if that would be the right/correct/possible/acceptable mechanism.

I think that goes directly to @ianlancetaylor's earlier point about needing to consider the interaction between this proposal and interfaces. If a tuple of values can implement a single interface, the mapping between values and interfaces is no longer 1:1. I expect that would get too complicated very quickly.

@bcmills

This comment has been minimized.

Member

bcmills commented Oct 4, 2017

Thinking more about the multiple-assignment switch alternative, you could reduce nesting even further if a multi-switch allowed the mixing of types and values:

func (o *binaryOperation) eval(ctx *ctx) (interface{}, error) {
	a, b := o.get2(execCtx, ctx)	// returns (interface{}, interface{})
	if a == nil || b == nil {
		return
	}

	switch _, x, y := o.op, x.(type), y.(type) {
	case ('+', complex64, complex64):
		return x + y, nil
	case ('+', float64, float64):
		return x + y, nil
	}
        return invOp2(a, b)
}

The downside of that would be that it's more complicated to specify, because you need some means of describing which terms are types vs. ordinary values.

@cznic

This comment has been minimized.

Contributor

cznic commented Oct 4, 2017

@bcmills

If a tuple of values can implement a single interface, the mapping between values and interfaces is no longer 1:1. I expect that would get too complicated very quickly.

AFAICT, it stays the same 1:1. At least that's the intention. I am quite probably missing/overlooking something. Will try to dig deeper.

@cznic

This comment has been minimized.

Contributor

cznic commented Oct 4, 2017

@bcmills

Thinking aloud, I'll try to clarify a bit more my understanding of the 1:1 relation. For every method, single receiver or multiple receiver:

  • The method's type is the method signature with all receiver types inserted at the beginning of the signature.
func (T) foo(X, Y) Z        // Type: func(T, X, Y) Z
func (T, U) bar(X, Y) Z     // Type: func(T, U, X, Y) Z
  • A method matches an interface method specification iff both the method's name and signature of the method's type with the first argument, which is the first receiver, removed.

Given

interface fooer {
        foo(X, Y) Z
}

all of func (A) foo(X, Y) Z, func (A, X) foo(Y) Z, func (B) foo(X, Y) Z, func(B, X) foo(Y) Z etc. do match the method specifier foo(X, Y) Z.

If X or Y (and so on), but not Z in the inteface method specification is an interface type that corresponds to a receiver X' or Y' (and so on) of a multiple-receiver method, X' must implement X and Y' must implement Y, etc. In an earlier example, this is the case of the value interface only when all four of the shown add methods are declared.

If a concrete type has all methods that match all method specifiers in the method set of an interface, the type implements that interface.

Any method can match only a single method specifier within a particular interface. Is that what you mean by the 1:1 relation?

Let me note that a value of type T can already implement any number of interfaces simultaneously, so I suppose that's not what you meant by '1:1 mapping between values and interfaces'.

I hope that the above is correct, but I'm honestly far from being sure about it.

@bcmills

This comment has been minimized.

Member

bcmills commented Oct 4, 2017

Let me note that a value of type T can already implement any number of interfaces simultaneously, so I suppose that's not what you meant by '1:1 mapping between values and interfaces'.

Right, I was thinking more about function arguments.

Suppose that I have a declaration like

interface fooer {
	foo(X, Y) Z
}

var F func(fooer)

How do I call that on an (X, Y) pair that implements fooer, bearing in mind that Go does not have tuple types?

@cznic

This comment has been minimized.

Contributor

cznic commented Oct 4, 2017

@bcmills

How do I call that on an (X, Y) pair that implements fooer, bearing in mind that Go does not have tuple types?

I'm not sure I understand. Are you talking about some method func (X, Y) foo(X, Y) Z? Because the first parameter of the interface method specification cannot become the first actual receiver of a method. The reason is that there's always at least one type "dropped" from the method specification signature compared to the signature of the type of the actual method implementation, single receiver method or not.

Do you mean instead a type sequence like (W, X) with a method declared like func (W, X) foo (Y) Z that matches the fooer's interface method specification foo(X, Y) Z?

Please expand, thank you.

@as

This comment has been minimized.

Contributor

as commented Oct 4, 2017

Is it correct to assume that this proposal effectively allows one to move the arguments out of the parameter list into an implicit list created by the method receiver?

func Cat(a, b, c string) string
func (a string) Cat(b, c string) string
func (a, b string) Cat(c string) string
func (a, b, c string) Cat() string

The top advantages of methods I can think of are:

  • Implicit pointer to function-associated data on every function call
  • Interface implementation

Regarding the first advantage, is this even possible without a construction that captures the two pointers in an aggregate list for repeated method calls?

// let a and b equal two instances of some structs of arbitrary type
ab := (a,b)
ab.Call()
ab.Call()
ab.Call() 
@cznic

This comment has been minimized.

Contributor

cznic commented Oct 4, 2017

Is it correct to assume that this proposal effectively allows one to move the arguments out of the parameter list into an implicit list created by the method receiver?

All of the Cat declarations in the example shown seem valid under this proposal. However, their purpose don't strike me. Usage example would be nice.

Anyway, except for the first Cat declaration, which is not a method, all of the others match the interface method specification Cat(string, string) string, while having from 1 to 3 string receivers. (Considering matching rules as discussed here, which are not formally part of the proposal.)

Regarding the first advantage, is this even possible without a construction that captures the two pointers in an aggregate list for repeated method calls?

Multiple-receiver method expressions and multiple-receiver method values are still an open question. So far the proposal forbids them, simply because I've not yet understood the solution(s) to that question enough - or at all.

@cznic

This comment has been minimized.

Contributor

cznic commented Oct 16, 2017

@bcmills I would like to answer your question, but I'm waiting for the clarification. (Requested here).

@bcmills

This comment has been minimized.

Member

bcmills commented Oct 16, 2017

I think I meant to use a simpler interface.

Suppose I have this code:

type Fooer interface {
	Foo() Z
}

func (X, Y) Foo() Z {…}

func DoFoo(f Fooer) {
	f.Foo()
}

In that case, how do I call DoFoo with an X, Y pair?

@cznic

This comment has been minimized.

Contributor

cznic commented Oct 16, 2017

Thanks for the clarification, now I understand the question.

The answer is: you cannot because in the example there's no defintion of a method matching the interface method specification Foo() Z, because it can be matched only by a method declaration having a single receiver and no arguments. No two receiver method declaration can match a signature with no argument - there are no 'slots' for the non-first receivers in the method specification. So to use a pair, we need a method specification of at least one argument:

type Fooer interface{
        Foo(Y) Z
}

func (X) Foo(Y) Z  { ... (A) } // Matches Foo(Y) Z
func (X, Y) Foo(Z) { ... (B) } // Matches Foo(Y) Z

func bar(x X, y Y) {
        x.Foo(y)     // (A)
        (x, y).Foo() // (B)
        DoFoo(x)
}

func DoFoo(f Fooer) {
        var x X
        var y Y
        f.Foo(y)     // (A)
        x.Foo(y)     // (B)
        (x, y).Foo() // (B)
}

All of the above would be legal under this proposal + the matching rules as discussed. So far the only proposed way to call a method of a type sequence is the one seen above in (x, y).Foo().


Stepping out of the proposal:

  • Method expressions/values for methods with multiple receivers
  • Tuples, as in Expression = current specs | '(' ExpressionList ')' .

I guess designing the above items will enable to call DoFoo with a pair X, Y. Only the last one seems somehow obvious: DoFoo((x, y)), IINM. Also then probably something like f := (x, y); DoFoo(f) should work too.

But those areas were left out intentionally and what's under the <HR> is not part of this proposal + matching rules discussed later.

@ianlancetaylor

This comment has been minimized.

Contributor

ianlancetaylor commented Mar 21, 2018

This is a significant increase in language complexity for a benefit that is much less clear, and is largely unstated above. There are languages that have multiple method receivers, but Go is not one of them. The restrictions on such methods make the language less regular.

Declining.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment