パイプライン演算子の歴史

(You can read this article in English.)

Ruby の開発版にパイプライン演算子(pipeline operator)が試験的に導入されましたが、いろいろあってプチ炎上になっています(チケット)。

せっかくの機会なので、パイプライン演算子の歴史を調べてみました。付け焼き刃の調査なので、間違ってたら教えてください。

パイプライン演算子とは

こんな感じのものです。

x |> f |> g |> h  # h(g(f(x))) と同じ意味

h(g(f(x))) という関数適用の式は、関数が呼ばれる順序(fgh)と、プログラムの字面上の順序(hgf)が逆でわかりにくいとされます。この問題は、特に、関数が大きくなったときに顕著になります。

wonderful_process_h(
  marvelous_process_g(
    fantastic_process_f(
      astonishing_argument_x
    )
  )
)

語順問題に加え、インデント地獄まで発生しています。人類はとにかく深いインデントをきらいます。

|> を使うと、インデントを深めず、実行順の通りに書くことができます。

astonishing_argument_x
|> fantastic_process_f
|> marvelous_process_g
|> wonderful_process_h

言語ごとに実現方法がだいぶ異なるのですが、ユーザ視点ではだいたいこういうものです。

Isabelle/ML のパイプライン演算子

The Early History of F# によると、パイプライン演算子を最初に導入したのは定理証明支援系の Isabelle/ML だそうです。1994 年の議論が発掘されています。

目的は上に述べた通り、関数名を処理順に並べていきたいというものでした。AAA というものを作り、それに BBB を足し、それに CCC を足し、DDD を足し、という処理を ML で素直に書くと

add DDD (add CCC (add BBB (make AAA)))

みたいな感じになります。これを、

make AAA
|> add BBB
|> add CCC
|> add DDD

と書くために導入されたことがわかります。

ちなみに、導入当初は also という名前だったようです。ML では infix という機能を使って文字だけの関数を中置演算子にできます*1。上の例で言うと実は add を中置演算子にする手もあるのですが(make AAA add BBB add CCC add DDD と書ける)、たくさんある add 系関数をすべて中置演算子にするのは抵抗がある、ということで、also を導入することになったようです。

さらに余談ですが、also から |> に至るまでにいろいろな名前が検討されていておもしろいです(ツイート)。

Isabelle/ML のコミットログを貼っておきます。

F# のパイプライン演算子

F# は Microsoft が開発した .NET Framework 向けの言語の 1 つです。OCaml がベースになっています。

F# がパイプライン演算子を取り入れたことで、パイプライン演算子は広く一般に認知されました。F# の主設計者である Don Syme も、著書の "Expert F#" でパイプライン演算子を「おそらく最も重要な演算子」と言っています。

The Early History of F# によると F# がパイプライン演算子を導入したのは 2003 年らしいです。F# で |> が重宝されるのには、「語順が気持ちいい」という以外に、「型推論で都合がよかった」という特有の事情があるようです。F# の型推論はふつうの HM 型推論ではないらしく*2、プログラムの字面上で先に書かれた式から型を決定していくらしいです。その結果、型注釈が余分に必要になることがあります。

let data = ["one"; "three"]
data |> List.map (fun s -> s.Length)

は、datastring list とあらかじめ分かるので型注釈はいりませんが、|> なしで書くと

List.map (fun (s: string) -> s.Length) data

というように、data が後に出てきてしまうので、無名関数の引数に s: string という型注釈が必要になってしまっています。*3

余談ですが、F# で広まったパイプライン演算子は、2013 年に OCaml に逆輸入されました。F# は OCaml ベースですが、パイプライン演算子については F# が先とのこと。

ML 系におけるパイプライン演算子のポイント

歴史からちょっと外れて、要点を整理しておきます。

ML 系言語における |> は、言語組み込みの演算子ではなく、単なる関数として実現されています。関数型プログラミングフリークが好きそうな非常にクールなハックです。このハックが成立する背景には、次のポイントがあると言えます。

  1. ユーザが中置演算子を定義できること
  2. カリー化が組み込みであること
  3. 「プライマリ」な引数を最後に受け取るという慣習があること

