Proxy "it": アローすら面倒な怠惰JavaScripterのための遅延評価

  • 3
    Like
  • 0
    Comment

TL;DR

遅延評価で
これ data.map(row => row.values.split(',')[2].trim().map(col => col.trim()).map(parseFloat)[2]);
が
こう data.map(it.values.split(',').map(it.trim()).map(parseFloat)[2]);
なる

はじめに

何をしたいのか

JavaScriptを書いていると、コールバック的な関数利用でプロパティ参照/メソッド呼出をする場合など、引数の定義と参照で2度同じ変数名を書かなければいけないことが多々あります。

// 例1 カンマ区切りのテキストデータを2次元配列にする
text.split('\n').map(line => line.split(','));

// 例2 カンマ+スペース区切りのテキストデータを2次元数値配列にする
text.split('\n').map(line => line.split(',').map(col => col.trim()).map(parseFloat));

// 例3 fetchの後続処理(今時はasync/awaitするけど…)
fetch(url).then(response => response.json()).then(...);

たかがそれだけといえばそれだけなのですが、さらさらとプログラムを書いている時に、同じキーワードを二度連続で入力したり、比較的運指負荷の高い=>などという文字列を入力するのはささやかながらも大変なストレスです。

本音としては単純にarg => argの部分をゴリッと削ってしまいたい(例えばtext.map(.split(','))等)わけですが、それでは何がなんだかわからなくなるので、引数を暗黙にitと受けて書けたら良いなあとなります。

text.split('\n').map(it.split(','));
text.split('\n').map(it.split(',').map(it.trim()).map(parseFloat));
fetch(url).then(it.json()).then(...);

「遅延評価」を実装することで、そんなitを実装してみましょう(実用性の低いネタです)。

先に完成図

var It = response => new Proxy(new Function, {
  get: (_,property) => It(entity => {
    const base = response(entity);
    return (typeof base[property] === "function") ? base[property].bind(base) : base[property];
  }),
  apply: (_,that,[...args]) => {
    if (typeof that === "undefined") return response(args[0]);
    return It(entity => response(entity)(...args));
  }   
});
var it = It(entity => entity);

あとは解説をするだけなので、これをみて「なるほどね〜」となった方は最後までお読み頂きありがとうございました。

解説

とりあえず実装してみる

関数呼出を遅延評価っぽくする

まずit.split(',')等という配列になりそうな式が、「引数を受けてそれをsplitする」という関数にならなければいけません。なんてことはありません。it.splitが「<区切り文字>を引数に受けて「引数を<区切り文字>で分割する」という関数を返す」関数であれば良いわけです。まどろっこしくなりましたがコードを見れば一発です。

var it = {
  split: delimiter => target => target.split(delimiter)
};

it.split(',')('区切ら,れる,文字列'); //=> ['区切ら','れる','文字列']

単純なようでここに遅延評価の本質が隠れています。「評価結果」ように見せて「評価をする関数」を用意することで、その関数を呼び出すまで実際の評価を後回しにすることができるのです。そして「「評価をする関数」を用意する」関数、ある種の高階関数ですが、これは2段階の(アロー)関数で実装可能です。以下、同様にit.trim()it.json()を作ることができます。

var it = {
  split: delimiter => entity => entity.split(delimiter),
  trim: () => entity => entity.trim(),
  json: () => entity => entity.json(),
};

任意のプロパティを生やすProxy

上のコードで、例1,3はitで動くようになると思いますが、略記のためにいちいちitオブジェクトにプロパティを加えていては本末転倒です。そこで、任意のプロパティアクセスに応対できるように、JavaScriptのProxyオブジェクトを用います。Proxyを用いると、乱暴に言えばプロパティアクセスを関数呼出に変換できます。

var it = new Proxy({}, {
  get: (_,property) => (...args) => entity => entity[property](...args)
});

Proxyやアロー関数のチェーン表記に慣れていない人は若干戸惑うかもしれませんが、先ほどのitオブジェクトのsplittrimjsonのコードを一般化しているだけです。プロパティ名(property) => 引数(args) => 対象(entity) => 対象[プロパティ名](...引数)という形になっていることに注目してください。最後の引数「対象(entity)」を受けるまでは「関数の積み重ね」を返すだけで実際には評価をしていないところがキモです。

