This is a Japanese translation from the original Gist in English. 原文:You're Missing the Point of Promises by Domenic Denicola
====== この記事は私のブログでも掲載しています。また、記事中で Promises/A に言及している箇所がありますが、この記事が書かれた当時は Promises/A+の仕様がまだ存在しなかったので、少し古く感じられるかもしれません。
Promise は非同期プログラミングを楽にするための抽象概念です。ごく簡単に言えば、以下のような、コールバック関数を引数として渡すスタイルの代わりに、
getTweetsFor("domenic", function (err, results) {
// the rest of your code goes here.
});
非同期関数は Promise という値を返すようになります。Promise は非同期処理完了後の最終的な結果を表します。
var promiseForTweets = getTweetsFor("domenic");
これは非常に便利です。なぜなら、これらの Promise 値をファーストクラス・オブジェクトとして扱う事ができるからです。つまり、Promise を他の関数に渡したり、複数の Promise を集合として扱うことが可能となります。それにより、従来の手法、例えばすべてのコールバック関数が呼ばれたかどうか調べるためにダミーのコールバック関数を挿入する、といったことをしなくてもよくなります。
私は以前に Promise がいかに素晴らしいものかについて長々と話した事がありますが、この記事では違ったテーマについて論じます。最近の JavaScript ライブラリに見られる Promise の誤った解釈についてです。はっきり言いましょう。それらは完全に Promise の要点を見落としています。
JavaScript の文脈で、Promise について語るとき、話者は通常、CommonJS Promises/A を指しています。(少なくともそれを指しているつもりで語っています。)Promises/A は私がいままで見た中でも、最も小さな「仕様」の一つです。それは、ただ一つの関数then
について述べているにすぎません:
A promise is defined as an object that has a function as the value for the property
then
:
then(fulfilledHandler, errorHandler, progressHandler)
Adds a
fulfilledHandler
,errorHandler
, andprogressHandler
to be called for completion of a promise. ThefulfilledHandler
is called when the promise is fulfilled. TheerrorHandler
is called when a promise fails. TheprogressHandler
is called for progress events. All arguments are optional and non-function values are ignored. TheprogressHandler
is not only an optional argument, but progress events are purely optional. Promise implementors are not required to ever call aprogressHandler
(theprogressHandler
may be ignored), this parameter exists so that implementors may call it if they have progress events to report.
This function should return a new promise that is fulfilled when the given
fulfilledHandler
orerrorHandler
callback is finished. This allows promise operations to be chained together. The value returned from the callback handler is the fulfillment value for the returned promise. If the callback throws an error, the returned promise will be moved to failed state.
最初の段落は分かりやすいと思います。それはつまるところ、コールバック関数の集合体です。then
を使い、成功時と失敗時(さらに進捗時も)のコールバック関数を Promise に登録します。そして Promise の状態が変化したときに、これらのコールバック関数が呼ばれます。(ちなみに、どうやって状態を変化するかについては、この非常に小さな仕様には記述されていません。)とりあえず、ここまではいいと思います。
問題は二番目の段落です。この段落こそが最も重要であるにもかかわらず、多くの人が理解できていないようです。
Promise は単なるコールバック関数の集合体ではありません。Promise はそのような単純なユーティリティよりも、もっと深い役割を担います。すなわち、同期関数と非同期関数を同列に扱うための仕組みを提供します。
では、どういう意味か説明しましょう。同期関数には、以下の2つの重要な特徴があります:
- 値を返す
- 例外を throw する
両者は関数の合成に不可欠なものです。つまり、同期呼び出しでは、ある関数の戻り値を他の関数にそのまま渡す、ということを無限に行えます。さらに重要なことは、処理が途中で失敗した場合、ある関数が throw した例外は、それをcatch
して処理できる関数が見つかるまで、関数呼び出しの連鎖内の途中のすべての関数を迂回して届けられるということです。
では、非同期関数の場合を考えてみましょう。非同期関数は処理結果を返す事はできません。関数呼び出し完了時には、まだ処理結果が準備できていないからです。同様に、例外を throw することもできません。なぜなら誰も catch してくれないからです。かくして、我々はいわゆる「コールバック・ヘル」に陥ってしまうのです。そこでは、関数を合成するために、ネストしたコールバック関数を書く必要があり、また、エラー通知を実現するために、手動で呼び出し元に通知してやらないといけません。ちなみに、非同期処理で例外を扱おうとすると、Domainのような厄介なものを導入する必要がありますが、決して使わないようにしましょう。
*Promise の要点とは、同期関数で行われている関数合成とエラー伝播を、非同期の世界に取り戻すことなのです。*そのためには、非同期関数が返す Promise は、以下の2つのいずれかの状態しか取り得ません:
- 値を受けて、fulfilled の状態になる。
- 例外を受けて、rejected の状態になる。
もし Promises/A の then
を正しく実装すれば、同期関数とまったく同じようなことが実現できます。つまり、fulfilled になった場合、値は非同期関数の呼び出し元に順に渡され、しかも途中で例外が発生した場合、それを処理できると宣言した関数に処理が渡ります。
以下の非同期呼び出しを見てください。
getTweetsFor("domenic") // promise-returning async function
.then(function (tweets) {
var shortUrls = parseTweetsForUrls(tweets);
var mostRecentShortUrl = shortUrls[0];
return expandUrlUsingTwitterApi(mostRecentShortUrl); // promise-returning async function
})
.then(doHttpRequest) // promise-returning async function
.then(
function (responseBody) {
console.log("Most recent link text:", responseBody);
},
function (error) {
console.error("Error with the twitterverse:", error);
}
);
これは、以下の同期呼び出しと等価です。*
try {
var tweets = getTweetsFor("domenic"); // blocking
var shortUrls = parseTweetsForUrls(tweets);
var mostRecentShortUrl = shortUrls[0];
var responseBody = doHttpRequest(expandUrlUsingTwitterApi(mostRecentShortUrl)); // blocking x 2
console.log("Most recent link text:", responseBody);
} catch (error) {
console.error("Error with the twitterverse: ", error);
}
特に、途中でエラーが発生した場合、手動で通知しなくてもcatch
ハンドラが呼ばれる点に注目してください。さらに、策定中のECMAScript 6 では、あるトリックを使うことで、同期呼び出しとほぼ同じコードが書けるようになります。
これらはすべて、上記の二番目の段落を正しく解釈することではじめて可能となります。
This function should return a new promise that is fulfilled when the given
fulfilledHandler
orerrorHandler
callback is finished. This allows promise operations to be chained together. The value returned from the callback handler is the fulfillment value for the returned promise. If the callback throws an error, the returned promise will be moved to failed state.
別の言い方をすれば、then
はコールバック関数を登録するためのものではなく、ある Promise を変質させて、その結果別の新しい Promise を生成するための仕組みなのです。
この意味で、最初の文節「this function should return a new promise.」は非常に重要です。(1.8 以前の) jQuery のようなライブラリはこれを守っておらず、単に既存の Promise の状態を変えているだけです。これはつまり、Promise を複数の呼び出し元に返した場合、互いに干渉する場合があることを意味します。これがどれだけ馬鹿げた事か気付くためには、同期呼び出しで同じことをした場合を想像してみてください。例えば、ある関数の戻り値を別の2つの関数に渡して、一方の関数がその値を例外に変えてしまえば、どうなるでしょうか? 実際、Promises/A はこれを明確に指摘しています:
Once a promise is fulfilled or failed, the promise's value MUST not be changed, just as a values in JavaScript, primitives and object identities, can not change (although objects themselves may always be mutable even if their identity isn't).
では、最後の2つの文を見てみましょう。これらの文は、新しく作られた Promise がどの状態を持つかを説明しています。つまり:
- もし成功/失敗のコールバックのいずれかが値を返した場合、新しく作られた Promise はその値により fulfilled の状態になる。
- もし成功/失敗のコールバックのいずれかが例外を throw した場合、新しく作られた Promise はその例外により rejected の状態になる。
結局、Promise の状態によって以下の4つのシナリオに集約されます。各シナリオには、同期呼び出しにおける等価物を記しています。それにより、4つのシナリオをすべてカバーすることがどれだけ重要かお分かりいただけると思います。
- Promise が fulfilled になったので、成功のコールバックを呼び出した結果、値が返ってきた。(通常の同期関数呼び出し)
- Promise が fulfilled になったので、成功のコールバックを呼び出した結果、例外が throw された。(受け取った値が不正だったので、例外を throw した。)
- Promise が rejected になったので、失敗のコールバックを呼び出した結果、値が返ってきた。(
catch
節で例外を受け取り、処理した。) - Promise が rejected になったので、失敗のコールバックを呼び出した結果、例外が throw された。(
catch
節で例外を受け取り、そのまま、もしくは新しい例外を生成して throw した。)
これらのすべてを対応しなければ、Promise が提供する同期関数と非同期関数の同列化の恩恵にあずかることはできません。そして、Promise は名前だけのものとなり、単なるコールバック関数の集合体となります。jQuery の現時点での Promise の問題は、上記のシナリオ1しか対応せず、シナリオ2から4を省略しているということです。また、Node.js 0.1 の EventEmitter
ベースの Promise (もはや then
able とも呼べないのですが) にも、同じ問題がありました。
さらに、例外を catch した場合に新しい Promise の状態を rejected にすることで、同期呼び出しの時と同様に、意識的に発生させた例外と、予期せぬ例外の両方をサポートできます。例えば、成功/失敗のいずれかのコールバックで、aFunctionThatDoesNotExist()
(存在しない関数)を呼び出してみてください。throw new Error("bad data")
と書くのと同様に、Promise は rejected の状態になり、そのエラーは呼び出し元の連鎖の中で直近の失敗のコールバックに渡されます。もはや Domain は不要ですね。
読者の皆さんは、おそらく私の頑で理屈っぽい話に唖然とされているかもしれません。また、なぜこれほどまでにライブラリの動作に対して腹を立てているのか、疑問に思う方も多いでしょう。
問題は以下の点にあります:
A promise is defined as an object that has a function as the value for the property
then
Promises/A を利用するライブラリの作成者として、この一文が真であると仮定したいのです。つまり、「thenable」たるものは、Promises/A で定義されている Promise が必要とするすべての力を兼ね備えたものでなければなりません。
この前提に立つ限り、我々は Promise の実装に依存しない、非常に拡張性の高いライブラリ を書く事ができます。実装が Q か when.js か、はたまた WinJS かに関係なく、Promises/A の仕様で定義されたシンプルな関数合成のルールに則ってプログラミングできるのです。例えば、このリトライ関数はすべての Promises/A の実装で動作します。
残念ながら jQuery のようなライブラリはこれに違反しています。そのため、API ドキュメントで自己を Promise であると謳っているにもかかわらず、実際の動作は Promises/A に準拠していないオブジェクトを検知する、醜いハック が必要になります。もしクライアントから jQuery の Promise を API に渡された場合、選択肢としては2つあります。1)関数合成の結果、奇妙な解析しにくいエラーが発生するかもしれないが、そのまま実行する。もしくは、2)事前にエラーを発生させて、そのようなクライアントの使用を一切禁じる。どちらも最悪ですね。
Ember の Defered が残念なコールバック関数の集合体になることは避けたかったので、@felixge の提案により、この記事を書きました。そしてこの記事を書いてから数時間後に、私は Promises/A に準拠していることを確認するためのテストスイートを書きました。これにより、将来我々は同じ仕様に準拠している事を保証できます。
本記事の執筆時、最新の jQuery は バージョン 1.8.2 で、Promise の実装はエラー処理の部分が完全に壊れています。望むべくはこの記事とテストスイートがきっかけで、jQuery 2.0 でこの問題が修正されていることを願います。
テストスイートをリリースしてから、すでに一つのライブラリ(@wycats の rsvp.js)が Promises/A の機能を正しく実装していることが確認されています。皆さんもぜひ、このスイートを活用してください。以下はこのテストをパスしたライブラリのリストです。私はこれらを無条件でお勧めします:
- Q by @kriskowal と私: Promise のすべての機能を提供するライブラリ。Node.js のアダプタと、progress イベント、long stack trace をサポートする。
- rsvp.js by @wycats: 非常に小さく軽量だが、Promise に完全準拠するライブラリ。
- when.js by @briancavalier: 中級サイズのライブラリ。Promise に加えて、非同期タスクを管理する機能を提供する。progress イベントとキャンセルにも対応する。ただし、非同期処理の実行順は保証しない。
上記のライブラリはまた、壊れた Promise を本物の Promise に変換するための「同化」関数(大抵はwhen
という名前)を提供します。もし jQuery のようなライブラリに手を焼いているのであれば、これらの使用を強くお勧めします。
var promise = Q.when($.get("https://github.com/kriskowal/q"));
// aaaah, much better