第一引数版パイプライン演算子

You can read this article in English.

Ruby の trunk にパイプライン演算子が入ったと話題になっていますね。

現在は意見募集中みたいな期間らしいので、思うところがある人は意見を上げると良いのではないでしょうか。

(※ Matz は「余計な混乱を生むから、名前は『チェーン演算子』とかにして記号も変更するよ( >>> とか?)」って言ってますね。 参照

ところでこの「パイプライン演算子」そのものについて、これを期に色々と語られているようですね。

@mame さんの記事がよくまとまっていてとてもわかりやすく読みやすいです。

雑に今回お話する内容に関係ある要点を抽出します。


  • Isabelle/ML が発祥で F# が広めた

  • 本来は「演算子の前の式の結果を、後ろの式の結果に関数適用するもの」

  • なのだけど Elixir はこれを「前の式の結果を後ろの関数の第一引数へ差し込む」という挙動の 構文 として取り入れている

  • Ruby のパイプライン演算子は Elixir 由来

  • Ruby では「前の式の結果をレシーバとして、左の名前のメソッドを呼び出す」という挙動


最初の引数か最後の引数か

Isabelle/ML 及び F# から始まったパイプライン演算子は「前の式の結果を後ろの式に関数適用する」という挙動でした。これは、上記記事にもある通り、自動的にカリー化される(かつ文法上、関数適用の優先度が演算子より高い) ML 系言語の特性と合わせると、自ずと「前の式の結果を後ろの関数呼び出しの最後の引数として適用する」という意味になり、様々な利点を生み出すことができたわけです。

また F# や OCaml のような言語の場合、この演算子を、そのための専用機能を用意することなく、単なる独自定義演算子という形で表現できました。

(実際には、余計な関数呼び出しによるパフォーマンスの低下を防ぐために、ちょっと特殊なことをしているかもしれません。)

一方で Elixir のパイプライン演算子は「前の式の結果を、後ろの関数に第一引数として差し込む」という挙動を行います。この「最初か最後か」の違いには実用上の理由があるのですが、ここでは詳しく述べません。

問題は、「関数の最初の引数として差し込む場合、言語機能として組み込む必要がある」という点です。

これはまぁ嘘で、例えば Clojure とかは同じような挙動をマクロを利用することで行っているのですが、 AST を組み替えないと再現できない機能である、という点は変わりません。

何故かと言うと、自然とカリー化される環境において「後ろの引数が欠けた関数適用」は単体で正当な式なのですが、第一引数が欠けた関数適用は、不正な式だからです。

そういうわけで、単なる関数であったパイプライン演算子は、特殊な構文となったのでした。

追記 : コメントで指摘されましたが、 Elixir のパイプライン演算子も Clojure と同じくマクロでした。ただ、上述の通り「 AST を組み替えないと実現できない」という点が重要です。


色々な言語のパイプライン演算子

Ruby のパイプライン演算子は更に異なる意味を持っていて「前の式の結果をレシーバとして、演算子の後ろのメソッドを呼び出す」という挙動をします。つまり、実質的に優先順位の低い .:: ということです。

元の F# とかのパイプライン演算子から見るとかなり奇矯な動きに見えるかもしれませんが、そもそも Ruby が元にした Elixir のパイプライン演算子からして F# のそれとは大きく違っているので、世の中は移り変わっていくということでしょうか。

さて、 F#, Elixir, Ruby のパイプライン演算子を例に出しましたが、パイプライン演算子を持つのはこれらの言語だけではありません。かつ、「第一引数として差し込む」挙動を行う言語も、 Elixir 以外に存在するのです。

今回はその、「第一引数として差し込む」挙動を取るパイプライン演算子を持つ言語と、その使われ方を紹介したいと思います。


Clojure のスレッディングマクロ

いきなり演算子ではなくマクロで恐縮なのですが、同じような気分で利用されるので挙げさせていただきます。

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}

マップを受け取り、 :hair-color を追加した上で、 :age を1つあげる、という処理でしょうか。

Clojure の assoc 関数は (assoc map key val) というシグネチャで、第一引数のマップに値を追加する関数。 update(update map key f) というシグネチャで、値を更新する関数ですね。

複数個の関数がネストしてしまっていて読みづらいと思われる方が多いかと思います。また、コードに変更を加える時も苦労しないといけないでしょう。

同等の処理を、 Elixir で書くと次のようになるでしょう。

def transform(person), do:

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

こちらもやはり、可読性に欠け修正し辛いと言わざるを得ません。

これが、スレッディングマクロを使うと次のようになります。

