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: Uniform Function Call Syntax #56242

Closed
earlye opened this issue Oct 15, 2022 · 13 comments
Closed

proposal: Go 2: Uniform Function Call Syntax #56242

earlye opened this issue Oct 15, 2022 · 13 comments
Labels
FrozenDueToAge LanguageChange Suggested changes to the Go language Proposal Proposal-FinalCommentPeriod v2 An incompatible library change
Milestone

Comments

@earlye
Copy link

earlye commented Oct 15, 2022

Author background

  • Experienced programmer, intermediate Go programmer
  • 30+ years in a variety of languages including C, C++, Java, Javascript/Node, PERL, Delphi, ...

Related proposals

  • Has this idea, or one like it, been proposed before?
    • I scanned the list of proposals but did not see anything related.
  • Does this affect error handling?
    • I don't see how it would
  • Is this about generics?
    • Yes and No. This is a more broad proposal than just "make generics do a thing," but it would help with an annoying consequence of the current generic implementation. Specifically, it provides an alternative that would allow a syntax that feels very much like a world where methods can have type specifiers without actually allowing that.

Proposal

  • What is the proposed change?
    Use Uniform Function Call Syntax to allow the first parameter to any function, suffixed by a '.', to be placed to the left of the qualified function name.

Examples:

s := 123.strconv.Itoa() // equivalent to s := strconv.Itoa(123)
d, err := s.strconv.Atoi() // equivalent to d, err := strconv.Atoi(s)

func Map[T, U any](input []T, f func(T) U) []U {
	result := make([]U, 0, len(input))

	for _, e := range input {
		result = append(result, f(e))
	}

	return result
}

strs := ([]int {1,2,3}).Map(strconv.Itoa) // equivalent to strs := Map([]int{1,2,3}, strconv.Itoa)
  • Who does this proposal help, and why?

My personal motivation is that I would like to allow my generic code to support function chaining like we see in other languages:

result := someList.
   filter(...).
   map(...).
   map(...).
   reduce(...)
   
Instead of:

i1 := filter(someList,...)
i2 := map(i1,...)
i3 := map(i2,...)
result := reduce(i3,...)
  • Please describe as precisely as possible the change to the language.
  • What would change in the language spec?

A clause saying that it is possible for the right-hand side of a . to be a (possibly generic) function specifier, in which case that function would be called, passing in the left-hand side of the . as the first parameter to the specified function. If there is ambiguity because the function specifier matches both a method and a function, I believe the method should be preferred.

  • Please also describe the change informally, as in a class teaching Go.

You can call functions as if they are methods, provided the function takes as its first parameter, the type of thing that is to the left of the ..

  • Is this change backward compatible?
    How backwards compatible?

    This change should not break any existing code, since method invocations would be preferred over UFCS invocations. It is essentially an expansion of the syntax.

  • Orthogonality: how does this change interact or overlap with existing features?

I've mentioned how it interacts with generics. I suspect it would also interact with the ability to extend interfaces in multiple modules, in the sense that you can effectively add (namespaced) method-like functions to any type.

  • Is the goal of this change a performance improvement?
    Not exactly. It's more a development quality-of-life improvement.

Costs

  • Would this change make Go easier or harder to learn, and why?
    I personally would find it to be a wash.

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

  • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?
    I don't understand the golang codebase enough to quantify these questions.

  • What is the compile time cost?
    I suspect it's fairly minimal, but again, I haven't delved too deeply into the golang codebase.

  • What is the run time cost?
    0, since this is just syntactic sugar.

  • Can you describe a possible implementation?
    (I have no idea if this is feasible in the context of the current language's implementation)
    When you've encountered what appears to be a method call, and the method specifier does not match any visible methods for the type to the left of the ., proceed to check if there are any functions that match the method specifier and take as the first parameter the same type as the item to the left of the .. If such a match is made, treat it as a UFCS function call.

  • Do you have a prototype? (This is not required.)
    No. I wish I had time to learn the internals of Go well enough to build one.

@gopherbot gopherbot added this to the Proposal milestone Oct 15, 2022
@beoran
Copy link

beoran commented Oct 15, 2022

Lua allows something like this with the a:foo syntax getting rewritten to a.foo(a) , but I am not sure if it is a good idea for Go.

@ianlancetaylor ianlancetaylor added LanguageChange Suggested changes to the Go language v2 An incompatible library change labels Oct 16, 2022
@ianlancetaylor
Copy link
Member

If I'm reading this correctly, what about code like

type S struct {}
func (S) F() {}
func F(S) {}
func G(a S) {
    a.F() // Does this call F or S.F?
}

That is, a.F might call the function F passing a as an argument, or it might call the method S.F with a as the receiver.

Of course we can make a rule for this case (and we would have to that it calls S.F). But 1) this makes the code ambiguous to the reader; 2) it means that adding a method to a type can unexpectedly change completely unrelated code, if the method happens to have the same name as an existing function.

@earlye
Copy link
Author

earlye commented Oct 17, 2022

That is, a.F might call the function F passing a as an argument, or it might call the method S.F with a as the receiver.

In the case of ambiguity like this, I would expect the method to match prior to the function, and therefor be the selected callee. That is, a.F() should call the method S.F with a as the receiver.

From the proposal:

If there is ambiguity because the function specifier matches both a method and a function, I believe the method should be preferred.

My rationale is that a method is part of the type's design, whereas functions are not.

I can see other expectations being reasonable as well, and if an alternative matching were selected, I'd be okay with it. From my perspective, the most important thing is that the resolution of this ambiguity is deterministic and fairly easy to predict.