1 はまあ面白機能という感じですが、2 と 3 が特に重要だと思います。

「プライマリ」とは、たとえばリスト処理関数ならリスト、配列処理関数なら配列のように、その関数の「主対象」と言えるような引数のことです。F# ではそういう引数を最後に受け取る仕様が徹底されています("Expert F# より引用↓)。たとえば List.map だと、まず変換関数(クロージャ)を受け取り、それからリストを受け取って、その各要素を変換した新しいリストを返します。

List.map   : ('a -> 'b) -> 'a list -> 'b list
Array.map  : ('a -> 'b) -> 'a[] -> 'b[]
Option.map : ('a -> 'b) -> 'a option -> 'b option
Seq.map    : ('a -> 'b) -> #seq<'a> -> seq<'b>

これらのポイントによって、|> は単に次の定義だけで実現できてしまいます。クール。*4

let (|>) x f = f x

パイプライン演算子とメソッドチェーン

述べた通り、F# の |> は、処理順に関数をつなげて並べるために使われます(x |> f |> g |> h)。

ところで、オブジェクト指向プログラミングによるメソッドチェーンも、処理順に関数をつなげて並べます(x.f().g().h())。

これが偶然の一致なのか、F# がこれをねらって |> を導入したのかはわかりません。しかし、.NET Framework の別言語である C#オブジェクト指向であることもあってか、パイプライン演算子とメソッドチェーンを関連付けて考える人は結構いるようです。

Elixir のパイプライン演算子

歴史にもどります。

Elixir がパイプライン演算子を導入しました。Elixir の作者の José Valim によると F# が由来のようです。見た目は F# と同じように書けます。

x |> f |> g |> h

見た目こそ F# たちと同じですが、実は言語仕様面から見たらかなり別物です。Elixir において |> は、単なる演算子というより、言語組み込みの構文です*5

x |> f(args..) # f(x, args...) と同じ意味

|> の左側の評価結果を、右側の関数呼び出しの先頭の引数にします(最後ではなく先頭)。

ふつうの二項演算子であれば、左右の式は独立した式になります。足し算を例にすると、expr1 + expr2 の 2 つの expr はどちらも単体で成立する式ですよね。F# の expr1 |> expr2 でも、expr2 は単体で成立する関数です。引数を複数取る関数のときは、カリー化を活用して部分適用しておきます。パイプラインで渡したい「プライマリ」の引数は最後なので、これがピタッとハマります。

一方 Elixir の |> の右側は、単体の関数呼び出し式として見ると引数が足らないので、独立していません。よって、Elixir の |>演算子というより構文と言うべきでしょう。

なぜこうなっているかというと、Elixir は先に述べた 3 つのポイントを 1 つも満たしていないためです。中置演算子を自由に増やすことはできず*6、デフォルトのカリー化はなく、主要な引数を最後に受け取る関数群の一貫設計もありません*7。なので、F# のパイプライン演算子の見た目だけを真似たもので、個人的な感覚では完全な別物、という印象です。

とはいえ、実用プログラミング言語の設計においては見た目こそが重要なことであり、言語マニアから見て別物とかは、ユーザ視点ではどうでもいいことです。(皮肉ではなく本当に)

Ruby のパイプライン演算子

最後に、つい先日 Ruby に試験的に導入されたパイプライン演算子の話です。

x |> f |> g |> h # x.f().g().h() と同じ意味
x |> f(args...) # x.f(args...) と同じ意味

|> の左側の式の評価結果をレシーバとして、右の名前のメソッドを呼び出します。

Ruby も Elixir と同じく、前述のポイントを満たしていません。よって、単なる演算子ではなく構文として導入する必要がありました。