(defn transform* [person]

(-> person
(assoc :hair-color :gray)
(update :age inc)))

Elixir でもパイプライン演算子を使って書き直してみましょう。

def transform(person), do:

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

見比べていただくと、どのような形に変換されているかはわかるかと思います。

パイプライン演算子とは違い、 Clojure のスレッディングマクロは連続する処理の最初に一度だけ使います。マクロの最初の引数に対象となる値を与え、その後ろに、処理対象の値を第一引数に取る関数を任意の個数並べます。

すると、マクロが処理を並べ替え、各処理の結果を次の処理へ、第一引数として挿入してくれます。

Clojure には第一引数に差し込むマクロ( thread-first macro )以外にも、最後の引数に差し込むマクロ( thread-last macro, ->> という記号)や任意の場所に差し込むマクロ( thread-as macro, as-> )が存在し、処理に合わせて適切なものを利用することができます。

何故「第一引数として差し込むマクロ」と「最後の引数として差し込むマクロ」との両方が用意されているかといいますと、 Clojure の組み込みの関数には、シーケンスを扱う関数はシーケンスを最後の引数として受け取り、コレクションなどのデータを扱う関数はそのデータを最初の引数として受け取る、という慣例があるからだそうです。

なので、利便性のためには両方に対応する必要があったのですね。

繰り返しますが、 Clojure のスレッディングマクロは、言語組み込みの特殊な機能ではなく単なるマクロです。専用の構文などではなく、言語に元から備わった機能だけを利用して作られています。

これは Clojure に限った話ではなく、他の Lisp 系の言語でも、スレッディングマクロを持つものはあります。

Lisp という言語の単純さと拡張性の高さが為せる業であり、他の言語だとこうはいきません。


BuckleScript/Reason のパイプファースト演算子

BuckleScript は OCaml コンパイラを改造して作られた、 OCaml を JavaScript に変換するコンパイラです。そういう意味で OCaml とほぼ同じ言語なのですが、今回紹介するパイプファースト演算子のような独自拡張も入っています。

Reason は JavaScript に近い文法を持つ、 OCaml にコンパイルされる言語です。 Reason は BuckleScript とセットで作られており、 Reason 言語から OCaml(BuckleScript) に変換され、そこから JavaScript へと変換されるという運用を取られます。

言語としてはほぼ OCaml と互換性があり、 OCaml と同じ型システムを用います。強力な型推論と型安全性を持ち、かつ破壊的変更なども許容する実用性の高い言語です。

元々が OCaml なので、 F# 式の「前の式の結果を後ろの式の結果に関数適用する」タイプのパイプライン演算子も入っています(これは F# から OCaml へ逆輸入されたものです)。 F#/OCaml と同じ |> という記号で利用できます。

一方で、第一引数に値を差し込むタイプのパイプライン演算子も存在します。それが「パイプファースト演算子」です。

BuckleScript: Pipe First

Reason: Pipe First

公式サイトにも "pipe syntax" と書かれている通り、こちらは単なる演算子ではなく文法です。上述の通り、「第一引数として差し込む」タイプのパイプライン演算子は、言語の元々の機能だけでは実現できません。

さて、見てみましょう。

せっかくなので上記の Clojure の例を参考にサンプルコードを書いてみます。

let transform person =

