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: alternate function call syntax to improve ergonomics of chainable top-level functions #47680

Closed
DeedleFake opened this issue Aug 13, 2021 · 5 comments
Labels
FrozenDueToAge LanguageChange Suggested changes to the Go language Proposal Proposal-FinalCommentPeriod v2 An incompatible library change
Milestone

Comments

@DeedleFake
Copy link

DeedleFake commented Aug 13, 2021

This is a random idea that popped into my head suddenly. I'm not entirely sure that I like it, but I thought that I'd put it out there and see what people think. At the very least, maybe it'll get some more proposals going to try to fix the issue that I'm trying to solve here.

Problem

One of the features purposefully left out of the generics design is adding new type parameters in method declarations. In other words, func (t *SomeType) SomeMethod[T any](v T) is illegal. As the design draft pointed out, the only real functionality difference between methods and top-level functions in Go is in interface satisfaction, and how methods that can add new type parameters would affect satisfaction has a lot of subtle implications. One solution that has been proposed is that those methods simply shouldn't count at all towards interface satisfaction, but then the question is why bother making them methods at all.

Unfortunately, top-level functions are not always ideal. While it's true that there's no functionality difference outside of interface satisfaction, certain common systems, such as iterators, lend themselves towards APIs that are very awkward as top-level functions. Under the current generics proposal, iterator transformation functions, such as map and filter, would have to be implemented as top-level functions, but top-level functions for iterator chaining can be very messy.

One option is to call those functions one after another with large numbers of intermediary variables:

// It's a bit of a contrived example, I know.
it := iter.OfSlice(s)
trunc := iter.Map(it, func(v float64) int { return int(v) })
trunc = iter.Filter(it, func(v int) bool { return v > 0 })
strings := iter.Map(it, func(v int) string { return fmt.Sprintf("%x", v) })
return iter.Slice(it)

This is very messy, as well as error prone. Some variable have to be new because they have new types, but some can be the same, and naming all of them something useful is a whole extra thing to worry about pointlessly. An alternative is to nest the calls, but that's actually worse:

// The reads like a C function pointer type declaration: Inside-out.
return iter.Slice(
  iter.Map(
    iter.Filter(
      iter.Map(
        iter.OfSlice(s),
        func(v float64) int { return int(v) },
      ),
      func(v int) bool { return v > 0 },
    ),
    func(v int) string { return fmt.Sprintf("%x", v) },
  ),
)

Proposal

The idea is simple: Add a syntax, probably in the form of a new operator, that allows top-level functions to be chained like methods. It would be nothing but syntax sugar.

An example syntax for this might look like a() -> b(). This would be equivalent to b(a()). With this syntax, the result of the expression on the left is passed as the first argument of the function call on the right. All other arguments are handled normally. In other words, b(a(1, 2, 3), 8, 9) could also be written as a(1, 2, 3) -> b(8, 9). This usage would make the syntax compatible with method expressions, as well as the majority of functions that already exist due to standard argument order conventions, and allow functions written with this syntax in mind to follow a standard argument ordering as well.

Using this syntax, the above admittedly-contrived iterator example could be rewritten as follows:

return iter.OfSlice(s) ->
  iter.Map(func(v float64) int { return int(v) }) ->
  iter.Filter(func(v int) bool { return v > 0 }) ->
  iter.Map(func(v int) string { return fmt.Sprintf("%x", v) }) ->
  iter.Slice()

It should be noted that this would only be legal if the expression on the left side has at most one return. A multi-return version could be implemented, but this proposal is only for a single-return-only variant.

Conclusion

Like I said above, I'm not entirely sure about this idea myself. One particular problem that I have with it is the namespacing required when chaining to an imported function, but I thought that I'd at least propose it as the lack of additional type parameters on methods is my biggest issue with generics as currently proposed, and this could solve that by simply sidestepping the issue completely.

Another potential issue that I have with this is that if it is decided later to try to allow methods with new type parameters that do satisfy interfaces, this will probably feel fairly redundant, but it could still be useful as pseudo extension functions.