There is admittedly the issue that one could write a set of generic functions that end up calling a method when they are expecting to call a function:

func (a S) F() {}

func F[T](a T) {}
func G[T](a T) {
  a.F() // expects to call F[T](a), but if T is S, will call method F w/ receiver a.
}

This may or may not be a problem. A generic library might make effective use of this by treating the function/method name as an interface hook - sort of a simple analog to the curiously recurring template pattern in C++ (https://en.cppreference.com/w/cpp/language/crtp).

@Merovius
Copy link
Contributor

Merovius commented Oct 18, 2022

Copying over from #56283 (TBQH these should really be duplicates):

Under this proposal, foo.bar.baz() could mean one of (at least?) four things

  1. Calling the method baz on the field bar of the struct foo
  2. Calling the method baz on the identifier bar in package foo
  3. Calling the function baz from the package bar with argument foo
  4. Calling the function baz from the current scope with argument foo.bar

I think this ambiguity, even if we can resolve it in the spec, makes code harder to understand.

There's also a parsing ambiguity, in that 3.e1 could both both be a) a floating point literal for 30, or b) the beginning of a function call e1(3). So the parser has to do look-ahead, which we generally do not like.

@asegs
Copy link

asegs commented Oct 18, 2022

Hey @earlye - I just had these conversations that you are having in this issue - #49085. I accepted that using the . operator for functions introduced too much ambiguity, and opened up the previously mentioned issue to discuss using an operator to chain functions instead of universal function call syntax.

@earlye
Copy link
Author

earlye commented Oct 18, 2022

@Merovius - I think #56283 probably is a duplicate, though I haven't read closely enough to confirm. Will try to make some time to do so. I see your point, along with @asegs ' point about ambiguity.

But I think it's important to note that 1 and 2 are indistinguishable without more context today. Specifically, the context of whether foo is a struct or package in the current scope. But if you think of packages as being a fairly special case of interface{}, then the difference is fairly moot.

Given the proposal's rule that we attempt to match methods before functions, I believe 3 vs 4 is the more problematic ambiguity:

is foo.bar.baz() the same as baz(foo.bar) or bar.baz(foo)?

I think this is the situation where a new operator would resolve the ambiguity fairly easily:

foo.bar#baz() is the same as baz(foo.bar)
foo#bar.baz() is the same as bar.baz(foo)

You could refine the proposal to say that "if the caller wants UFCS lookup, they use the new operator #". That would also eliminate the ambiguity for 3.e1 - it's always a floating literal, while 3#e1 is always the same as e1(3).

NOTE: I chose # here specifically because it feels unlikely to be the actual selected operator - it's a comment in too many languages. I'm not sure which symbol to actually pick and don't really have a strong opinion as to which one. Could go with :, .., $. whatever.

@asegs
Copy link

asegs commented Oct 18, 2022

This is again exactly where I ended up, check out my proposal and feel free to discuss there.

@aarzilli
Copy link
Contributor

@Merovius I think your example is actually a little bit worse, given expression A.B.C() if B isn't a package name it is going to parse to:

              CallExpr
               /    \
     SelectorExpr   nothing
       /       \
SelectorExpr   C
   /    \
  A      B

but if B is a package name it will parse to

              CallExpr
               /    \
     SelectorExpr    A
        /    \       
        B    C  

which means that go/parser.ParseExpr has to be deprecated because parsing a standalone expression is now impossible without the list of package names. If we want this syntax to be backwards compatible, i.e. A.B.C always parses the old way if B is both a field of A and a package name, then the parser also needs to know the type of A while parsing.

@Merovius
Copy link
Contributor

Merovius commented Oct 18, 2022

@aarzilli I think A.B.C() would always have to parse as the first AST, with the actual selection of which it is done by the type checker.

That being said, I believe there is a consensus that using . for UFCS is just a bad idea which is off the table.

@leaxoy
Copy link

leaxoy commented Oct 19, 2022

Or introduce ::, so became to bar::baz.foo()

@ianlancetaylor
Copy link
Member

This introduces a new way to do something that we can already do. It also introduces an ambiguity between whether a.F refers to a field/method or function, which even if resolved at the language level is still confusing for the reader. It also introduces a new use of dot, one of the most overloaded tokens in Go.

The emoji voting is also fairly negative.

For these reasons, this is a likely decline. Leaving open for four weeks for final comments.

@preslavrachev
Copy link

While I personally like UCS a lot, I think that it cannot make it to Go for one simple reason - age. Go is a mature language, well past v 1.0 and any such change will not only bring questions about backwards compatibility, but what is more important - as @ianlancetaylor pointed out, it will cause confusion with methods and will lead to a lot of potential code inconsistency. Let's not even mention the impact on the build tool ecosystem (linters, static analysis tools) - judging by how much it has been taking those to adopt generics, we don't want to introduce another potential seismic shift.

That said, UCS is awesome and I definitely encourage everyone to have a look at how it has been implemented in other languages. I personally am on the controversial opinion that Go could have lived just as well without the method syntax - and UCS could have been a perfect solution for adding a bit of OO syntactic sugar to Go. But that train has long since gone. Plus, I have no idea how one would have tackled interfaces and polymorphism without methods.

@ianlancetaylor
Copy link
Member

No change in consensus.

@ianlancetaylor ianlancetaylor closed this as not planned Won't fix, can't repro, duplicate, stale Dec 7, 2022
@golang golang locked and limited conversation to collaborators Dec 7, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
FrozenDueToAge LanguageChange Suggested changes to the Go language Proposal Proposal-FinalCommentPeriod v2 An incompatible library change
Projects
None yet
Development

No branches or pull requests

9 participants