updateAge (setHairColor person `gray) ((+) 1)

これをパイプファースト演算子で書き換えます。こうなります。

let transform person =

person
|. setHairColor `gray
|. updateAge ((+) 1)

BuckleScript のパイプファースト演算子は |. です。

次に Reason でのサンプルコードを見てみましょう。

let transform = person =>

updateAge(setHairColor(person, `gray), (+)(1));

こうなります。

let transform = person =>

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

Reason でのパイプファースト演算子は -> です。

Elixir のものと同じなので、機能については特に言うことはありません。ここでは、何故既に「最後の引数として差し込むタイプのパイプライン演算子」が存在する言語に、「最初の引数として差し込むタイプのパイプライン演算子」が追加されたのか、について述べましょう。

上で述べたとおり、 BuckleScript/Reason は JavaScript に変換される言語です。そして設計思想として、 JavaScript との連携を重視しています。 OCaml のコードの中で JavaScirpt にて定義された関数を利用できる必要があります。

しかし、 OCaml と JavaScrip では型システムが全く異なるので、型定義などをそのまま持ってくることができません。そこで、 OCaml 側で JavaScript の関数に型を付けることで、 JavaScript の関数を OCaml の世界に持ち込んでいます。

function transform(person) {

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

type person

external transform: person -> person = "transform"

単なる関数であれば問題ないのですが、メンバ関数、つまりメソッドの場合は少し厄介です。とはいえメンバ関数は、暗黙の this を引数として受け取る関数と考えることができます。

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

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

ここで出てくる [@@bs.send] は BuckleScript で導入されるアノテーションで、これを external に付けると、その関数を「第一引数の式に対するメソッド呼び出し」と見なして interop を行ってくれます。

つまり、

let person2 = transform person1

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

はそれぞれ

var person2 = transform(person1);

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

と変換されるのです。

[@@bs.send] の無い transform 関数が通常の関数に変換されているのに対して、 [@@bs.send] を付けた map 関数は第一引数をレシーバとしたメソッド呼び出しに変換されています。

ここで問題(?)になるのは、メソッド呼び出しに変換される関数は、その関数の処理の中心となる値、いわゆる「プライマリ」な引数を、第一引数として受け取るということです。

これは、言うまでもなく既存の OCaml のパイプライン演算子とは相性がよくありません。

(* 無理やり使ってみる *)

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

そこで、パイプファースト演算子が導入されました。

これを使えば次のように書くことができます。

let result = [|1; 2; 3|]

|. map (fun a -> a + 1)
|. filter (fun a -> a mod 2 = 0)

これはまるでメソッドチェーンですね。

そのように見えるのも当然で、このパイプファースト演算子は「メソッドチェーンを模すために」導入されたのです。

メソッド呼び出しがある JavaScript との連携の必要性から導入されたのが、 BuckleScript/Reason のパイプファースト演算子なのです。


D言語の UFCS

D言語には Uniform Function Call Syntax という機能があります。

これはどういうものかといいますと、「関数呼び出しをメソッド呼び出しの見た目で書ける」というものです。

どういうことでしょう。コードを見てみましょう。

Person transform(Person person)

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

これが、こうなります。

Person transform(Person person)

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

D言語の UFCS とは、 func(a, b, c) という関数呼び出しを、 a.func(b, c) と書いても良い、という機能です。普通の関数をメンバ関数(メソッド)のように扱えるのです。

function: UFCS

DLang Tour

この UFCS が活きてくるのはD言語のテンプレートと併用する場合です。

例えばDのコレクション系ライブラリは、「レンジ」と呼ばれる抽象的な対象に対して作られています。この「レンジ」は特定の class や interface を継承しているわけではないので、コレクション系の操作はメソッドではなく関数として提供されています。

しかし、コレクションに対する操作はチェーンしたい場合が多いものです。

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

これが、 UFCS を利用すると、

[1,2,3]

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

のように書くことができます。

機能としては「関数呼び出しをメソッド呼び出しに見せかける」というものですが、その根っこのところには、パイプライン演算子と同じく引数と関数の順序を入れ替えることで可読性を上げようとする考えがあります。

(公式サイトでは、「既存のクラスに対してメソッドを後付けできる」という点が強調されていますが。)


まとめ

Elixir と同じ「左側の式の結果を、右側の関数呼び出しの第一引数として挿入する」という挙動をするパイプライン演算子を提供する言語を集めてみました。

いずれも、オブジェクト指向言語と関係の深い言語であるのは興味深いですね。 BuckleScript/Reason 及び D言語は、明確にメソッド呼び出しを模倣する為にこの機能を入れています。

処理の一番の関心対象たる値、「プライマリ」な引数を第一引数として取るか最後の引数として取るかは、言語の性質や文化に左右されるので、パイプライン演算子における、左側の値が右側の式のどの位置に来ると嬉しいのかという問題について、これを決めつけることは難しいのだと思います。

実際、 Clojrue や BuckleScript/Reason のような「どちらにも来得る」言語は、利便性の為に両方のタイプのパイプライン演算子を用意しています。パイプライン演算子は( F# の型推論のように例外はありますが)基本的に「見た目を良くする為」に導入している言語が多いので、最初の引数として差し込むか、最後の引数として差し込むか、は本質的な問題では無いのだと思います。

とはいえ、 Ruby におけるパイプライン演算子の「右側の値を使って左側の式の結果のメソッド呼び出しを行う」という挙動はちょっと他に類を見ないものだと思うので、実際使われてみるとどのような感想を得られるのか、気になるところではありますね。

( BuckleScript/Reason のパイプライン演算子は、事実上それに近い挙動を行わせるために導入されたものではあるのですが、 Ruby の場合は既に . を使ったメソッド呼び出しができる上で、更に同じような機能の演算子を導入しようとしているという違いがあります。)