メソッドチェーンをできるようにする

再帰的な構造の導入

上記のコードでは例2のit.split(',').map(...).map(...)のような、メソッドチェーンに対応できません。これを回避するためにitに再帰的な構造を導入します。メソッドチェーンを実際に評価するときはit.split(',').map(...).map(...)(実際にitが指すもの)という形の関数呼出になりますが、これを下図のように逆伝播してitに置き換える感じにしたいわけです。

   it.split(',').map(parseFloat)(実際にitが指すもの)
=> it.split(',')(実際にitが指すもの).map(parseFloat)
=> it(実際にitが指すもの).split(',').map(parseFloat)
=> 実際にitが指すもの.split(',').map(parseFloat)

上の図は厳密な表現ではなくただの「引数がだんだん上に遡っていっているね」気持ちを表現したものです。実装しやすいように上手く定義すると以下のようになります。

  1. itは責務関数である。責務は引数をそのまま返すこと。
  2. 責務関数を呼出すると、(当然)責務を実行して結果を得る。
  3. 責務関数のメソッドを呼出すると、「責務を実行し、その結果に同名・同引数のメソッド呼出をする」という責務を持つ責務関数を得る。

ここで責務関数というてきとー用語を導入しました。雰囲気で読んで頂けるとありがたいですが「遅延させたものを後で(呼び出した時に)ちゃんと評価して返す」責務を持っているというニュアンスです。そして3.の再帰的な責務関数の生成が、メソッドチェーンを可能にしています。

実装する

1から3をそのまま実装して、動作確認してみましょう。2,3から責務関数はそれ自体の呼出に加えて任意のメソッドの呼出が可能ですが、ここでやはりProxyオブジェクトを用いて、前者はapplyハンドラ、後者はgetハンドラとして実装することにします。また責務から責務関数を作る関数をItとしています。

var It = response => new Proxy(new Function, {
  apply: (_,__,[entity]) => response(entity),
  get: (_,method) => (...methodArgs) => It(entity => response(entity)[method](...methodArgs))
});
var it = It(entity => entity);

it.split(',').map(parseFloat)('1,2,3');

// 動作確認
var text = `1, 2, 3
2, 4, 6
3, 6, 9`;

text.split('\n').map(it.split(',').map(parseFloat)('1,2,3')); //=> [[1,2,3],[2,4,6],[3,6,9]];

動きました!メソッドチェーンの遅延評価が実現しました。

プロパティも遅延評価する

プロパティ/メソッド/コールバックの区別

ここまでProxyのgetハンドラにはメソッド名がくることを想定していましたが、プロパティも参照したくないですか?下みたいなことしたくないですか?

var data = [{id:1, values:'3, 1, 4, 1, 5'}, {id:2, values:'2, 7, 1, 8, 2'}];
data.map(it.values.split(',').map(it.trim()).map(parseFloat)[2]);

ちょっとわかりにくいかもしれませんがvalues[2]というプロパティ(+配列)アクセスが新規性です。しかも、それをメソッドとごっちゃ混ぜでチェーン可能にします。

さて、ここで一度立ち返りたいのですが、そもそもJavaScriptにメソッドとプロパティの区別は(たぶん)ありません(あったら教えてください!)。そのため「遅延させていたプロパティの評価」なのか「遅延させるメソッドの引数」なのか、本質的に区別するのは困難です。

しかし今回のitのような用法のケースでは、遅延させていたものの評価は、コールバック先で実行されます。この事実とthisを使えば、両者を「だいたい」検出できます。

somefuncion(it.property)   //=> it.property関数内のthisは「だいたい」undefined
somefuncion(it.property()) //=> it.property関数内のthisはit

ここで「だいたい」と言っているのは、以下の2つの注意点があるからです。

  1. コールバック先(somefunction内)で、下のコードのngの例のようにメソッド的な呼出をしていると、thisが上書きされてしまう
  2. Proxy不使用かつ非strictモードの時には、1.のケースを除いて、undefinedではなくグローバルオブジェクト(Window/global)が得られる
ok = callback => callback();
ng = callback => ({a:callback}).a();

console.log(ok(function () { return this; })); //=> global object
console.log(ng(function () { return this; })); //=> {a: callback}

