JavaScript Promise ... イカした詳細
JavaScript Promises ... In Wicked Detailを翻訳させて頂きました、プロミスについて実装しながらその仕組みを学べるドキュメントです。著者のMatt Greer氏から許可を得て翻訳、公開しています。
私はここしばらく仕事でJavaScriptのプロミスを利用してきました。プロミスを使い始めたときは少し頭を悩ませたりもしましたが、今やかなり効率的にプロミスを利用しています。しかし、結局のところ、私はプロミスがどのように機能しているか理解できていませんでした。この文章はこのことに対する私の解です。この文章を最後まで読めば、プロミスについてあなたもよく理解できるでしょう。
この文章では、 ほぼ Promise/A+specに準拠したプロミスを目標にインクリメンタルにプロミスを実装しながら、プロミスが非同期プログラミングのニーズにいかにマッチしているか理解していきます。この文章はプロミスに対するある程度の理解を前提としています。もしまだプロミスに対する理解が足りない場合は、promisejs.orgをチェックすると良いでしょう。
目次
なぜ
なぜプロミスを詳細に理解する必要があるのでしょうか?ある物事がどのように機能しているのか正しく理解することは、これを利用する能力を向上させ、またトラブル時のデバッグをより容易にします。私は同僚と一緒にトリッキーなプロミスの振る舞いに悩まされた時にこの文章を書くことを思いつきました。もし今わかっていることをその時に知っていたなら、私はあの時悩まなかったと思います。
シンプルなユースケース
なるべくシンプルなところからプロミスの実装をはじめていきます。まず以下のコードを
doSomething(function(value) { console.log('Got a value:' + value); });
以下のようにしてみます、
doSomething().then(function(value) { console.log('Got a value:' + value); });
そのためには、doSomething()
を以下から
function doSomething(callback) { var value = 42; callback(value); }
以下のようにプロミスベースのコードに変更します。
function doSomething() { return { then: function(callback) { var value = 42; callback(value); } }; }
これは単にコールバックパターンの些細なシュガーです。このままではとても無意味なシュガーです。しかし出発点としては十分だし、またプロミスの裏側にある核となる考えが見えてきました。
プロミスは結果の値の概念(the notion of an eventual value)をオブジェクトの中に取り込む
プロミスがこれほど興味深いのはこれこそが主な原因です。一度結果の値の概念をオブジェクトに取り込めれば、とても強力なことが実現できます。このことについては後でより詳細に説明します。
プロミス型を定義する
単純なオブジェクトリテラルでは今後の実装に耐えられません。拡張していけるように実際のPromise
型を定義します。
function Promise(fn) { var callback = null; this.then = function(cb) { callback = cb; }; function resolve(value) { callback(value); } fn(resolve); }
そしてdoSomething()
をこれを利用するように書き直します
function doSomething() { return new Promise(function(resolve) { var value = 42; resolve(value); }); }
ここで問題が一つあります。実行の順番を確認すると、resolve()
がthen()
の前に呼ばれているのが分かります、つまりresolve()
が呼び出されたタイミングではcallback
がまだnull
のままです。setTimeout
を用いた些細なハックでこの問題を隠蔽してみます。
function Promise(fn) { var callback = null; this.then = function(cb) { callback = cb; }; function resolve(value) { setTimeout(function() { callback(value); }, 1); } fn(resolve); }
ハックを用いることで、我々の実装は何とか動くようになりました。
このコードは脆いし、悪い
ここまでの単純で貧弱なプロミスの実装は機能させるために非同期を用いなければなりません。この実装をもう一度失敗させるのは簡単です、then()
を非同期にコールすると、コールバックが再びnullとなってしまいます。ここで、すぐに失敗してしまう実装を一度示したのは、ここまでの実装がthen()
とresolve()
の重要性の理解をとても容易にするためです。これらはプロミスのキーとなるコンセプトです。
プロミスは状態を持っている
上記脆い実装は予期せずしてある事実を明らかにしています。プロミスは状態を持っています。実装を先に進める前に、プロミスがどんな状態を取り得るのか知っておく必要があります、そうすればこれら状態の間を正しく行き来することが可能になります。プロミスの取り得る状態を知ることで、我々の実装から脆さを取り除くことができます。
- プロミスは値を待つために
pending
になり、また値と共にresolved
になることができる- 一度プロミスが値に解決されたら、プロミスはずっとその値を維持する、再度解決されることはない
(プロミスはまた拒否されることもあるが、このことについては後ほどエラーハンドリングの節で触れます)
プロミスの状態を明示的に追跡するよう実装を変更します、こうすることでハックを取り除くことができます
function Promise(fn) { var state = 'pending'; var value; var deferred; function resolve(newValue) { value = newValue; state = 'resolved'; if (deferred) { handle(deferred); } } function handle(onResolved) { if (state === 'pending') { deferred = onResolved; return; } onResolved(value); } this.then = funcition(onResolved) { handle(onResolved); }; fn(resolve); }
実装は複雑になりました、しかし呼び出し元はthen()
を好きな時に実行することができ、呼ばれた側はresolve()
を好きなときに実行することができるようになりました。この変更で同期または非同期いずれにも対応できるようになっています。
これはstate
フラグのおかげです。then()
とresolve()
は共に新しいメソッドhandle()
に処理を移譲しています、handle()
は状況に応じて以下のいずれかを行います:
- 呼ばれた側が
resolve()
を呼ぶ前に呼び出し元がthen()
を呼んだ場合、この場合返却すべき値はまだありません。このときstatus
はpending
であり、呼び出し元のコールバックを後で使えるように保持しておきます。後でresolve()
が呼ばれた時に、コールバックを実行し、このコールバック経由で値を渡すことができます。 - 呼び出し元が
then()
を呼ぶ前に呼ばれた側がresolve()
を呼んだ場合: この場合は結果の値を保持しておきます。続いてthen()
が呼ばれたときには既に値を返す準備ができています。
setTimeout
がなくなっていることに気づいたでしょうか?setTimeout
は後ほどまた登場しますが、一時に一事です。
プロミスではプロミスのメソッド呼び出しの順序は問題にはなりません。
then()
とresolve()
は目的にかなった時にいつでも自由に呼び出すことができます。これは結果の値の概念をオブジェクトに取り込んだことによる強力なメリットの一つです。
まだ実装すべき仕様は多く残っていますが、ここまでで既にとても強力なプロミスの実装ができています。then()
を好きなだけ何回でも呼ぶことができ、毎回同じ値を得ることができます。
var promise = doSomething(); promise.then(function(value) { console.log('Got a value:', value); }); promise.then(function(value) { console.log('Got the same value again:', value); });
このプロミスの実装において、このことー
then()
を何回でも呼ぶことができるーは完全には正しくはありません。もし反対のことが起きたら、つまりresolve()
が呼ばれる前に呼び出し元がthen()
を複数回呼んだ場合、then()
呼び出しの最後の一回しか信用することはできません。プロミスの中に実行中のdeferredを一つではなく、リストで保持することでこの問題は解決できます。ここでは話をシンプルにするためにこの修正は行なっていません。
プロミスのチェイン
プロミスがオブジェクトの中に非同期の概念を取り込んでいるため、プロミスをチェインして、マップして、並列に又は連続に実行して…数々の便利なことを実現できます。プロミスを利用していると以下のようなコードをよく見かけます
getSomeData() .then(filterTheData) .then(processTheData) .then(displayTheData)
getSomeData()
はプロミスを返却しています、これはひき続くthen()
呼び出しから読み取れます、しかし最初のthen()
の結果もまたプロミスである必要があります、1度目のthen()
の結果に対し再度then()
が呼ばれているからです(2度も!)。then()
でプロミスを返却するように修正できれば、物事はもっと面白くなります。
then()
は常にプロミスを返す
以下はチェインをサポートするようアップデートしたプロミスの実装です
function Promise(fn) { var state = 'pending'; var value; var deferred = null; function resolve(newValue) { value = newValue; state = 'resolved'; if (deferred) { handle(deferred); } } function handle(handler) { if (state === 'pending') { deferred = handler; return; } if (!handler.onResolved) { handler.resolve(value); return; } var ret = handler.onResolved(value); handler.resolve(ret); } this.then = function(onResolved) { return new Promise(function(resolve) { handle({ onResolved: onResolved, resolve: resolve }); }); }; fn(resolve); }
かなり実装が複雑になって来ました。インクリメンタルにゆっくりとこの実装を組み立ててきてよかったと思いませんか?この変更でのキーポイントはthen()
が新しいプロミスを返却していることです。
then()
は常に新しいプロミスオブジェクトを返すので、少なくとも常に一つのプロミスは作成され解決され無視されます。これは無駄なことに見えるかもしれません。コールバックによるアプローチはこの問題を孕んでいません。これはプロミスに対し繰り返し言及される問題の一つであり、いくつかのJavaScriptコミュニティがプロミスを避ける理由の一つです。
2つ目のプロミスはどんな値に解決されるのでしょうか? 2つ目のプロミスは最初のプロミスの返却値を受け取ります 。これは実装コードのhandle()
の終わりで確認できます。handler
オブジェクトはonResolved
コールバックと一緒にresolve()
への参照を持っています。一つ以上のresolve()
のコピーが生成されて、各プロミスは自分のためのresolve()
のコピーと、そのresolve()
のコピーを実行するためのクロージャを持っています。これが最初のプロミスと2つ目のプロミスとの橋渡しとなっています。最初のプロミスは以下の行で終えることができます:
var ret = handler.onResolved(value);
例の中ではhandler.onResolved
は以下です
function(value) { console.log("Got a value:", value); }
別な言い方をすれば、これが最初のthen()
呼び出しの時にコールバックの引数として渡されます。この最初のハンドラの戻り値が2つ目のプロミスを解決するために用いられます。こうしてチェインが実現されています。
doSomething().then(function(result) { console.log('first result', result); return 88; }).then(function(secondResult) { console.log('second result', secondResult); }); // 出力は // // first result 42 // second result 88 doSomething().then(function(result) { console.log('first result', result); // 明示的に何も返さない }).then(function(secondResult) { console.log('second result', secondResult); }); // 今度の出力は // // first result 42 // second result undefined
then()
は常に新しいプロミスを返却するので、このチェインは好きなだけ深くすることができます。
doSomething().then(function(result) { console.log('first result', result); return 88; }).then(function(secondResult) { console.log('second result', secondResult); return 99; }).then(function(thirdResult) { console.log('third result', thirdResult); return 200; }).then(function(fourthResult) { // 続く… });
ではこの例でもし全ての結果が最後に欲しくなったときはどうするのでしょうか?チェインでは手動で結果を持ち回す必要があります。
doSomething().then(function(result) { var results = [result]; results.push(88); return results; }).then(function(results) { results.push(99); return results; }).then(function(results) { console.log(results.join(', '); }); // 出力は // // 42, 88, 99
プロミスは常にひとつの値に解決される。もし一つ以上の値を渡したい場合、何らかの形で多値を作る必要がある(配列、オブジェクト文字列の連結、など)
より良い解決策としてはプロミスライブラリのall()
メソッドか、プロミスの利便性を向上させるその他の数多くのユーティリティメソッドを用いることです、これらライブラリのAPIの探索はあなたに譲ります。
コールバックはオプション
then()
の引数に渡すコールバックは必須ではありません。もし与えられなかった場合、プロミスはひとつ前のプロミスと同じ値に解決されます。
doSomething().then().then(function(result) { console.log('got a result', result); }); // 出力は // // got a result 42
この部分の処理はhandle()
の中で確認できます、コールバックが無い場合、handle()
は単純にプロミスを解決して処理を終えています。この時のvalue
は前のプロミスの値のままです。
if(!handler.onResolved) { handler.resolve(value); return; }
チェインの中でプロミスを返却する
ここまでのチェインの実装は少し愚直です。渡されたresolved
な値を闇雲にひき続く処理に渡していっています。解決される値の一つがプロミスの場合どうなるでしょうか。例えば
doSomething().then(result) { // doSomethingElseはプロミスを返却する return doSomethingElse(result) }.then(function(finalResult) { console.log("the final result is", finalResult); });
現状では、上記コードは期待する通りに動きません、finalResult
は実際には完全に解決された値にはならず、プロミスのままでしょう。期待される結果を得るためには以下の様に記述する必要があります
doSomething().then(result) { // doSomethingElseはプロミスを返却する return doSomethingElse(result) }.then(function(anotherPromise) { anotherPromise.then(function(finalResult) { console.log("the final result is", finalResult); }); });
このような冗長なコードを書きたたがるプログラマがいるでしょうか?プロミスの実装でシームレスにこれをハンドルするよう修正してみます。修正は単純で、resolve()
の中で解決された値がプロミスの場合を特別扱いするようにします
function resolve(newValue) { if (newValue && typeof newValue.then === 'function') { newValue.then(resolve); return; } state = 'resolved'; value = newValue; if (deferred) { handle(deferred); } }
プロミスが返却される限り再帰的にresolve()
を呼び出し続けます、一度プロミスではないものが返却されたら元の通り処理が続きます。
これは無限ループになる可能性を孕んでいます。Promises/A+ specでは実装に無限ループを検知することを推奨していますが、要求はしていません。
また、この実装は仕様に準拠していません。またこの文章中の実装全てこの点について仕様に完全に準拠しているわけではありません。さらなる理解のためにはプロミスの解決処理を読むことをおすすめします。
newValue
がプロミスかどうか判定するための処理がとてもルーズなものであることに気づいたでしょうか?then()
メソッドの存在だけを確認しています。このダックタイピングは意図的なものです。こうすることで異なるプロミスの実装が相互に対話することが可能になります。実際に複数のプロミスライブラリの混合は普通に起こり得ることです、あなたが利用するサードパーティ製のライブラリは、それぞれが異なるプロミスの実装を用いている可能性があるためです。
異なるプロミスの実装はそれらが仕様に準拠している限り相互に対話することができる
チェインを手に入れて、我々のプロミスの実装はかなり完成に近づきました。でもこれまでエラーハンドリングについて一切考慮できていません。
プロミスをリジェクトする
プロミスは処理中に都合が悪くなったら 理由 と共に 拒否 する必要があります。呼び出し元はこれが発生した時にどのようにしてこのことを知ることができるでしょうか?呼び出し元はthen()
の第二引数にコールバックを渡すことでこれを知ることができます。
前に言及したとおり、プロミスは pending から resolved 又は rejected のいずれかの状態に遷移する、両方には遷移しない。言い換えれば、上記コールバックのいずれか一つのみが呼ばれる。
プロミスはreject()
によって拒否を可能にします、reject()
はresolve()
の邪悪な双子です(evil twin)。以下はdoSomething()
にエラーハンドリングのサポートを加えたものです
function doSomething() { return new Promise(function(resolve, reject) { var result = somehowGetTheValue(); if (result.error) { reject(result.error); } else { resolve(result.value); } }); }
プロミスの実装では拒否をハンドリングする必要があります。プロミスが拒否されたらすぐに、それ以降の全ての下流の(downstream)プロミスもまた拒否される必要があります。
以下は拒否をサポートした完全なプロミスの実装です
function Promise(fn) { var state = 'pending'; var value; var deferred = null; function resolve(newValue) { if (newValue && typeof newValue.then === 'function') { newValue.then(resolve, reject); return; } state = 'resolved'; value = newValue; if (deferred) { handle(deferred); } } function reject(reason) { state = 'rejected'; value = reason; if (deferred) { handle(deferred); } } function handle(handler) { if (state === 'pending') { deferred = handler; return; } var handlerCallback; if (state === 'resolved') { handlerCallback = handler.onResolved; } else { handlerCallback = handler.onRejected; } if (!handlerCallback) { if (state === 'resolved') { handler.resolve(value); } else { handler.reject(value); } return; } var ret = handlerCallback(value); handler.resolve(ret); } this.then = function(onResolved, onRejected) { return new Promise(function(resolve, reject) { handle({ onResolved: onResolved, onRejected: onRejected, resolve: resolve, reject: reject }); }); }; fn(resolve, reject); }
reject()
関数自信の追加に加えて、handle()
内部で拒否をハンドルする必要があります。handle()
の中で、state
の値に依存して拒否するかそれとも解決するか決定しています。引き続くプロミスのresolve()
又はreject()
呼び出しが自分自身のstate
値を適宜に設定できるように、このstate
の値は次のプロミスに渡されていきます。
プロミスを利用する際、エラーのコールバックを渡し忘れるのは容易なことです。もしそうした場合、プロミスの処理中に都合の悪いことが起こっている兆候を 一切 知ることができなくなります。せめて、チェインの最後のプロミスにはエラーコールバックを指定するべきです。飲み込まれたエラーについては以降の説で詳しく説明します。
予期せぬエラーが発生した場合も拒否されること
ここまでのプロミスの実装は既知のエラーに対してしか責任をとっていません。unandled exception
は発生し得るし、その場合全てがクラッシュしてしまいます。プロミスの実装はこれらの例外をキャッチして適宜拒否することが必要不可欠です。
そのためにはresolve()
をtry/catchブロックでくるむ必要があります
function resolve(newValue) { try { // ... 前の通り } catch(e) { reject(e); } }
また呼び出し元から渡されたコールバックがunandled exception
を発生させないことを保証する必要もあります。これらコールバックはhandle()
の中で呼ばれています、最終的な実装は以下のようになります
function handle(handler) { // 前と同じ var ret; try { ret = handlerCallback(value); } catch (e) { handler.reject(e); return; } handler.resolve(e); }
プロミスはエラーを飲み込む
プロミスを正しく理解していないとエラーがプロミスに飲み込まれてしまう、これはよく陥る罠である
以下の例を考えてみます
function getSomeJson() { return new Promise(function(resolve, reject) { var badJson = "<div>uh oh, this is not JSON at all!</div>"; resolve(badJson); }); } getSomeJson().then(function(json) { var obj = JSON.parse(json); console.log(obj); }, function(error) { console.log('uh oh', error); });
何がおきたのでしょうか?then()
の中のコールバックは有効なJSONを期待しています。そのため単純に引数にもらったjson
をパースしようとしていますが、ここで例外が発生します。でもエラーコールバックはちゃんと指定して有ります、そのためこのコードは問題ないのではないでしょうか?
いいえ、 このエラーコールバックは実行されないだろう fiddleでこの例を実行してみると、出力は何も得られないはず。エラーは置きないし、何も置きない。純粋に 寒気のする 静けさだけが残る(Pure chilling silence)
なぜでしょうか?unhandled exception
はthen()
のコールバックの中で発生しているため、これはhandle()
の中でcatchされます。そしてhandle()
は今対応中のプロミスではなくthen()
の返却したプロミスを拒否します、今対応中のプロミスは既に解決済みのため拒否されません。
then()
のコールバックの中では、対応中のプロミスは既に解決されていることを常に覚えているべきだ。コールバックの結果はこのプロミスに対し何の影響も与えない
上記エラーを捕まえたい場合、さらに下流でエラーコールバックを渡す必要があります。
getSomeJson().then(function(json) { var obj = JSON.parse(json); console.log(obj); }).then(null, function(error) { console.log("an error occured: ", error); });
これで正しくエラーをログできるようになりました。
筆者の経験では、これがプロミスの最も大きな落とし穴であす。より良い解決策について次の節で説明します。
レスキューのためのdone()
ほとんどの(全部ではないが)プロミスライブラリはdone()
メソッドを持っています。これは上記then()
の落とし穴を回避する点を除いて、then()
にとても良く似ています。
done()
はthen()
が呼べるタイミングではいつでも呼べます。異なる点はdone()
はプロミスを返却しないこと、そしてdone()
の中で発生したunhandled exception
はプロミスの実装側ではcatchされないことです。別な言い方をすれば、done()
は全体のプロミスチェインが完全に解決されたタイミングを表現しています。先ほどのgetSomeJson()
の例はdone()
を用いることでよりロバストなコードに修正できます。
getSomeJson().done(function(json) { // これが投げるとき、それは飲み込まれない var obj = JSON.parse(json); console.log(obj); })
done()
もまたthen()
と同じくエラーコールバックを引数に取りますーdone(callback, errback)
ーそして全てのプロミスが解決ーdoneされているので、発生したどんなエラーでも知ることができます。
done()
は(少なくとも今は)Promises/A+ specの一部ではない、なのであなたの選択するライブラリはこれを持っていないかもしれない
プロミスの解決は非同期である必要がある
最初の方の実装でsetTimeout
を用いたハックを行いました。一度このハックを取り除いた後これまでsetTimeout
は登場してきませんでした。しかし実際には、Promises/A+ specではプロミスの解決は非同期に行うことを要求しています。単純にhandle()
の実装をsetTimeout
コールにくるめばこの要求を満たすことができます。
function handle(handler) { if(state === 'pending') { deferred = handler; return; } setTimeout(function() { // ... as before }, 1); }
これで要求に準拠することができました。実際には現実のプロミスライブラリはsetTimeout
を使わない傾向にあります。ライブラリがNodeJS向けの場合はprocess.nextTick
、ブラウザ向けであれば新しいsetImmediate
かsetImmediate shim(setImmediate
これまでIEのみがを具備していました)、又はKris Kowalのasapのような非同期ライブラリを利用している可能性があります(Kris Kowalは有名なプロミスライブラリであるQの作者です)。
なぜ仕様はプロミスの解決に非同期を要求するのか
プロミスの解決を非同期にすることで、実行フローに一貫性と信頼性を保証することができるようになります。以下の混みいった例を考えてみます
var promise = doAnOperation();
invokeSomething();
promise.then(wrapItAllUp);
invokeSomethingElse();
ここでのコールフローはどうなっているでしょうか?関数の名前から、invokeSomething()
-> invokeSomethingElse()
-> wrapItAllUp()
のフローが推測できます。しかし今の我々の実装では、コールフローはプロミスが同期に解決されるか非同期に解決されるかに依存して変わります。もしdoAnOperation()
が非同期に動く場合、コールフローは推測通りです。でももし同期に動いた場合、実際のコールフローはinvokeSomething()
-> wrapItAllUp()
-> invokeSomethingElse()
であり、期待したものにはならないでしょう。
上記のような状況に対応するために、プロミスはたとえ必要なくとも常に非同期に解決されます。プロミスを非同期にすることで、利用者の驚きを減らし、利用者が自分のコードを理解するときに非同期性について考えずにプロミスを利用することを可能にします。
まとめの前に … then/promise
多くのフル機能のプロミスライブラリが存在します。then organizationのpromiseライブラリはシンプルなアプローチをとっています。このライブラリは仕様に準拠すること、それ以上を具備しないことを目標としています。このライブラリの実装を見れば、見慣れたコードに見えると思います。then/promiseはこの文章中で実装したプロミスの基礎であり、我々は ほとんど 同じプロミス実装を組み立ててきました。Nathan ZadoksとForbes Lindsayに、彼らの素晴らしいライブラリとJavaScriptのプロミスへの働きかけについて感謝します。Forbes Lindsayはまた冒頭で言及したpromise.orgサイトの貢献者でもあります。
実際の実装とこの文章での実装では幾つかの相違点があります。これはPromises/A+ specの中にまだこの文章中で触れていない詳細がもっとあるためです。仕様を読んで見ることをお勧めします、この仕様は短く直感的です。
まとめ
ここまで読んでくれてありがとうございます。私達はプロミスの核となる部分をカバーしました、そしてこの核のみが仕様に記載されている内容です。多くの実装はもっとたくさんの機能を提供しています、all()
、spread()
、race()
、denodeify()
、など他にも多くの機能があります。プロミスで実現可能な事を知るためにBluebirdのAPIドキュメントを見てみることをお勧めします。
一度、プロミスがどう機能しているのか、そしてプロミスを利用する際の注意点を理解したら、私はプロミスを本当に好きになりました。プロミスは私のプロジェクトにおけるコードをとてもクリーンでエレガントなものにしてくれました。まだ話すべきことはたくさんあります、この文章は始まりに過ぎません。
もしこの文章が面白かったら。私をTwitter上でフォローしてください、このようなガイドをまた書いた時にツイートします。
参考文献
プロミスに関し優れた文献が多く有ります
- promisejs.org — プロミスに関する素晴らしいチュートリアルがあります(本文中でも度々言及しました)
- Qの設計の理論的根拠 — 本文章によく似ていますが、より詳細に立ち入っています。Qの作者であるKris Kowalが記述しています
- done()は良いかをめぐる議論
- プロミスチェインをflattenにする — Thomas Burlesonによる、プロミスの一歩進んだ利用法について述べている素晴らしい文章です。本文章が「何」に関して述べているとすれば、Thomasの文章は「なぜ」について考察しています