Elixir の |> は、左側の式を右側の関数呼び出しの第一引数としました。一方 Ruby|> は、右側のメソッド呼び出しのレシーバとしています。これは、Ruby のメソッドにおける「プライマリ」な引数がレシーバであることを考えると、ある意味で自然な設計です。

しかしこれでは . と大差がありません。何がうれしいかというと、複数行に渡るメソッドチェーンを明示的に書いたり、括弧を省略できたりするというのが挙げられます。

(1..)
.take(10)
.each {|x| p x }

1..
|> take 10
|> each {|x| p x }

下のほうが簡潔でよい、という人がいることを否定することは出来ません。(自分もすごい良いと思ってるのかというと、まあそこまでではないんですが)

一方で問題点もあります。

  • Elixir に慣れた人たちにはわかりにくい
  • 従来のメソッドチェーンとできることが変わらないので意義が乏しい

前者については、自分が知る限りレシーバという概念がある言語にはじめてパイプライン演算子を導入する事例なんですよね*8Ruby は Elixir じゃないし Elixir はオブジェクト指向じゃないんで、「Elixir と違う!」とだけ言ってもしょうがない。また、Ruby では関数的なメソッド(File.joinMath.logなど)はそれほど出てこないので、Elixir に合わせる需要が少ない可能性も考えられます。

後者については、何か有用な使い方が見つかるかも知れないし、見つからなければ matz が消すと思うんで、リリースの 12 月までもうちょっとゆっくり考えてみてもよいのではないかと、個人的には思っています。

しかしこのあたりの問題点の指摘をうけて、現在名称や記号の変更が検討されています。Ruby の「パイプライン演算子」は幻想で終わってしまうのか、生暖かく見守っていきましょう。

まとめ

パイプラインの歴史を駆け足で追ってみました。ほとんど一晩でざっと調べただけなので、なんか間違ってたら教えてください!

追記(2019-06-16) ○○言語の pipeline に言及がない!っていう悲しみの声を聞きます。ここでは Ruby のパイプライン演算子の先祖(と自分が思っているもの)だけを書きました。他の言語にもありますが、まともに調べてないので詳しい言及はせず、知ってる範囲で列挙だけしておきます。

  • Elm と Julia には |> がある。
  • Scala には pipe が最近入った。
  • R には %>% を提供するライブラリ(tidyverse)がある。
  • Racket や Clojure の threading macro も同じような概念。
  • JavaScript では部分適用とあわせて現在検討中。

なお、興味深いことに shell スクリプトのパイプラインは、あまり関係ないかも知れません。というのも、Isabelle/ML の議論では "pipeline" という名前は一回も出てきていません。| という記号も、シェルを意識したという雰囲気は感じられませんでした。F# では "pipe" という名前がついていますが、名前をつけるときにシェルを意識していたかどうかはわかりません。

追記(2019-06-17)

@cedretaber さんの記事がとても参考になります。ざっと書くと

  • Clojure の threading macro
  • BuckleScript/Reason のパイプファースト演算子(Elixir のように先頭に挿入する構文、記号はそれぞれ |.-> らしい)
  • D言語の UFCS(func(a, b, c) という関数呼び出しを、a.func(b, c) と書ける機能)

という感じです。あと Elixir の |> は言語組み込みではなくマクロだそうです(この本文にも注釈を足しておきました)。詳しくはあちらの記事をご参照ください。

qiita.com

*1:できない ML もあるかも。

*2:追記(2019-06-17):話はもうちょっと複雑で、基本は HM 型推論だけど、.NET のライブラリで推論が効かないケースがあるとコメントで教えてもらいました。

*3:この例は The Early History of F# からの引用です。

*4:実行時オーバーヘッドにならないよう、処理系がなにか特別な最適化をしている、という話はどこかで見かけました。

*5:追記(2019/06/17):言語組み込みではなくマクロで実現されているそうです。

*6:あらかじめ用意されたいくつかの中置演算子を自由に再定義することは可能なようです。

*7:逆に、主要な引数を最初に受け取る一貫設計になっているのだと思います。

*8:メジャーな前例があったら教えて!