Monday, 5 May 2025

Pipe Operator part 2

Last year I found out about the existence of a pipe operator in several languages (other than bash) and wrote a post about some ways (Pipe21, coconut, a decorator that transpiles the function...) to bring that into Python. I've recently come across a very interesting post about pipes in different languages

The article mentions several decisions to take when adding pipes to a language:

  • Piping functions which accept multiple parameters. Should the piped value be the first parameter or the last (pipe first, pipe last)? Well, of course you can circumvent that by adding an extra wrapper function that just takes the piped value and invokes the real function with the value in the right position, or you can use Partial function application. I like the "pipe everywhere" approach and using a keyword to refer to the piped value.
  • What do we pipe (what do we put on the right side of the pipe operator), expressions or unary functions? I mean we pass the value to an expression or to an unary function (F# style)

The different answers to the 2 above questions have been the reason for having 2 different proposals for adding pipes to JavaScript, though there's now a single contender that is still going through the endless approval process. That proposal is a really interesting read, as it also explains how it differs from the rival proposal. To summarize:

Main proposal: We put an expression on the right side of the pipe operator and we refer to the piped value using a keyword.

Discarded proposal (F# like pipes): We put an expression that evaluates to an unary function on the right side of the pipe operator. If we are piping to a function that receives multiple parameters (or want to invoke a method in the piped value), we have to use an unary arrow function that will invoke the real function/method.

I have to say that I really like a lot this pipe operator, and revisiting my thoughts on how to emulate it in Python, I've come to the conclusion that just using a pipe() function (and combining it with functools.partial) is almost as elegant as the Pipe21 approach that I mentioned last year. It feels like a verbose, function based, version of the F# like approach. Let's see.


import functools
from functools import partial as pl

def pipe(val: Any, *fns: list[Callable]) -> Any:
    """
    pipes function calls over an initial value
    """
    def _call(val, fn):
        return fn(val)
    return functools.reduce(_call, fns, val)


cities = ["Paris", "Berlin", "Xixon", "Porto", "Prague", "Turin"]

pipe(cities, 
pipe(cities, 
    pl(filter, lambda x: x.startswith("P")), # bind the first argument to filter
    pl(map, lambda x: x.upper()), # bind the first argument to map
    pl(sorted, key=lambda x: len(x)), # use keyword arguments to bind the second argument to sorted
    print,
)

# ['PARIS', 'PORTO', 'PRAGUE']

# if we had a pipe operator like the one proposed for javascript we could write this:
# https://github.com/tc39/proposal-pipeline-operator?tab=readme-ov-file
# (cities
#     |> filter(lambda x: x.startswith("P"), $$)
#     |> map(lambda x: x.upper(), $$),
#     |> sorted($$, key=lambda x: len(x)),
#     |> print($$),
# )

Languages like C# or Kotlin come with extension methods, which facilitate method chaining and can seem like an alternative to having a pipe operator. I still prefer the pipe operator. We should use extension methods for a class/interface if really they fit in it (and we had missed to put them directly in the class/interface when it was designed), but we should not arbitrarily add extension methods to an object just to enable method chaining. All this relates to the discussion of whether something should be a method of a class or a separate function. If our normal design logic tells us that something should be a function and not a method, the lack of a pipe operator should not push us to turn the function into an extension method.

No comments:

Post a Comment