12
$\begingroup$

Say I have a loop that looks like this:

for i in range(1, 10) {
   print(i)
}

Now I want to take the same sequence in reverse, and filter out even numbers. I might also allow the function to work with arguments like range(10, 1) but let's ignore that option for now. I'll use other things called reverse and filter that work on any sequence. I'm wondering about syntax possibilities.

for i in filter(reverse(range(1, 10)), odd) { ...
for i in range(1, 10).reverse().filter(odd) { ...
for i in range(1, 10) | reverse | filter(odd) { ...
for i in range(1, 10) ⏵ reverse ⏵ filter(odd) { ...
for i in range(1, 10) → reverse → filter(odd) { ...
for i in range 1 10 → reverse → filter odd { ...

The first case I find harder to read as the function calls pile up.

With the second case (extension methods), I wonder if they are good enough, and obviate the need for a special "pipeline" syntax.

The next three are all similar, except I'm considering using some Unicode for operators unlike traditional programming languages. We have all these nice glyphs; why not use them?

The last one is based on Haskell, which I haven't used much, but I guess syntax like that without (...) can also work.

I know C++ is using | for its std::ranges pipelines. It looks like Julia uses |>. What else is out there?

$\endgroup$
15
  • 5
    $\begingroup$ Personally, I really dislike unicode operators in languages. Mostly because I can't type them with my physical keyboard, save for horrbily overcomplicated ways like Alt + '+' + DEADBEEF or copy-pasting it every time $\endgroup$
    – abel1502
    Commented Jul 21, 2023 at 15:56
  • 1
    $\begingroup$ @DavidYoung That's cool, but then why not use \forall itself in the language, if that's what the user types anyway? $\endgroup$
    – abel1502
    Commented Jul 21, 2023 at 16:05
  • 6
    $\begingroup$ Whatever notation you use, filter is a very bad function name, languages seem to be contradictory about if this acts like a filter that removes things or a filter to let them through. It's better to have remove or reject or keep, anything that is less ambiguous $\endgroup$
    – coredump
    Commented Jul 21, 2023 at 17:46
  • 3
    $\begingroup$ For what it's worth, the font my browser is using to display this webpage doesn't seem to have a proper glyph for ⏵. My hardware's a bit old but my browser and OS are definitely up to date. $\endgroup$ Commented Jul 22, 2023 at 6:08
  • 3
    $\begingroup$ Please do not force me to use an IDE. $\endgroup$ Commented Jul 22, 2023 at 17:23

5 Answers 5

15
$\begingroup$

There are several different traditions for this in real-world languages. Many of them have colonised previously-unused punctuation, rather than having a strong design ethos to them. The semantics also vary: some pass a value as the next argument to a function (or only to a curried application), some instead pass it as an inserted first argument to a function call, some have reverse or unpacking pipelines, some permit only single-parameter functions, and some only have stream processing.


Haskell has multiple in its standard library:

  • & has type a -> (a -> b) -> b, the classical left-to-right pipeline providing the value on the left as the next argument to the function on the right. This is not in the Prelude but can be imported from Data.Function; it is the most common realisation of this type in Hoogle, but there are others in other libraries. You could use this as something like range 1 10 & reverse & filter odd.
  • $ has type (a -> b) -> a -> b, reverse pipeline right-to-left. This one is in the Prelude. You could use this as something like filter odd $ reverse $ range 1 10.
  • $! is $ but strict in the value argument.
  • Versions of the operation are also provided for functors (<&> and <$>), applicative functors (<**> and <*>), and other types, most obviously monadic bind >>=.
  • . performs the function composition without the final application to a value.

The conventional pipeline operator in R is %>%. It is a library operator from the ubiquitous dplyr and magrittr packages, and it works a little differently to the others — it's in effect a macro rewrite that inserts the left-hand value as the first argument of the function call written on the right:

1:10 %>% rev %>% filt(odd)

would expect filt to be defined as function(vector, predicate). This is the opposite order to the Haskell version. Note also that the odd predicate needed to be parenthesised, to make filt(odd) a function call. There are many other third-party pipelining operators as well, and support in this one for inserting the value at a placeholder position.

Several languages include |> for pipelining, but with differing semantics:

  • F# uses |> as its main pipe operator, passing the value on the left as the next argument to the function on the right. You could use this as something like range 1 10 |> List.rev |> List.filter odd. It also uses ||> for a tuple-unpacking version and <| for the reverse order. >> and << perform unapplied composition. Elm uses these as well.
  • OCaml has |> since version 4, but not the others from F#.
  • Hack uses |> with explicit placeholders for the piped value on the right-hand side: range(1, 10) |> Vec\reverse($$) |> Vec\filter($$, $n ==> $n % 2 == 1) inserts the argument in place of $$.
  • Julia also uses |>, and only for single-parameter functions, but its macro support allows libraries to extend this to provide placeholders for the argument value.
  • Elixir has |> too, but working like in R rather than F#.
  • The TC39 proposal for JavaScript pipelines uses this operator with Hack semantics and % as the placeholder token.

Concatenative languages use juxtaposition to represent this pipelining (and function composition in general). Mere adjacency is sufficient to pipeline the output of one function into the next already. This holds for stack-based Forth, Kitten, Joy, and others, and for inline ones like Om or Kihi.

Unix shell languages use | to compose stream pipelines, as do PowerShell object pipelines and many other inspired languages. These are connecting input and outputs streams, rather than nested function applications. jq, being quasi-concatenative, has both juxtaposition and | for pipeline steps, and they can be used in slightly different places; the input value is out of band and not a parameter regardless.

Raku uses the pretty verbose andthen, with some short-circuiting semantics, and elements of placeholders and method-call shorthands. This is borderline to constitute a pipeline.

Any language with uniform function call syntax provides pipelining via its method application operator, typically .. This typically provides the left-hand side as the first parameter of the function, but some approaches allow specifying the receiver in the declaration. This does require an increased level of multiple dispatch, but no new syntax at the call site. Extension methods, as in C#, give a more restricted version of the same thing. In either case, you'd expect to write something like range(1, 10).reverse().filter(odd) as in your example.


There is a wide variety of syntax in use and subtly or drastically different semantics between existing languages. There isn't any visible coupling between the syntactic form in use and the behavioural design of the operation, so fixing one doesn't point at a specific tradition for the other. Many cases are simply an available symbol, or a symbol borrowed from another language but given quite different meaning locally.

$\endgroup$
3
  • 1
    $\begingroup$ I'd just like to note, to avoid confusion, that Rust uses the term “uniform function call syntax”, but for something different than what's discussed in this answer. $\endgroup$
    – xigoi
    Commented Jul 22, 2023 at 22:13
  • $\begingroup$ More specifically, the Rust project formerly used "uniform function call syntax" for both applying a method using the same syntax as for a free function and for (the proposed feature of) applying a free function using method call syntax, before later renaming the latter to "uniform method call syntax" to remove the ambiguity. $\endgroup$
    – user570286
    Commented Jul 23, 2023 at 2:15
  • $\begingroup$ Nitpick/update: the first pipe operator in R was %>% from magrittr, but since R 4.1 the pipe operator |> has been a core feature of the language. The semantics are similar to %>%, although it is a bit less flexible; x |> f(y) |> g() is treated by the interpreter to g(f(x, y)). Since R 4.2, the "piped in" argument can be something other than the first, using _ as a placeholder, but only for named arguments; e.g. x |> f() |> g(y, data = _) results in g(y, data = f(x)). The magrittr placeholder, ., can appear multiple times in any context, e.g. x %>% f(y, ., sum(.)). $\endgroup$
    – brendan
    Commented Jul 23, 2023 at 9:08
6
$\begingroup$

APLs just let their functions take arguments on the right and a higher-order functions take a single functions on the left, thereby allowing you to chain everything from right to left:

for i in odd filter reverse range 1 10

You can try this in an actual APL by defining the required odd, filter, reverse, and range:

odd←{2|⍵}
filter←{(⍺⍺ ⍵)/⍵}
reverse←⌽
range←⊃(⊣,+∘⍳)-⍨/

Now, running

:For i :In odd filter reverse range 1 10
    ⎕←i
:EndFor

prints:

9
7
5
3
1

Attempt this online!

$\endgroup$
2
  • 2
    $\begingroup$ Along similar lines, Forth, in which the stack mechanism means that everything is automatically a "pipeline". If it had complex types like ranges, this would read very simply as 1 10 RANGE REVERSE ' ODD? FILTER FOR I ... $\endgroup$ Commented Jul 21, 2023 at 16:36
  • $\begingroup$ Agreeing with Adam, APL also had operator composition. $\endgroup$ Commented Jul 22, 2023 at 7:33
4
$\begingroup$

Composition Macros

Clojure supplies threading macros. Basically, you call them like functions and they do the function chaining at compile-time.

range(1, 10) ⏵ reverse ⏵ filter(odd)

This example would look something like

(->> (range 1 10) reverse (filter odd))

I could see this manifesting in a non-Lisp as looking something like

->>(range(1, 10), reverse, filter(odd))

If you're going the Ruby way and not requiring parentheses for the top-level function call, this could be simplified to

->> range(1, 10), reverse, filter(odd)
$\endgroup$
4
$\begingroup$

LINQ

Why use operators when you could use words? The syntax is basically SQL with a different clause order:

var filteredBackwardsRange =
  from num in Enumerable.Range(1, 10)
  where IsOdd(num)
  orderby num descending
  select num;

foreach (var i in filteredBackwardsRange)
{
  // ...
}

This looks cool, but every project I've worked on that uses LINQ prefers to call the methods explicitly:

var filteredBackwardsRange = Enumerable.Range(1, 10)
  .Reverse()
  .Where(IsOdd);

foreach (var i in filteredBackwardsRange)
{
  // ...
}

LINQ is not only a bit more verbose, it requires more work on the parser's side, and isn't extensible. (In fact, Reverse is one of the ones that doesn't have a keyword equivalent, hence orderby descending in the first snippet.) I can't honestly recommend it in a new language, but it does exist.

$\endgroup$
3
$\begingroup$

It's not prior art, but a suggestion:

:= for special purpose pipeline start marker

So you can have both, a special pipeline syntax that uses no special inter pipe symbol, and the rest of language is unaffected.

A pipeline sequence expects some data to be emitted from one coroutine to another, and may or may not produce data at end. It's akin to saying that each pipeline stage is a small program that reads a local stdin and outputs to a local stdout, but in this case, probably reads from typed channel readers and outputs to typed channel writers.

This may be relevant if your language will have support for cooperative routines or full blow async/await channel pipeline.

Something like this:

# Use

var odd = ...;                               // normal syntax
var seq := range(1, 10) reverse filter(odd); // pipe application syntax
foreach( var filtered in seq )
    // ...

# Declared as

def asyncio int range( _ , int start , int end , int step = 1 ): // no input
def asyncio T reverse (T arg):                    // async channel interface
def asyncio T filter (T arg , Predicate<T> pred): // asyncio w/ extra params
def T filter_odd (T arg, Predicate<T> pred):      // normal syntax

# Signature desugars to and is implemented as

def async AsyncWriter<T> reverse (AsyncReader<T> arg):
    var stack = new Stack<T>();
    while ( var cont, data = arg.ReadAsync() )
        stack.Push( data );
    while ( stack.HasData )
        yield return stack.Pop();

def async AsyncWriter<T> filter (AsyncReader<T> arg, Predicate<T> pred):
    while ( var cont, data = arg.ReadAsync() )
        if ( pred.Invoke( data ) )
            return yield data;

# Pipe application accepts normal and async/asyncio fuctions,
# but it may inject Optionals in chain. Async channel represents
# better no data as "no transmission" / send nothing.

def Optional<T> filter_odd (T data):
    if ( num % 2 > 0 )
        return data;
    else
        return null;
$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .