MEP 22: パイプライン演算子 を実装したという話。

CIE XYZカラーへの変換を実装していて、複数変換を呼びたくなる事がちょくちょくあった。

  let col = gamma2linearA(to_ncolor(inputEx(*pxy)))

こういうのは以下にもパイプ演算子が欲しい。

実装したもの

上記のものを、以下のように書き直せるようにした。パイプ演算子は |> を採用。

  let col = inputEx(*pxy) |>
            to_ncolor(...) |>
            gamma2lienarA(...)

これはv1.0.01から使える予定です。

パイプ演算子自体はRやOcamlといった言語に馴染みがあれば見慣れたものだと思いますが、 ... というのは他の言語では無い要素に思うので、なぜMFGではこれがあるのか、という話をしてみたい。

そのためには、そもそも他の言語ではなぜ必要無いのか?という事を見てみるのが良いと思う。

他の言語に見る、パイプラインの引数の指定

パイプラインというのは、その右辺の関数呼び出しのどこかに左辺の式を入れる、という機能になっている。 ようするにもともとF(a, b, c)という呼び出しがあった時に、このcだけを外から入れる機能だ。

概念的には、F(a, b, c)を以下のように書けるのがパイプ演算子といえる。

c |> F(a, b)

パイプ演算子が左辺の値を右辺の引数の最後に追加している。

パイプ演算子を言語がサポートする場合、2つの部分に着目すると良い。

  1. パイプ演算子が入れる引数はどこになるか(上記の場合は三番目の最後の引数になっているが、Rなどは1番目の引数になる、言語によって違う)
  2. 右辺の呼び出しが不完全になる問題にどう対応するか?

1に関して。だいたい引数の最後に追加するか、最初に追加するかのどちらかになる。 ML系列は最後、R(のdplyr)は最初に追加する。

2に関して。F(a, b)というのは、本来3引数の関数を2引数で呼び出している。 本質的にパイプ演算子の右辺は引数が一つ足りない状態で呼び出す事になるので、 呼び出しとしては必ず不完全な引数での呼び出しになる。 その時に右辺がシンタックスとしてどう合法的にするか、という問題がある。

この2つに着目してパイプ演算子の仕様を見ると、その言語の特徴がわかる。

ML系列の言語の場合、部分適用

F#では、関数呼び出しの引数が足りていない時は部分適用により右辺が1引数の関数呼び出しになる事を利用している。 だからパイプ演算子は右辺の1引数関数を左辺の値を引数に呼び出す事になる。

以下のようにあれば、

a |> F a b

Fは型としてはa, b, cの型を引数に取る関数で、 右辺のF a bがcの型を引数に取る1引数関数になるため、どこにcを渡すかという事に曖昧性は無い。

また、Fはもともとa, b, cの3引数を取る関数だが、これを2引数、F a bで呼ぶ事は言語として全く問題無いため、 不完全な呼び出しをどうパースするか、という問題が無い。

そして右辺がaとbを適用した結果の残りの引数となるので、Fとしてはいつも最後の引数がパイプラインによって渡される引数となる。 |>は単なる二項演算子で、特別な言語機能がある訳では無い。

言語仕様の部分適用とうまくマッチした概念といえる。

Rのdplyrの場合

Rのdplyrでは、以下の2つの呼び出しが同じ意味になる。

a |> F(b, c)
F(a, b, c)

パイプ演算子で渡される引数は、最初の引数として渡される。 これはML系列の言語が最後の引数になるのとは正反対だ。

しかも、右辺単体のF(b, c)は、引数の型が合っていないので、関数呼び出しとしては不正である。

RはLispなどに似ていて、関数の評価前のツリーを取り出す事が出来て、 dplyrも右辺の式を評価前のツリーとして取り出して左辺の値を追加して呼び出しを行っている(たぶん)。

これは他の言語に慣れた人だとかなり気持ち悪い機能に見えるかもしれないが、 Rでは、同様の仕組みを使ってplotのラベルに式の文字列表現を使ったり、といった事はイディオムとして普通に行われていて、 関数が呼び出し元のツリーをいじるのは頻繁に行われている。

当然こんな事をみんながやるとカオスになるが、dplyrは最初の引数を全てdataframeに統一する、 といった縛りを入れて一貫して使うようにしている。 コンベンションで縛るのがR的である。

ここでF(b, c)がツリーとしては取り出せるという所にも注目すべき所がある。 これはRに静的型づけでのパースエラーが無いから可能になっている。

実行されなければこうした型エラーは問題が無いのでツリーが取り出せて、 それを変形して最終的なツリーで型の整合性が取れていれば問題が無いから実現出来ている。

Rの静的型づけのなさと評価前の式を取り出して変更する機能とうまくマッチした概念になっている。

MFGの場合のプレースホルダー

さて、MFGは部分適用が無く、Rのように評価前の式をアクセスして変形する、 という機能も無い。

パイプ演算子の右辺が、単体でパース可能である必要があるので、 パイプ演算子が突っ込む引数に、何かを書く必要がある。

c |> F(a, b, ?)

この ? の部分に何かを置く必要がある。これはRやML系列の言語には無い問題だ。

という事で上記の言語には無い何かを置かなくては行けない理由というのは、 関数呼び出しのシンタックスから自然に結論づけられる。

問題は何を使うか?という所だ。

候補としては

  1. ...
  2. _
  3. それ以外の何か

の三択くらいになると思う。 今回は1を選んだ訳だ。

だからMFGとしては以下のように書く。

c |> F(a, b, ...)

なぜ...を選んだか、という話を以下にしていきたい。

MFGにおける、... というシンタックスシュガー

そもそもMFGには以前から...という機能があり、 それと似ているからパイプラインの時のプレースホルダーに選ばれた。

という事でそれ以前の ... について見ていきたい。

もともとMFGにはifelのネストを避けるために ... という機能がある。 これは、関数呼び出しの最後の引数に使う事が出来て、 この ... がある時は引数をそのカッコの外の次の式から使う、という機能になっている。

ようするに、以下の2つは同じ意味となる、という機能だ。

F(a, b, c)

F(a, b, ...) c

最後の引数が外に置ける、というのは、最近の言語の、ラムダ式が最終引数の場合には良く見られるシンタックスシュガーで、KotlinやSwiftなどに存在している。 (実はMFGも最後がブロック引数の場合は内部的には最後の引数のブロックは外に置けるというシンタックスシュガーとして振る舞っているが、最後以外にブロック引数があるケースが無いため、ブロックがたかだか一つだけで外に指定するというシンタックスの言語と見て差し支えない)。

それの拡張というか類似機能として、...を使うとブロック以外の式も最後の引数を外に置く事が出来る、という事になっている。

これは、典型的にはifelで使うもので、以下のようにifelがネストしているものを(elifはifelのエイリアス)、

ifel(i == 0,
     val0,
     elif( (i%2) == 0,
           valEven, valOdd))

...を使って以下のように書ける。

ifel(i == 0,
     val0,
     ...)
elif( (i%2) == 0,
    valEven, valOdd)

これは、

  1. iが0ならval0を
  2. iが偶数ならvalEvenを
  3. それ以外ならvalOddを

を返す、というふうに、ネストを意識せずに上から順番に条件を読む事を可能にする。

なお、本題と外れるが、引数をただ返すだけの特別な関数、else を使えば、以下のようにも書ける。

ifel(i == 0,
     val0,
     ...)
elif( (i%2) == 0,
    valEven, ...)
else(valOdd)

これは通常の言語のifelのように読める。

なお、 ... はifel以外でも実は任意の関数で使える。 さらにこれは単なるシンタックスシュガーで、パース時にIRを変更するだけなので、 最終的なIRには残らない。

だから冒頭に挙げた以下の式は、

  let col = gamma2linearA(to_ncolor(inputEx(*pxy)))

パイプ演算子を使わなくても以下のように書ける。

  let col = gamma2linearA(...)
            to_ncolor(...)
            inputEx(*pxy)

これでは何が不十分だったのか、というのをまず見てみよう。

もともとの ... とパイプ演算子の違い

さて、パイプでない版とパイプ版を比べてみよう。

  # 旧来の...のみ版
  let col = gamma2linearA(...)
            to_ncolor(...)
            inputEx(*pxy)

  # パイプ演算子版
  let col = inputEx(*pxy) |>
            to_ncolor(...) |>
            gamma2lienarA(...)

関数の順番が逆になる事がわかる。

処理としては、以下の作業を行っている

  1. pxyにあるinputのBGRAを取り出して
  2. 0.0〜1.0にノーマライズして
  3. ガンマ補正する

(ちなみにこれを一気に行うto_lbgraという関数がv1.0.01から導入されたので、今後はこの処理はもう一つ呼び出しを減らす事は出来るようになった)

パイプ演算子では、この処理を行う順序に書く事が出来る。 一方で旧来の並び順では、これを下から逆に読んでいく必要がある。

  1. 下の結果にガンマ補正する
  2. 下の結果を0.0〜1.0にノーマライズする
  3. pxyにあるinputのBGRAを取り出す

コードを書いていて、最初に1を書くのは難しい。 頭の中でパイプ演算子の順番にやりたい事を一旦考えて、 それを逆順に書く必要がある。 これが結構脳に負荷が掛かる。

パイプ演算子は処理を行う順に書く事が出来るので、書きやすく読みやすい。

... のシンタックス上の役割の類似性

さて、順番が逆になるので違う機能ではあるが、...はプレースホルダーとして既に使われているもので、 パース時の振る舞いにも類似性がある。

  1. 任意の型とマッチする
  2. その式の外の値に置き換わる
  3. 最終的なIRには残らない

置き換わる対象が違うだけで、呼び出し時に使えるプレースホルダーでパースの途中で置き換わる一時的なもの、 という性質は同じだ。 実際パース時に一時的なツリーを作る処理は今回一切変更は無く、 置き換える所だけパイプ演算子の右辺かどうかで変えている。 置き換える前の時点では全く同様、というのが...をこちらでも使うjustificationになっている。

一方 _ は既に使われているが、これは仮引数やdestructuringなどで使わない変数のためのプレースホルダーで、 左辺値として使われる。 呼び出しのプレースホルダーとは違う。 シンタックス的な役割としてはだいぶ異なるものなので、これを使うのには抵抗があった。

このどちらでも無い新しい構文要素を作るのは考えたし、 そちらの方が良い可能性もあるけれど、あまり字句要素を増やしたくなかったので今回は見送った。 使われる記号が多い言語は、なれると良いけれど、ぱっと見た時に拒否反応が出るという印象があるので、 大衆向けを目指しているMFGとしてはあまり記号を増やしたくはなかった。

MFGにおけるパイプ演算子とは

ここまで理解するとMFGにおけるパイプ演算子とは何か、 という事も説明出来る。

MFGにおけるパイプ演算子とは以下のようなものである。

  1. 二項演算子(優先度は>などと同じにしてある)
  2. 右辺の式の...を、次の式では無くパイプ演算子の左辺の式に変更するもの

まとめ

  1. MFGでパイプ演算子を実装したよ
  2. パイプ演算子は右辺の呼び出しが不完全になる問題をどう扱うかで言語ごとの特徴が出るよ
    • MFGは...というプレースホルダーが必要になるよ
  3. パイプ演算子は ...の振る舞いをコンテキスト内でだけ変更する何かだよ