Original: "Callbacks are imperative, promises are functional: Node's biggest missed opportunity" by James Coglan
Translated by Yuta Okamoto (@okapies)
- 訳者は JavaScript や Node.js に関する専門知識がほとんどありません。識者のツッコミをお待ちしております。「◯◯が分からない」等も歓迎です。
- 元記事から構成を一部変更しています。また、関数型プログラミングに関する記述のうち、議論の骨子に絡まないものは省略しています。
- (To share core ideas of the original article with beginner of functional programming, this translation omits some points such as monads.)
- 元記事の翻訳については原著者から承諾済み。Thanks to @jcoglan!
- 本記事の関連エントリがこちらで紹介されています。
- 本文中で言及されてる Mikeal Rogers 氏の反論。
- 上記に対する James Coglan 氏の再反論。
- 記事に対して頂いたコメント。
約束 (promise) の本質は、状況が変わっても不変 (immune) であり続けることだ。
Frank Underwood, 'House of Cards'
- JavaScript には第一級関数 (first-class functions) があるので、それをもって”関数型”プログラミング言語だと言われることがある。確かに第一級関数は便利だけど、しばしば JS での関数型プログラミングは ”値によってプログラムする” という核心となる面を見落としている。
- ”関数型プログラミング”という呼び名は、(オブジェクトの代わりに)”関数によってプログラムする”という意味だという誤解を招きやすい。
- オブジェクト指向があらゆるものをオブジェクトとして扱うように、関数型プログラミングはあらゆるものを 値 として扱う。
- ”あらゆるもの”というのは、関数に限らない。数値や、文字列や、リストや、その他のデータは明らかに値だし、その他にも、オブジェクト指向プログラミングのファンが思いもよらないようなモノも値として扱える。
- ベストの状態では、関数型プログラミングは宣言的だ。
- 命令型プログラミングでは、僕らが欲することを どのように (how) やるかを、命令シーケンスを記述することでマシンに伝える。
- 関数型プログラミングでは、僕らが 何を (what) 計算してほしいかを、値同士の関係を記述することでマシンに伝える。マシンは、それを起こすための命令シーケンスを算出する。
- もし Excel を使ったことがあるなら、君は関数型プログラミングをやったことがある。
- Excel のユーザは、問題をモデル化するために、値同士のグラフがどうやって他のグラフから導出されるかを記述する。
- 新しいデータが挿入されると、Excel はそれがグラフに与える影響を算出してあらゆるものを更新してくれる。それをするために、君が命令シーケンスを書く必要はない。
- 僕が検討したいのは、ここでの定義からすると、Node.js がプロミス (promise) ベースの API ではなくコールバック (callback) ベース の API を選んだのは最大の設計ミスではないか、ということだ。この決定は、Node.js 開発のかなり初期になされたものだ。
誰もが(コールバックを)使っている。もし、君がプロミスを返すモジュールを公開しても、誰も気にとめないだろう。絶対に誰もそのモジュールを使おうとしないだろう。
もし私が小さなライブラリを書いて、それが Redis と話しに行ったら、それ以上することはない。Redis には、私に渡されたコールバックを渡すだけでいい。そして、コールバック地獄のような問題に当たった時は、私が秘密を教えてあげよう。同じように、コルーチン地獄や、モナド地獄や、君が作ったあらゆる抽象化からもたらされる地獄があることを。君がそれを十分に使えばそうなる。
九割のケースでは、このとてもとてもシンプルなインタフェースで十分だ。それで何か一つする必要があるなら、小さなインデントを一つ付け加えれば終わりだ。そして、もし複雑なユースケースに対応したいなら、npm から async や async に依存する 827 のモジュールをインストールすればいい。
- この引用は、Mikeal Rogers が最近の講演で Node の設計哲学の様々な面について語ったものだ:
- Node が宣言する”非エキスパートプログラマが、高速な並行ネットワークプログラムを簡単に構築できるようにする”というデザインゴールからすると、僕はこの姿勢は非生産的だと思う。
- プロミスを使うと、正確でかつ極限まで並行的なプログラムを簡単に構築できる。制御フローは、ユーザが明示的に実装するのではなくランタイムに算出させるからだ。
- 正しく動作する並行プログラムを書くには、操作が正しい順番で起きるか確認する必要がある。
- JavaScript は単一スレッドだけど、非同期性がもたらす競合状態 (race conditions) は避けられない。あらゆるアクションは、コールバックが I/O 待ちしている間に CPU 時間を他のアクションに明け渡す。複数の並行アクションは、メモリ上の同じデータへアクセスしたり、データベースや DOM に対して重複するコマンド列を実行したりする。
- この記事で示したいのは、プロミスは Excel のように値同士の相互依存性を使って問題を記述することで、君自身が制御フローを考えずともツールによって解決法を正しく最適化できるということだ。
- 誤解を解いておきたいんだけど、プロミスは”コールバックベースの非同期処理をキレイな構文で書く方法”じゃない。プロミスは、問題を根本的に異なる方法でモデリングする。構文よりも深いところまで行って、問題の解き方を本当に意味論のレベルで変えてしまうんだ。
- まず最初に、僕が数年前に書いた ”プロミスは非同期プログラミングのモナド” という記事を振り返ってみたい。先の記事の要点は、モナドは関数同士を組み合わせるのを助けるツールだ、ということだった。
- 例えば、ある関数の出力が次の関数の入力になることで、パイプラインを構築できる。これは、値同士の構造的な関係を使って達成できる。この記事では、値と、値同士の関係という考え方が再び重要な役割を果たす。
- 説明の助けとするため、再び Haskell の型表記法を使っていきたい。
foo :: Bar
は、”値 foo は Bar 型である” という意味。foo :: Bar -> Qux
は、” foo は、型 Bar の値を引数として取って、型 Qux の値を返す関数” という意味。- 入力や出力が正確に何の型なのか重要でない場合は、
foo :: a -> b
のように一文字の小文字を使う。 - 複数の引数を取る関数には矢印を追加する。例えば、
foo :: a -> b -> c
は、 foo は a と b の二つの型の引数に取って c 型の何かを返すという意味。 - では、Node の関数である
fs.readFile()
を見てみよう。これはString
型のパス名 (pathname) とコールバックを引数に取って、何も返さない。そのコールバックは、Error
とファイルの内容を含むBuffer
を引数に取って、やはり何も返さない。 readFile
の型は以下のように表せる。()
は Haskell の表記法で null 型を表す。
readFile :: String -> Callback -> ()
- コールバック自身も関数なので、その型シグネチャは以下のように表せる。
Callback :: Error -> Buffer -> ()
- これらを互いに一緒にすると
readFile
はString
と、Buffer
と共に呼ばれる関数を引数に取る関数として表せる。
readFile :: String -> (Error -> Buffer -> ()) -> ()
- さて、もし Node がプロミスを使っていたらと考えてみよう。この場合、
readFile
はString
を引数に取ってBuffer
に対するプロミスを返す。
readFile :: String -> Promise Buffer
- もっと一般化すると、コールバック・ベースの関数はいくつかの入力とコールバックを引数に取る。そして、コールバックはいくつかの出力によって呼び出される。一方で、プロミス・ベースの関数はいくつかの入力を引数に取り、いくつかの出力に対するプロミスを返す。
callback :: a -> (Error -> b -> ()) -> ()
promise :: a -> Promise b
- なぜ、コールバックを使ったプログラミングが難しいのか。その根本的な原因は、コールバック・ベースの関数が null 値を返すからだ。
- コールバック・ベースの関数は何も返さないので、関数同士を組み立てにくい。
- 戻り値のない関数は副作用を起こすためだけに実行される。こうした関数はブラックホールだ(訳注:値が吸い込まれて出てこれない?)。
- コールバックを使ったプログラミングは、本質的に命令型だ。副作用に頼った手続きの実行の順序付けをするために、手作業で制御フローを編成する。これが、正確な並行プログラムを書くのを困難にする。
- プロミス・ベースの関数は、対称的に、関数の結果を常に タイミングに依存しない方法で 扱えるようにする。プロミスを使ったプログラミングでは、入力に関数を適用して出力にマップし、値同士の関係を通じて問題を解く。
- コールバック・ベースの関数を呼び出すと、関数を呼び出した後にそのコールバックが呼び出されるまでの間、 結果を表すものがプログラムのどこにも出てこない。
fs.readFile('file1.txt',
// しばらく時間が経つと...
function(error, buffer) {
// 結果が飛び出して出現する
}
);
- コールバックやイベント・ベースの関数から結果を得るには、基本的に”適切なタイミングで適切な場所に”いる必要がある。
- もし、イベントが発火した後にイベントリスナーをバインドしたり、コールバック内の適切な場所にコードがない場合、結果を取り逃す場合がある。
- こうしたことは、Node で HTTP サーバを書く際に問題になる。制御フローを正しくコーディングしないとプログラムが壊れてしまう。
- 一方で、プロミスはタイミングや順序を気にしない。
- リスナーを取り付けるのはプロミスの解決前でも後でも良く、値を取り逃すことはない。
- プロミスを返す関数は、結果を表現する値を直ちに返す。この結果を表す値は第一級 (first-class) のデータとして使うことができ、他の関数へ渡すことができる。君は、プロミスへの参照を保持してさえいれば値を取り出せる。
var p1 = new Promise();
p1.then(console.log);
p1.resolve(42);
var p2 = new Promise();
p2.resolve(2013);
p2.then(console.log);
// prints:
// 42
// 2013
then()
というメソッド名は(そのジョブの副作用である)操作の順序付けという意味を含んでいるが、unwrap
という意味だと考えてもいい。プロミスは ”まだ不明な” 値のコンテナで、プロミスから値を取り出して他の関数に与えるのがthen
の役割だ。- 上記のコードは、値が いつ 利用可能になるか、どんな順序で起こるかについては何も言っていない。単に 依存関係 を表しているだけだ。値をログするには、まず依存関係が何かを知らなければならない。
- プログラムの順序付けは、この依存関係の情報から 浮かび上がって くる。これは微妙な違いだけど、この記事の終わりの方で遅延プロミスについて検討する際にはっきりするだろう。
- ここまで出てきた関数は、ほとんど相互作用がなかった。プロミスがなぜ強力なのか理解するために、もう少しやっかいなものに取り組んでみよう。
fs.stat()
を使って、一連のファイルから mtimes を取得するコードがある。もし同期バージョンならpaths.map(fs.stat)
を呼ぶだけでいい。だけど、非同期関数をマップするのは難しい。 async モジュールを試してみる。
var async = require('async'),
fs = require('fs');
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
async.map(paths, fs.stat, function(error, results) {
// results を使う
});
- とりあえずはこれでいいけど、もし無関係のタスクで
file1
のサイズが必要になったら? もう一度 stat すればいい。
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
async.map(paths, fs.stat, function(error, results) {
// results を使う
});
fs.stat(paths[0], function(error, stat) {
// stat.size を使う
});
- これは動くけど、今度はファイルを二度 stat している。ローカルファイルを操作する時はいいけど、仮に https 越しに巨大なファイルを取得する場合には、もっと問題になるだろう。ファイルを一度だけアクセスするようにするために、前のバージョンに戻して、最初のファイルを特別扱いするようにする。
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
async.map(paths, fs.stat, function(error, results) {
var size = results[0].size;
// size を使う
// results を使う
});
- これで動くけど、今度はリスト全体が完了するまで size に関わるタスクがブロックされる。それに、リスト中のアイテムのどれかについてエラーがあると、最初のファイルの結果も全く取れなくなる。これは良くないので、別のアプローチを試してみる。最初のファイルを、リストの残りの部分と分けて扱う。
var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
file1 = paths.shift();
fs.stat(file1, function(error, stat) {
// stat.size を使う
async.map(paths, fs.stat, function(error, results) {
results.unshift(stat);
// results を使う
});
});
- これも動くけど、今度はプログラムが並列でなくなった。
- 以前は全て並行実行されていたのに、最初の一つが完了するまでリクエストのリストを開始できないので、さらに長い時間がかかってしまう。
- また、一つのファイルを他と異なる扱いをするために、配列操作が必要になっている。
- オーケー、成功へ向けた最後の挑戦だ。僕らは既に、以下のことが必要だと知っている。
- 全てのファイルに対して stat を取得する。
- 各ファイルに一度だけアクセスする。
- 最初のファイルへのアクセスが成功したら、その結果に対して何か処理をする。
- 全てのファイルへのアクセスが成功したら、そのリストに対して何か処理をする。
- 問題におけるこの依存関係についての知識を用いて、 async を使って表現してみよう。
var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
file1 = paths.shift();
async.parallel([
function(callback) {
fs.stat(file1, function(error, stat) {
// use stat.size
callback(error, stat);
});
},
function(callback) {
async.map(paths, fs.stat, callback);
}
], function(error, results) {
var stats = [results[0]].concat(results[1]);
// stats を使う
});
- これで全てが正しく動作する。各ファイルは一度だけアクセスしているし、全ての処理は並列に完了するし、最初のファイルの結果へは他のファイルとは独立にアクセスできるし、依存するタスクは可能な限り早く実行される。ミッション完了!
- いや、いまいちだ。 僕は、これはとても酷いと思うし、問題がもっと複雑になった時にうまく拡張できないのは確実だ。
- これを正しく動作させるために、考えなければならないことが大量にある。
- 設計の意図が明らかでないので、後々のメンテナンスで壊しやすい。
- 後に続くタスクを追加する方法が、必要な処理をどうやって実行するかの戦略に影響を受ける。
- 僕らが導入した特殊ケースについて、お粗末な配列変更のことを文書化する必要がある。うへぇ。
- こうした全ての問題は、問題を解決する主な手段として、データの依存関係の代わりに制御フローを使っていることに起因する。
- ”このタスクを実行するにはこのデータが必要だ”と言って、物事を最適化する方法をランタイムに算出させる代わりに、どれを並列化すべきでどれを逐次的にすべきかをランタイムへ明示的に伝えているので、解決法がとても脆弱になる。
- それでは、プロミスはどうやって改善するのか?
- さて、まずはコールバックを引数に取る代わりに、プロミスを返すファイルシステム関数が必要だ。それらの関数を手作業で書かずに、関数を僕ら向けに変換するものをメタプログラミングしよう。
- 例えば、その関数は
String -> (Error -> Stat -> ()) -> ()
という型の関数を引数に取ってString -> Promise Stat
という型を返すべきだ。そんな関数の一つがこれだ(これは完全ではないけど、今回の目的には十分):
// promisify :: (a -> (Error -> b -> ()) -> ()) -> (a -> Promise b)
var promisify = function(fn, receiver) {
return function() {
var slice = Array.prototype.slice,
args = slice.call(arguments, 0, fn.length - 1),
promise = new Promise();
args.push(function() {
var results = slice.call(arguments),
error = results.shift();
if (error) promise.reject(error);
else promise.resolve.apply(promise, results);
});
fn.apply(receiver, args);
return promise;
};
};
- これで問題を再度モデル化できる。基本的に僕らがやりたいのは、path のリストを stat に対するプロミスのリストへとマップすることだ:
var fs_stat = promisify(fs.stat);
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
// [String] -> [Promise Stat]
var statsPromises = paths.map(fs_stat);
- これでもう見返りがある:
async.map()
ではリスト全体が完了するまで処理するデータが得られないのに対して、君はこのプロミスのリストから最初の一つを選び出して処理できる:
statsPromises[0].then(function(stat) { /* stat.size を使う */ });
- プロミス値を使うことで、既にほとんどの問題が解決している。全てのファイルを並行的に stat すると共に、単に配列から一つ選び出せば(最初のファイルだけでなく)任意のファイルを独立にアクセスできる。
- 以前のアプローチでは最初のファイルを明示的に操作する必要があったけど、この方法では明らかに、必要なファイルが変わると対応できない。だけど、プロミスのリストを使えば簡単だ。
- あと欠けているピースは、stat の結果が全て揃った時に反応する方法だ。
- 以前のやり方では、最終的に
Stat
オブジェクトのリストが得られた。一方、ここではPromise Stat
オブジェクトのリストになっている。 - 僕らは、全てのプロミスが解決するのを待ってから、全ての stat のリストを生成したい。言い換えると、複数のプロミスのリストを、単一のリストに対するプロミスへ変換したい。
- やってみよう。リストを単純にプロミスメソッドで拡張して、プロミスを含むリスト自身が、自身の全ての要素が解決した時に解決するようにする。(この関数は
jQuery.when()
と似ている。 when() はプロミスのリストを引数に取って、全ての入力が解決した時に解決する新しいプロミスを返す。)
// list :: [Promise a] -> Promise [a]
var list = function(promises) {
var listPromise = new Promise();
for (var k in listPromise) promises[k] = listPromise[k];
var results = [], done = 0;
promises.forEach(function(promise, i) {
promise.then(function(result) {
results[i] = result;
done += 1;
if (done === promises.length) promises.resolve(results);
}, function(error) {
promises.reject(error);
});
});
if (promises.length === 0) promises.resolve(results);
return promises;
};
// 訳注: list() の実装についてはいくつか意見が出ているようです。
// 詳細は原文の※欄や https://twitter.com/ktz_alias/status/322383653252521984 を参照。
- これで、プロミス内の配列をラップするだけで、全ての結果がやって来るのを待てるようになった:
list(statsPromises).then(function(stats) { /* stats を使う */ });
- したがって、僕らの解決法全てをこのようにまとめることができる:
var fs_stat = promisify(fs.stat);
var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
statsPromises = list(paths.map(fs_stat));
statsPromises[0].then(function(stat) {
// stat.size を使う
});
statsPromises.then(function(stats) {
// stats を使う
});
- この解決法の表現はかなりキレイだ。
- ちょっとした汎用的な糊(プロミスのヘルパー関数)を既に存在する配列メソッドと共に使うことで、問題を正確に、効率的に、かつ変更が容易な方法で解くことができた。
- この問題のために async モジュールの特化したコレクションメソッドは必要はない。プロミスと配列という直交した考え方を、非常に強力な方法で結びつけるだけでいい。
- このプログラムは、処理が並列的なのか逐次的なのかについて、何も言っていないことに特に注意しよう。僕らが何をしたいのか、そしてタスクの依存関係は何か。これさえ言えば、プロミスライブラリが全ての最適化をしてくれる。
- 実際、 async コレクションモジュールの機能の多くは、プロミスのリストに対する操作へと容易に置き換えることができる。
async.map(inputs, fn, function(error, results) {});
- は、以下と等しい。
list(inputs.map(promisify(fn))).then(
function(results) {},
function(error) {}
);
async.each()
は、要するに副作用のために関数を実行して戻り値を投げ捨てるasync.map()
なので、その代わりmap()
を使えばいい。async.mapSeries()
(それに、さっき議論したasync.eachSeries()
)は、プロミスのリストに対してreduce()
を呼ぶのと同じだ。すなわち、入力のリストを引数に取ってreduce()
を使うと、各アクションが以前に成功したアクションに依存するプロミスを生成する。- 例を挙げてみよう。
fs.rmdir()
を使ってrm -rf
と同じものを実装したのがこのコードだ:
var dirs = ['a/b/c', 'a/b', 'a'];
async.mapSeries(dirs, fs.rmdir, function(error) {});
- は、以下と等しい。
var dirs = ['a/b/c', 'a/b', 'a'],
fs_rmdir = promisify(fs.rmdir);
var rm_rf = dirs.reduce(function(promise, path) {
return promise.then(function() { return fs_rmdir(path) });
}, unit());
rm_rf.then(
function() {},
function(error) {}
);
unit()
は連鎖を始めるための関数で、すでに解決済みのプロミスを生成するだけだ。
// unit :: a -> Promise a
var unit = function(a) {
var promise = new Promise();
promise.resolve(a);
return promise;
};
- この
reduce()
によるアプローチは、リストから次のディレクトリパスのペアをそれぞれ引数に取り、以前のステップでの成功に依存するpromise.then()
を使ってパスを削除するアクションを作るだけだ。 - これは、空でないディレクトリを扱うことができる。もし、以前のプロミスが何らかのエラーで拒絶されたら、連鎖を中断するだけだ。
- 値の依存関係によって実行順序を強制するのは、関数型言語において副作用を扱う際に核となるアイデアだ。
- この最後の例は同等の async を使ったコードより冗長だけど、君を惑わすことがない。ここで鍵となる考え方は、プロミス値とリスト操作という別々の発想を結びつけて、プログラムを組み立てることだ。特別な制御フローライブラリに頼る必要はない。既に見てきたように、このアプローチによってプログラムの検討が容易になる。
- 正確に検討しやすくなるのは、僕らの思考プロセスの一部をマシンに委譲したからだ。 async モジュールを使うとき、僕らの思考プロセスは以下のようになる:
- A. プログラム内のタスク同士が互いに こんな感じで 依存してるから、
- B. 操作同士は こんな感じで 順序付けされてないといけないから、
- C. じゃあ B を表すコードを書いてみよう。
- プロミスの依存関係を表すグラフを使うと、ステップ B を完全に省略できる。君はタスクの依存関係を表すコードを書いて、コンピュータに制御フローを解決させればいい。
- 別の言い方をすると、コールバックは明示的な制御フローを使って、たくさんの小さな値を互いにのり付けする。
- 一方で、プロミスは明示的な値の関係を使って、たくさんの制御フローのかけらを互いにのり付けする。
- コールバックは命令的で、プロミスは関数的だ。
- このトピックでは、まだプロミスの最後の応用であり、関数型プログラミングの核となるアイデアについて考察していない。それは、遅延 (laziness) だ。
- Haskell は怠け者な言語だ。これは、君のプログラムを一番上から下まで実行するスクリプトのように扱うということではなく、プログラムの出力(標準入出力やデータベース等)を定義する式から実行を始めて、戻りながら動作するという意味だ。
- Haskell は、プログラムの動作に必要なものだけを計算する。つまり、最後の式に対して依存する式(最後の式への入力となる式)を調べて、そのプログラムが生成する必要がある全ての出力を計算するまで、グラフを遡る。
- 繰り返しになるけど、計算機科学の問題に対する最良の解決法は、問題をモデル化する正しいデータ構造を見つけることだ。
- そして JavaScript には、今書いたことにとてもよく似た課題が一つある。それはモジュールのロードだ。君はプログラムが本当に必要とした時だけモジュールをロードしたいし、それをできるだけ効率的にやりたいだろう。
- 僕らは、依存性を扱える CommonJS や AMD より前に、ちょっとしたスクリプトローダーのライブラリを持っていた。
- これらのライブラリは、上記の例とよく似た動作をする。スクリプトローダーに対して、どのファイルを並列にダウンロードできるか、あるいはどれを逐次的に扱うかを明示的に指示するんだ。
- 要するに、単にスクリプト同士の依存性を記述してローダーに最適化させるのではなく、ダウンロード戦略を書き出さないといけないわけで、正確さと効率性を両立するのはかなり大変だ。
LazyPromise
の概念を導入してみよう。これはプロミスオブジェクトで、ある非同期処理かもしれない関数を含んでいる。- その関数は、誰かがプロミスの
then()
を呼んだ時に一度だけ呼び出される。つまり、誰かが結果を必要とした時に一度だけ評価を始める。 - 以下のコードは
then()
をオーバーライドして、まだ開始していない時だけ処理を開始するようにする。
var Promise = require('rsvp').Promise,
util = require('util');
var LazyPromise = function(factory) {
this._factory = factory;
this._started = false;
};
util.inherits(LazyPromise, Promise);
LazyPromise.prototype.then = function() {
if (!this._started) {
this._started = true;
var self = this;
this._factory(function(error, result) {
if (error) self.reject(error);
else self.resolve(result);
});
}
return Promise.prototype.then.apply(this, arguments);
};
- 例えば、以下のプログラムは何もしない。このプロミスの結果を要求しない限り処理は完了しない。
var delayed = new LazyPromise(function(callback) {
console.log('Started');
setTimeout(function() {
console.log('Done');
callback(null, 42);
}, 1000);
});
- だけど、この行を追加するとプログラムは Started を出力して、一秒待ってから Done と出力して、続いて 42 と出力する。
delayed.then(console.log);
- そして、処理は一度だけ実行される。
then()
を呼ぶたびに何度でも結果を生成するけど、その度に処理が実行されることはない。
delayed.then(console.log);
delayed.then(console.log);
delayed.then(console.log);
// prints:
// Started
// -- 1 second delay --
// Done
// 42
// 42
// 42
- このとてもシンプルで汎用的な抽象化を使うと、最適化されたモジュールシステムをすぐさま構築できる。
- 僕らが一連のモジュールを作成することを考えてみよう。各モジュールは、名前と、それが依存するモジュールのリストと、渡された依存関係と共に実行してモジュールの API を返すファクトリを持っている。これは、AMD の動作にとても似ている。
var A = new Module('A', [], function() {
return {
logBase: function(x, y) {
return Math.log(x) / Math.log(y);
}
};
});
var B = new Module('B', [A], function(a) {
return {
doMath: function(x, y) {
return 'B result is: ' + a.logBase(x, y);
}
};
});
var C = new Module('C', [A], function(a) {
return {
doMath: function(x, y) {
return 'C result is: ' + a.logBase(y, x);
}
};
});
var D = new Module('D', [B, C], function(b, c) {
return {
run: function(x, y) {
console.log(b.doMath(x, y));
console.log(c.doMath(x, y));
}
};
});
- これはダイアモンドの形になっている。つまり D は B と C に依存し、それぞれが A に依存する。
- これが意味するのは、まず A をロードしたら B と C を並列にロードでき、それらが完了したら D をロードできるということだ。だけど、僕らはこの戦略を自分で書かずにツールに算出させたい。
- これは、モジュールを
LazyPromise
のサブ型としてモデル化することでとても簡単にできる。ファクトリは、さっき作った list プロミスヘルパーを使ってモジュールの依存関係の値を要求するだけだ。 - 次にファクトリは、タイムアウト後にモジュールをその依存関係と共に生成する。タイムアウトは、非同期にロードする際のレイテンシをシミュレートするためだ。
var DELAY = 1000;
var Module = function(name, deps, factory) {
this._factory = function(callback) {
list(deps).then(function(apis) {
console.log('-- module LOAD: ' + name);
setTimeout(function() {
console.log('-- module done: ' + name);
var api = factory.apply(this, apis);
callback(null, api);
}, DELAY);
});
};
};
util.inherits(Module, LazyPromise);
Module
はLazyPromise
なので、上記のようにモジュールを定義しただけでは何もロードされない。僕らがモジュールを使おうとした時に初めてロードを開始する。
D.then(function(d) { d.run(1000, 2) });
// prints:
//
// -- module LOAD: A
// -- module done: A
// -- module LOAD: B
// -- module LOAD: C
// -- module done: B
// -- module done: C
// -- module LOAD: D
// -- module done: D
// B result is: 9.965784284662087
// C result is: 0.10034333188799373
- 見ると分かるように、最初に A がロードされて、それが完了すると B と C が同時にダウンロードを開始し、その両方が完了すると D がロードされる。これは、僕らがまさに望んでいた動作だ。もし
C.then(function() {})
だけを呼ぶと、 A と C だけがロードされるのが分かるだろう。必要なモジュールのグラフの中にないモジュールはロードされない。 - こうして、遅延プロミスのグラフを使うことで、正しく最適化するモジュールローダーをほとんどコードを書かずに作ることができた。僕らは、制御フローを明示的に使って問題を解くのではなく、値同士の関係を使った関数型アプローチを使った。これは、制御フローを自分で書くことを中心とした解決法よりもはるかに楽だ。このライブラリは、あらゆる非循環な依存関係グラフを受け取って制御フローを最適化する。
- これがプロミスの真の実力だ。
- プロミスは、構文レベルでインデントのピラミッドを避けるためだけの方法じゃない。問題をより高いレベルでモデル化する抽象化を提供することで、もっとツールに仕事を任せられる。
- そして実際、それは全て僕らがソフトウェアから要求されるべきものだ。もし Node が並行プログラミングを簡単にすることに本当に真剣に取り組むなら、ぜひプロミスを再検討すべきだ。