DEV Community

cedretaber
cedretaber

Posted on • Updated on

Pipeline: Pass it in first

A pipeline operator was introduced to the trunk of Ruby, and it has a controversial argument. It seems that various things have been said about this "pipeline operator" itself.

I recommend this article in @mame's blog because it is very well organised and easy to understand.

Note: Matz said that the name and the characters of this operator are going to be changed.
Feature #15799 note-29

To be first, or to be last

The original pipeline operator from Isabelle/ML and F# behaves as "applying the result of the right-hand side expression to the result of the left-hand side expression". As described in @mame's article, this can be combined with the characteristics of ML-based languages that functions are automatically curried and the grammatical precedence of function application is higher than that of operators. It has the meaning of "passing the result of the left-hand side expression to the right-hand side function call as the last argument", and can produce various advantages.

Moreover, for languages like F# and OCaml, this operator could be defined in the form of just a user-defined operator without having a dedicated construct for that.

Elixir's pipeline operator, on the other hand, behaves as "passing the result of the left-hand side expression to the right-hand side function call as the first argument". There is a practical reason for this "first or last" difference, but I won't go into detail here. The problem is that if we put the left-hand side value as the first argument of the right-hand side function call, we need to include it as a language feature.

In fact, languages such as Elixir and Clojure perform the same behaviour by using macros, but the need to change AST remains the same. The reason is that in environments where functions are naturally curried, application of functions that lacks the last argument is a valid expression on its own, but which lacks the first argument is an invalid expression.

For this reason, the pipeline operator, which was just a function, becomes a special syntax.

Pipeline operators in various languages

Ruby's pipeline operator has a more different meaning and behaves as "the result of the left-hand side expression is the receiver and calls the right-hand side as a method". In other words, this operator is essentially low-priority . and ::. It seems quite strange behaviour from the viewpoint of the original F# or other functional languages, but Elixir's pipeline operator, to which Ruby referred, is also very different from F#'s.

Well, I took the pipeline operators of F#, Elixir, and Ruby as an example, but they are not the only languages that have pipeline operators. And there exsits languages other than Elixir whose pipeline operator behaves as "pass as the first argument".

Clojure: Threading Macros

Yes, it is a macro. However, since it has a similar usage, I will introduce it.

Clojure Threading Macros

(defn transform [person]
  (update (assoc person :hair-color :gray) :age inc))

(transform {:name "Socrates", :age 39})
;; => {:name "Socrates", :age 40, :hair-color :gray}

This function receives a map, puts :hair-color into it and increments its :age. assoc function has the signature (assoc map key val) and add a key-value pair into the first argument. update function has the signature (update map key f) and update the value of the first argument.

It is difficult to read because multiple functions are nested. You will also have to struggle when making a change to codes like this.

The equivalent process, written in Elixir, looks like this:

def transform(person), do:
  Map.update!(Map.put(person, :hair_color, :gray), :age, & &1 + 1)

This too is hard to read.

The code looks like this when using a threading macro:

(defn transform* [person]
  (-> person
      (assoc :hair-color :gray)
      (update :age inc)))

Let's rewrite using Elixir with the pipeline operator.

def transform(person), do:
  person
  |> Map.put(:hair_color, :gray)
  |> Map.update!(:age, & &1 + 1)

Unlike pipeline operators, Clojure's threading macros are used only once at the beginning of the sequence of forms. Give the target value to the first argument of the macro, followed by any number of functions that take the value to be processed as the first argument. Then the macro will rearrange the forms and insert the result of each form to the first argument of the next form.

This macro is called thread-first macro. There are other macros like this in Clojure, the one is a thread-last macro ->> which passes as the last argument and another is thread-as macro as-> which can pass to anywhere. You can use something appropriate for what you want to do.

Why are there both the "as the first argument" macro and the "as the last argument" macro? The reason why is that Clojure's built-in functions have a convention in which functions that deal with sequences take sequences as the last argument, and functions that deal with data such as collections take that data as the first argument.

So Clojure had to prepare both macros for convenience.

Again, Clojure's threading macros are just macros, not special features built into the language. Rather than using a special syntax, it is built using only the native features of the language. This is not limited to Clojure, and some other Lisp-like languages also have the macros like this. The simplicity and extensibility of the Lisp make it possible to do so.

BuckleScript/Reason: Pipe First

BuckleScript is a compiler that converts OCaml into JavaScript, which is a modification of OCaml compiler. In that sense, it is almost the same language as OCaml, but it also includes its own extensions such as the pipe first operator introduced here.

Reason is a language compiled into BuckleScript, with a syntax close to JavaScript. Reason is made in conjunction with BuckleScript, and is converted from Reason language to BuckleScript and converted to JavaScript from there.

The languages are almost compatible with OCaml and use the same type system as OCaml. It is a highly practical language that has strong type inference and type safety, and also allows destructive changes.

Since it is originally OCaml, it also contains a pipeline operator which works as "Applying the result of the right-hand side expression to the result of the left-hand side expression". This is the one imported back from F# to OCaml.