Feel free to leave a thumbs-down if you think that it's too weird.

Language Change Template

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

Experienced.

What other languages do you have experience with?

C, Ruby, Python, Kotlin, Java, Rust, JavaScript, and a few others.

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

Slightly harder due to there being an extra syntactic detail to learn, I suppose, but the idea's pretty straightforwards.

Has this idea, or one like it, been proposed before?

I don't think so.

Who does this proposal help, and why?

Anyone trying to write a complicated API that lends itself to chained function calls but that has to be written as top-level functions, It also allows for what are essentially extension methods by way of making top-level functions behave like methods, allowing, for example, for a third-party package to implement some new middleware iterator function and for it to integrate well into existing systems.

What is the proposed change?

Add a new syntax sugar that allows the first argument to any regular, top-level function to be specified before the name of the function. One possibility is that of an -> operator that does just that, making b(a(1, 2, 3), 8, 9) completely equivalent to a(1, 2, 3) -> b(8, 9).

Is this change backward compatible?

Yes.

Show example code before and after the change.

See above.

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?
    • A few would definitely, especially the formatters.
  • What is the compile time cost?
    • There would be one extra syntactical structure that needs to be parsed, so there would be one, but it would probably be pretty minor.
  • What is the run time cost?
    • Non-existent.

Can you describe a possible implementation?

Nope.

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

Nope.

How would the language spec change?

It would need a new section under function call syntax that would describe this syntactical feature.

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

It complements function call syntax by allowing for certain patterns that are currently unfeasible due to incredibly awkward ergonomics.

Is the goal of this change a performance improvement?

No.

Does this affect error handling?

As currently proposed, no, not really, but a variant could allow for chaining functions with multiple returns and then it could be useful.

Is this about generics?

It was inspired by an issue that came up in the generics design, but it doesn't have anything to do with generics directly itself.

@gopherbot gopherbot added this to the Proposal milestone Aug 13, 2021
@ianlancetaylor ianlancetaylor added v2 An incompatible library change LanguageChange Suggested changes to the Go language labels Aug 13, 2021
@deanveloper
Copy link

deanveloper commented Aug 13, 2021

related - #33361

Personally, i think that this problem is better remedied by allowing to shadow variables within the same scope, similar to Rust. Shadowing in Rust is used as a way which allows for removing temporary variables, while keeping argument-passing explicit:

it := iter.OfSlice(s)
it := iter.Map(it, func(v float64) int { return int(v) })
it := iter.Filter(it, func(v int) bool { return v > 0 })
it := iter.Map(it, func(v int) string { return fmt.Sprintf("%x", v) })
return iter.Slice(it)

I know Go wanted to go the route of "we should remove all variable shadowing", and obviously this is a step in the opposite direction. But I think that it may provide a very significant benefit whenever we have iterators/streams/etc.

The one thing I'm afraid of is of people starting to use := everywhere.

@DeedleFake
Copy link
Author

DeedleFake commented Aug 13, 2021

related - #33361

Ah, I missed that one completely. I looked for alternate function call syntaxes and chaining function calls, but that one didn't come up. I guess that something very similar to this has been proposed before after all, then.

I know Go wanted to go the route of "we should remove all variable shadowing", and obviously this is a step in the opposite direction.

I think that the shadowing problem actually stems more from the oddity of := combined with multiple returns, not that it exists at all. I think that Go needs to move towards variable declaration being per-variable on the left-hand side of the assignment, not an all-or-nothing like := is.

@beoran
Copy link

beoran commented Aug 14, 2021

@deanveloper I recently found a serious bug in my work software and it was due to shadowing. So, please, let'd not go there.

@ianlancetaylor
Copy link
Member

This syntax doesn't seem to support error handling very well.

It also does not have strong support based on emoji voting.

Therefore, this is a likely decline. Leaving open for four weeks for final comments.

@ianlancetaylor
Copy link
Member

No further comments.

@golang golang locked and limited conversation to collaborators Oct 6, 2022
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

5 participants