ふと、「Identity Functorってパイプライン演算子っぽい」と思ったんですよ。何言ってんだって感じですが。とりあえず、JavaScript で下記のような関数を定義するとパイプラインっぽいことができるんじゃないかと思った次第です。
const wrap = x => ({
unwrap: x,
map: f => wrap(f(x)),
tap: f => wrap((f(x), x))
});
tap と unwrap がある理由
Identity Functor にtapは不要です。Scala がpipe(上のmapに対応するメソッド)とともにtapも導入したことを参考に入れてみました。副作用が身近な言語ならtapは確かに便利ですね。
Scala では、pipe/tapを呼び出すと暗黙の変換が発動して元の値がChainingOpsに包まれます。また、pipe/tapの戻り値はChainingOpsに包まれていません。そのため明示的に包んだり、包装をほどいたりする必要はありません。それに比べると、上の JavaScript の実装は明示的なwrap/unwrapが必要になります。なぜなら、暗黙的な変換はされませんし、map/tapの戻り値が包まれた状態で返されるからです。
余談ですが、tapは Ruby が源流です。逆に言うと Ruby のthenはパイプラインっぽいかもしれませんね。
map をパイプラインとして使ってみる
次のコードの「通常の関数適用」と「map を使った関数適用」の処理結果はどちらも202になります。
const addOne = x => x + 1;
const timesTow = x => 2 * x;
// 通常の関数適用
timesTow(addOne(100));
// map を使った関数適用
wrap(100).map(addOne).map(timesTow).unwrap;
mapを使うと関数適用の入れ子が解消され、処理の順にそって関数を左から右に書けています。パイプラインっぽいことは十分できていますね。
thisを考慮しなければ、fn(…(f1(f0(x))…)の形の関数適用の入れ子は一般に、wrap(x).map(f0).map(f1)….map(fn).unwrapへ書き換え可能です。
tap を使ってみる
上の map 使用例のコードにtapを挟み込むと、処理途中の結果をコンソールに表示できます。具体的に、処理結果は202で変わりませんが、コンソールには100, 101, 202と途中結果が表示されていきます。
const addOne = x => x + 1;
const timesTow = x => 2 * x;
// tap を使って処理の途中結果をコンソールに出力
wrap(100) .tap(console.log)
.map(addOne) .tap(console.log)
.map(timesTow) .tap(console.log)
.unwrap;
一次変数を用意したり、引数をそのまま返す関数を用意すれば通常の関数適用でもtapと同じ事はできるかもしれません。ただ、tapの方がコードの追加は容易でしょうし、不要になった際の除去も簡単でしょう。
ちなみに、応用すると下記のようなおどろおどろしい FizzBuzz を書くこともできます。
const toFizzBuzz = n => wrap(n)
.map(num => ({num, str: ''}))
.tap(x => {
if(x.num % 3 === 0)
x.str += 'Fizz';
})
.tap(x => {
if(x.num % 5 === 0)
x.str += 'Buzz';
})
.tap(x => {
if(x.str === '')
x.str += x.num.toString();
})
.map(x => x.str)
.unwrap;
関数合成
使いどころは不明ですが、同じような考えで関数合成のみを扱うオブジェクトも生成できます。
const unary = (f = x => x) => ({
run: f,
mappend: g => unary(x => g(f(x))),
tappend: g => unary(x => {
const y = f(x);
g(y);
return y;
})
});
const addOne = x => x + 1;
const timesTow = x => 2 * x;
const compose = unary()
.tappend(console.log)
.mappend(addOne)
.tappend(console.log)
.mappend(timesTow)
.tappend(console.log)
.run;
compose(100);
型付け
TypeScript で型を意識するなら次のようになるでしょうか。wrapは素直に型が付きますね。unaryは、型が合わないためデフォルト引数を設定できません。別途、emptyUnaryを用意すれば、同じようにemptyUnary()を起点に関数を合成していく事ができます。
type Identity<A> = {
unwrap: A,
map: <B>(f: (x: A) => B) => Identity<B>,
tap: <U>(f: (x: A) => U) => Identity<A>
}
const wrap = <A>(x: A): Identity<A> => ({
unwrap: x,
map: f => wrap(f(x)),
tap: f => wrap((f(x), x))
});
type UnaryBuilder<A, B> = {
run: (x: A) => B,
mappend: <C>(g: (x: B) => C) => UnaryBuilder<A, C>,
tappend: <U>(g: (x: B) => U) => UnaryBuilder<A, B>
}
const emptyUnary = <A>(): UnaryBuilder<A, A> => unary(x => x);
const unary = <A, B>(f: (x: A) => B): UnaryBuilder<A, B> => ({
run: f,
mappend: g => unary(x => g(f(x))),
tappend: g => unary(x => {
const y = f(x);
g(y);
return y;
})
});
おわり
まあ、ごちゃごちゃ言ってないで、パイプライン演算子|>が使える環境なら、それを使えばいいですし。使えなくても次のような関数を定義すれば十分ですね。
const pipe = (x, ...fs) => fs.reduce((v, f) => f(v), x);