Skip to content

proposal: spec: pipe operator with explicit result passing #70826

Closed as not planned
@DeedleFake

Description

@DeedleFake

Go Programming Experience

Experienced

Other Languages Experience

Elixir, JavaScript, Ruby, Kotlin, Dart, Python, C

Related Idea

  • Has this idea, or one like it, been proposed before?
  • Does this affect error handling?
  • Is this about generics?
  • Is this change backward compatible? Breaking the Go 1 compatibility guarantee is a large cost and requires a large benefit

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

Yes, several times. This variant directly addresses the main issues brought up in those proposals.

Does this affect error handling?

Not directly, though it possible could in some cases.

Is this about generics?

Not directly, though it addresses a situation that has arisen as a result of generics.

Proposal

When a pipe operator has been proposed before (#33361), the primary issues with it were

  1. Not enough interest.
  2. Not explicit enough.
  3. Might lead to APIs being written specifically to accommodate it, thus potentially making them awkward for no reason.

I think that the first point is arguable as a reason not to consider a feature, but more importantly I think that the situation there has changed and I think that #49085's continued discussion is good evidence that some way to fix the issue that a pipe operator would address is a very popular idea. With generics being added and now iterators, too, some way to write chains of function/method calls in a left-to-right or top-to-bottom manner has, I think, gained a fair bit of usefulness that it didn't have back in 2019 (#33361). Simply using methods like many other languages, such as Rust, do, has a lot of problems that have been pointed out in the above issue, but functions have none of those problems. Their only issue in this regard is simply syntactic.

Points 2 and 3, however, I think are very solvable in a simple way: Add a special variable that is defined for the scope of each piece of the pipe that contains the value of the previous expression instead of magically inserting function arguments. For example, assuming that the bikeshed is painted piped:

a() |> f1(piped, b) |> f2(c, piped)

The first |> operator creates a new scope that exists only for the expression immediately to its right, in this case a call to f1(). In that scope, it defines a variable, piped, containing the result of the expression to its left, in this case just a(). The second |> operator creates a new scope that shadows the existing piped, introducing a new piped variable containing the result of the expression to its left, in this case a() |> f1(piped, b). And so on with a longer pipeline.

This completely fixes problem 2, as it now makes piping extremely explicit. It mostly fixes problem 3 as it makes the operator significantly more flexible, reducing the need for writing APIs specifically to accommodate it. I think this not completely solvable, though, as, at some point, someone will always write something that they probably shouldn't have.

It also allows the pipe operator to become non-exclusive to function calls. Any single-value expression now becomes valid at any point in a pipeline, allowing even things like

a() |> f1(piped, b) |> S{Value: piped} |> f2(c, piped)

For a more practical example, here's some iterator usage:

// Problem: Horrendously unreadable and uneditable.
xiter.Filter(
	func(v int) bool { return v > 0 },
	xiter.Map(
		func(v string) int {
			n, _ := strconv.ParseInt(v, 10, 0)
			return int(n)
		},
		strings.SplitSeq(input, "\n"),
	),
)

// Using multiple explicit variables:
// Problem: Better than the last one in terms of readability, but
// still difficult to edit by, for example, adding or removing
// operations in the middle because of needing to avoid variable name
// reuse if types change and also being sure to pass the correct one
// to the next in the chain.
lines := strings.SplitSeq(input, "\n")
ints := xiter.Map(lines, func(v string) int {
	n, _ := strconv.ParseInt(v, 10, 0)
	return int(n)
})
ints = xiter.Filter(ints, func(v int) bool { return v > 0 })

// With this proposal:
ints := strings.SplitSeq(input, "\n") |>
	xiter.Map(func(v string) int {
		n, _ := strconv.ParseInt(v, 10, 0)
		return n,
	}, piped) |>
	xiter.Filter(func(v int) bool { return v > 0 }, piped)

Side note: I'm not a huge fan of needing to put the |> operator at the end of a line. I think Elixir's way of doing it with the operator at the beginning of each of the subsequent lines looks way better. Unfortunately, Go's semicolon insertion rules kind of make this necessary unless someone can come up with a way to do it that doesn't involve special-casing the |> operator, which I definitely think would be unnecessary. For comparison's sake, here's that same iterator chain written the other way around:

strings.SplitSeq(input, "\n")
|> xiter.Map(func(v string) int {
	n, _ := strconv.ParseInt(v, 10, 0)
	return n,
}, piped)
|> xiter.Filter(func(v int) bool { return v > 0 }, piped)

Language Spec Changes

A section would have to be added about the |> operator. It shouldn't directly affect any existing parts of the spec, I don't think.

Informal Change

The |> operator allows expressions to be written in a left-to-right manner by implicitly passing the result of one into the next in the form of a variable called piped that is scoped only to the right-hand side of each usage of |>, shadowing any existing variables named piped in parent scopes, including previous |> usages in the same pipeline.

Is this change backward compatible?

Yes.

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

It allows a compromise between adding generic types in method calls (#49085) and function calls having poor ergonomics for certain use cases.

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

Slightly harder as the idea of the specially-scoped variable and its automatic shadowing of its counterparts in previous pipeline stages would have to be explained.

Cost Description

Tiny compile-time cost. No runtime costs. Slight increase in language complexity. Slight increase in potential for poorly written code as some people might misuse the operator.

Changes to Go ToolChain

All tools that parse Go code would have to be updated. gofmt and goimports would be affected the most.

Performance Costs

Compile-time cost is minimal. Runtime cost is nonexistent.

Prototype

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions