前置き
Rubyで一瞬だけ湧いて出てきて消えたPipeline風演算子については忘れてください。あれはメソッド呼び出しの演算子であって今回取り上げるPipeline operatorとは似て非なるものです。
JavaScripterでも、Ramda.jsやRxJSを普段から使っている人には既知の内容だと思うのでこの記事は読まなくて大丈夫です。
ちなみに僕のステータスは関数型にわか勢なので認識に間違いがあったらなんらかの訂正もらえるとうれしいです。
Pipeline operatorとは
古くはML系の言語で定義されてF#やElixirの流行で一般に有名になった演算子こと |>
です。
f a
を a |> f
と書けるようになります。
詳しくはこちらで: https://mametter.hatenablog.com/entry/2019/06/15/192311
覚えた方がいい理由
TypeScriptとの相性が良いからです。
なぜTypeScriptとの相性が良いのか
マルチパラダイムな言語であるJavaScriptに柔軟で高機能な型を提供するTypeScriptは様々な書き方が可能となっています。
それによってコミュニティの流派もクラスベースOOPから関数型まで存在するのですが、このPipeline operatorは関数型よりのスタイルを好む人によりフィットする機能になります。
逆にクラスベース好き一派には刺さらないんじゃあないでしょうか。
高階関数の型推論をさせやすくなる
様々な言語で実装されがちな、Result型を操作する関数を例にあげます。
詳細は述べませんが、TypeScriptにはTagged Unionと呼ばれる便利なテクニックがあります。このテクニックによって代数的データ型を模倣できるのですが、Extension Methodsという機能を持つC#と違ってTypeScriptは例えシンタックス上であっても型に実装を追加することができません。
なのでTagged Unionに振る舞いを定義するには別途関数として宣言する必要があります。
export enum ResultStatus { Success = 'success', Failed = 'failed', } type Success<V> = {type: ResultStatus.Success; value: V}; type Failed<E extends Error> = {type: ResultStatus.Failed; value: E}; export type Result<V, E extends Error = Error> = Success<V> | Failed<E>;
ML系でいうFunctorを実装したい場合は以下のような関数が必要になります。
namespaceを使っているのはResultの定義を拡張して Result.map
と呼び出せるようにするためです。babelでTSをコンパイルしている人は設定が必要になるので注意。
export namespace Result { // ... /** * Haskell風に書くなら * instance Functor Result where * fmap func (Success a) = Success func a * fmap func (Failed a) = Failed a * * ほら、大体同じでしょ? */ export function map<X, Y>(op: (x: X) => Y) { return (arg: Result<X>): Result<Y> => { switch (arg.type) { case ResultStatus.Failed: return arg; case ResultStatus.Success: return { type: ResultStatus.Success, value: op(arg.value), }; } }; } }
mapの定義がカリー化されているのは、メソッドチェインの変わりに関数合成できるようにするため(Pipelineの話につなげるための伏線)です。
これで、Resultの結果を操作したいときは
const r: Result<X> = readFileContent('~/Downloads/dog.txt'); const transformer = Result.map(t => 'content: ' + t); const transformed = transformer(r);
のように書ける……わけではありません。
TypeScriptの型システムではどう書いても const transformer = Result.map(t => 'content: ' + t);
の操作対象が Result<string>
であることを認識できず、anyに落ちてしまいます。
変数の使われ方から逆算したt推論ができる言語であれば、 content: ' + t
の部分でtがstringだと推論できます(ML系とか?)が、TypeScriptの場合は Result.map((t: string) => ...)
のように明示しなくてはなりません。
でもいちいち型を書くとかそんなダルいことしたくない……全部推論してほしい!
そこでPipelineです。
しかしPipelineはいまだstage 1で仕様策定に揉めている機能なのでTypeScriptで使うことはできません。なのでminimalな仕様をエミュレートする物を入れ(作り)ます。
class Pipeable<Arg> { constructor(private readonly x: Arg) {} public pipe<R>(op: Operator<Arg, R>): R; public pipe<X, R>(op: Operator<Arg, X>, op2: Operator<X, R>): R; public pipe<X, X2, R>(op: Operator<Arg, X>, op2: Operator<X, X2>, op3: Operator<X2, R>): R; public pipe<X, X2, X3, R>(op: Operator<Arg, X>, op2: Operator<X, X2>, op3: Operator<X2, X3>, op4: Operator<X3, R>): R; public pipe<X, X2, X3, X4, R>( op: Operator<Arg, R>, op2: Operator<X, R>, op3: Operator<X2, R>, op4: Operator<X3, R>, op5: Operator<X4, R>, ): R; public pipe<R>(...ops: ReadonlyArray<Operator<any, R>>) { const op = (arg: Arg) => ops.reduce((prev, op) => op(prev) as any, arg); return op(this.x); } } export function functional<X>(x: X): Pipeable<X> { return new Pipeable(x); }
これによって、仮にResultへの操作がチェインされても以下のように書けるようになりました。
const r = readFileContent('~/Downloads/dog.txt'); functional(r) .pipe( Result.map(t => t.split('\n')), Result.map(numOfLines => `file has ${numOfLines} of lines`), Result.map(msg => `!!!! ${msg}!!!!`), ); // Pipeline operaorがきたらこう書ける r |> Result.map(t => t.split('\n')) |> Result.map(numOfLines => `file has ${numOfLines} of lines`) |> Result.map(msg => `!!!! ${msg}!!!!`)
型注釈なしでちゃんと動作していますね?
TypeScriptの型システムは前提条件からの推論しかできないのですが、 r: Result<Text>
という前提条件(この場合は引数)が先に出現するPipeline operatorは、このようなTypeScriptの弱点を補って動作させることができるようになるため、ある意味本家のML系言語よりも導入の恩恵が大きいかもしれません(要出典)。
OCamlなどでpipeがうまく働いているのは、operator単体の素晴らしさもそうですが上手く統一されている標準ライブラリのコーディングルールなどの環境もその一翼を担っているのだとおもいます。
ECMAScriptのpipeline規格はまだ前提となる方針のところから議論されている段階なので、TypeScriptで本格的に導入されるのは当分先になるでしょうし、導入されても歴史的カオスにまみれたジャバScriptワールドでどの程度ワークしてくれるのかは未知数です。
しかし似非関数型一派を自称する僕としては、この演算子が入ってくれると非常に愉快なことになるなぁとか、そんなところです。
以上。
補足
世の中にあるモナドなろう系TypeScript記事はclassの継承で再現する派が圧倒的に多いようです。
なのでそもそも今回前提としたResultの実装に疑問を抱いた人が多いかもしれません。というか↑の方式で記事書いてる人みたことない。
念のため継承で再現するとこんな感じですかね。
class Success<T> { constructor(private x: T) {} map<U>(func: (x: T) => U): Success<U> { return new Success(func(this.x)); } } class Failed { constructor(private x: Error) {} map<U>(_func: (x: any) => U): Failed { return this; } } type Result<T> = Success<T> | Failed; // こんな感じになる? r .map(t => t.split('\n')), .map(numOfLines => `file has ${numOfLines} of lines`), .map(msg => `!!!! ${msg}!!!!`),
上の書き方の優位性は何もなしで普通にメソッドチェインで簡単に書けること、 interface Functor<T>
のような型クラス(的なもの)を提供できることでしょうか。
しかしTagged Unionでは、Switch caseの条件式にかけたときにTypeScriptコンパイラが網羅性をチェックしてくれるという優位性があります。
Result型程度だと恩恵はないのですが、例えばElmでよく使われるRemoteData型などをTypeScriptでエミュレートしようとする場合などはSwitch caseの網羅性チェックの恩恵を嬉しく感じることが多いでしょう。
どちらも良し悪しだと思うのですが、擬似Pipeを導入することでそれなりに書き味もよくできるので今回は後者の方式を採用しました。