On the other hand, there is also a type of pipeline operator that passes a value as the first argument in BuckleScript/Reason. That is the "pipe first operator".

BuckleScript: Pipe First
Reason: Pipe First

As the official site calls it "pipe syntax", this is not just an operator but a syntax.

let transform person =
  updateAge (setHairColor person `gray) ((+) 1)

Rewrite this with a pipe first:

let transform person =
  person
  |. setHairColor `gray
  |. updateAge ((+) 1)

BuckleScript's pipe first is |..

Next, let's look at the sample code in Reason:

let transform = person =>
  updateAge(setHairColor(person, `gray), (+)(1));

It rewrites like this:

let transform = person =>
  person
    ->setHairColor(`gray)
    ->updateAge((+)(1));

Reason's pipe first is ->.

It is the same as Elixir's, so there is nothing special about features. Here, I will explain why a type of pipeline operator which passes as the first argument is added to a language that already has a type of pipeline operator which passes as the last argument.

As mentioned above, BuckleScript/Reason is a language that translates into JavaScript. And as a design concept, it emphasises linkage with JavaScript. They need to provide means to use the functions defined in JavaScript in the BuckleScript/Reason code.

However, BuckleScript/Reason and JavaScript have completely different type systems, so you cannot bring type definitions in as they are. So, by defining JavaScript function types in the BuckleScript/Reason code, you introduce JavaScript functions into the OCaml world.

function transform(person) {
    return updateAge(setHairColor(person, "gray"), n => n + 1);
}
type person

external transform: person -> person = "transform"

It's fine if it's just a function, but it's a little tricky if it's a method. You can think of a method as a function that receives as implicit this as an argument:

external map : 'a array -> ('a -> 'b) -> 'b array = "map" [@@bs.send]
external filter : 'a array -> ('a -> 'b) -> 'b array = "filter" [@@bs.send]

[@@bs.send] is an annotation introduced in BuckleScript, and if this is attached to external, the function is regarded as a method call to the first argument expression and interop is performed.

let person2 = transform person1

let arr = map [|1;2;3|] (fun n -> n + 1)

Each is converted as follows:

var person2 = transform(person1);

var arr = [1,2,3].map(function(n) { return n + 1 });

While the transform function without [@@bs.send] is converted to a normal function, the map function with [@@bs.send] is converted to a method call the first argument of which is the receiver.

The problem here is that a function that is converted to a method call receives as its first argument the value that is the "primary" argument.
This is, of course, incompatible with the existing OCaml's pipeline operator.

(* Hmmm *)
let result = [|1; 2; 3|]
  |> (fun arr -> map arr (fun a -> a + 1))
  |> (fun arr -> filter arr (fun a -> a mod 2 = 0))

Therefore, pipe first was introduced. If this is used, it can write as:

let result = [|1; 2; 3|]
  |. map (fun a -> a + 1)
  |. filter (fun a -> a mod 2 = 0)

This is like a method chain. It looks natural that this pipe first was introduced to mimic the method chain. It is the pipeline operator of BuckleScript/Reason that was introduced by the need to work with JavaScript that has a method call.

D: UFCS

The D language has a feature called Uniform Function Call Syntax. That is what it says: "You can write function calls with the look of method calls".

What does this mean? This is the original code:

Person transform(Person person)
{
    return updateAge(setHairColor(person, "gray"), n => n + 1);
}

We also can write this as:

Person transform(Person person)
{
    return person
        .setHairColor("gray")
        .updateAge(n => n + 1);
}

The UFCS of the D language is a feature that you can write the function call func(a, b, c) as a.func(b, c). You can treat an ordinary function like a method (member function).

function: UFCS
DLang Tour

This UFCS comes into play when used in conjunction with templates of the D language. For example, the collection library of the D language is made for an abstract object called "range". This "range" does not inherit a particular class or interface, so collection operations are provided as functions rather than methods.

However, operations on collections often want to be chained.

writeln(filter!(x => x % 2 == 0)(map!(x => x + 1)([1,2,3])));

With UFCS:

[1,2,3]
    .map!(x => x + 1)
    .filter!(x => x % 2 == 0)
    .writeln;

This feature is to make a function call look like a method call, but at the root of it, there is an idea to try to improve readability by rearranging the order of arguments and functions as well as pipeline operators.

Conclusion

I collected languages that provided a pipeline operator that behaves the same as Elixir, "passing the result of the left-hand side expression as the first argument of right-hand side function call". It is interesting to note that all the languages I introduced here are closely related to object-oriented languages. BuckleScript/Reason and the D language clearly put this feature in order to mimic method calls.

Whether the "primary" argument is taken as the first argument or the last argument depends on the nature and culture of the language. We should not want all pipeline operators from various languages to behave the same. In fact, languages that can come to both, such as Clojure and BuckleScript/Reason, provide both types of pipeline operators for convenience.

I think that Ruby's pipeline operator that has a behaviour of calling the method with the left-hand side as a receiver and the right-hand side as a method name and arguments is quite unique. It's exciting to do something new, so try it on your own and feel what it looks like!

Top comments (0)