今回はProxyを用いているので2.の懸念はありません。逆に1のケースは、どうしようもないので、無いことを祈りましょう(何か上手い方法があったら教えてください)。結論としては今回、typeof this === "undefined"であれば「遅延させるメソッドの引数」ではない(=「遅延させていたものの評価」である)と考えることにします。

プロパティ参照とメソッド呼出を遅延させる

それでは、具体的な実装を考えていきましょう。先の実装ではapply/getハンドラをそれぞれ「責務関数を呼出する」「責務関数のメソッドを呼出する」とに分けて区別していました。しかしプロパティも遅延させることを念頭に置いて「メソッド的に責務関数を呼出する」「コールバック的に責務関数を呼出する」「責務関数のプロパティを参照する」に分けて考えることにします。

以下はイメージ図です。先ほどと異なりプロパティと関数適用の各タイミングで(実際にitが指すもの)が遡っていきます。またくどいようですが(実際にitが指すもの)という「遅延していたものの評価」としての関数適用と、それ以外(「遅延させるメソッドの引数」)の関数適用は(明示していませんが)区別することになります。そうでなければ1-2行間と2-3行間の違いが説明できません。

   it.values.split(',').map(...)(実際にitが指すもの)
=> it.values.split(',').map(実際にitが指すもの)(...)
=> it.values.split(',')(実際にitが指すもの).map(...)
=> it.values.split(実際にitが指すもの)(',').map(...)
=> it.values(実際にitが指すもの).split(',').map(...)
=> it(実際にitが指すもの).values.split(',').map(...)
=> (実際にitが指すもの).values.split(',').map(...)

これを先ほどと同じように「責務関数」という言葉をでっち上げて動作を定義しましょう。

  1. itは責務関数である。責務は引数をそのまま返すこと。
  2. 責務関数のプロパティ参照をすると、「その責務を実行し、その結果の同名のプロパティ参照をする」という責務を持つ責務関数を得る。
  3. 責務関数に「遅延させるメソッドの引数」を適用すると、「その責務を実行し、その結果に同引数で関数適用する」という責務を持つ責務関数を得る。
  4. 責務関数を「遅延していたものの評価」として関数適用すると、その責務を実行して結果を得る。

実装する

素朴に実装を試みると以下のとおりです。applyハンドラの中で、先ほど確認したthis(ここではthatに束縛されている)を用いた場合分けで3,4を区別していることに注意します。

var It = response => new Proxy(new Function, {
  get: (_,property) => It(entity => response(entity)[property]),
  apply: (_,that,[...args]) => {
    if (typeof that === "undefined") return response(args[0]);
    return It(entity => response(entity)(...args));
  }   
});
var it = It(entity => entity);

it.split(',').map(parseFloat)('1,2,3');

// 動作確認
var data = [{id:1, values:'3, 1, 4, 1, 5'}, {id:2, values:'2, 7, 1, 8, 2'}];
data.map(it.values.split(',').map(it.trim()).map(parseFloat)[2]); //=> エラー

おっと、これではエラーが発生します。メソッド呼出を「プロパティ取得」と「関数適用」に分離したために、実際のメソッド評価時に適切なthisが束縛されていないのが問題なので、取得したプロパティが関数だった場合に、親オブジェクトを束縛しておきます。

var It = response => new Proxy(new Function, {
  get: (_,property) => It(entity => {
    const base = response(entity);
    return (typeof base[property] === "function") ? base[property].bind(base) : base[property];
  }),
  apply: (_,that,[...args]) => {
    if (typeof that === "undefined") return response(args[0]);
    return It(entity => response(entity)(...args));
  }   
});
var it = It(entity => entity);

it.split(',').map(parseFloat)('1,2,3');

// 動作確認
var data = [{id:1, values:'3, 1, 4, 1, 5'}, {id:2, values:'2, 7, 1, 8, 2'}];
data.map(it.values.split(',').map(it.trim()).map(parseFloat)[2]); //=> [4, 1]

動きました!これで理想のitが完成です!

まとめ

ということで、唐突に終了を迎えますがitストーリー仕立てのProxyによる遅延評価の実装の話でした!
最後までお読み頂きありがとうございましたm(_ _)m