Promiseの本質は「ネスト解消」ではない
Promiseとは何であり、何でないか
よくある説明と疑問
JavaScript Promise を初めて学んだとき、こう説明されたことがあるだろう。
コールバックはネストが深くなり読みにくくなる。(コールバック地獄)
この問題を解決するのがPromiseである。
そして次のような疑問を感じたことはないだろうか。
- なぜネスト解消に "約束" という名前がついたのか? 名前おかしくない?
- なぜPromise登場後もずっとコールバックと共存してるのか?
本稿は、見過ごされがちなPromiseの本質に光を当て、このような疑問を解消する試みである。
ネスト問題は表層
コールバック地獄の見た目のネストは本質的な限界というより書き方の話でもあった。
コールバックでもフラットに書くことはできる。
// 無名関数をベタ書きすると深くなる
getUser(id, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
console.log(comments);
});
});
});
// 関数分離するだけでフラットになる(Promise登場前から可能)
function onComments(comments) {
console.log(comments);
}
function onPosts(posts) {
getComments(posts[0].id, onComments);
}
function onUser(user) {
getPosts(user.id, onPosts);
}
getUser(id, onUser);
実際、async.jsのwaterfall()のようなライブラリは
ES6 より前からコールバックをフラットに繋ぐ仕組みを提供していた。
つまりPromiseの存在価値を「ネスト解消」だけに求めるのは十分とは言えない。
コールバックの本当の問題「制御の逆転」
コールバックには、見た目のインデントよりずっと根が深い問題がある。
制御の逆転(inversion of control) と呼ばれる問題だ。
コールバック関数を渡すと、それを「いつ、何回、どう呼ぶか」は呼び出し先の関数に委ねられる。 呼び出し元には制御権がなく、相手が行儀よく振る舞うことを信じるしかない。 そして次のような事故が起こり得る。
- コールバックを仕様に反して何回も呼んでしまう(バグや実装ミス)
- コールバックを一度も呼ばない(エラーも成功も握りつぶす)
- コールバックを同期的に呼ぶか非同期的に呼ぶかが関数によって(あるいは条件によって)バラバラ
Zalgo問題: 同期/非同期が混在する罠
最後の問題は特に厄介で、npmの作者の一人である Isaac Z. Schlueter が "releasing Zalgo"(Zalgoを解き放つ)と名付けたほど知られた現象である。
function getUser(callback) {
if (cache.user) {
callback(null, cache.user); // 同期的に呼ぶ
} else {
ajax('/api/user', callback); // 非同期的に呼ぶ
}
}
この関数は、キャッシュの有無によって同期にも非同期にもなる。 呼び出し側はその振る舞いの違いを意識してコードを書かなければならず、意識を怠ると次のような事故が起きる。
let value = "初期値";
getUser((err, user) => { value = user; });
console.log(value); // キャッシュありなら正しい値、なしなら"初期値"のまま
このように、同期になるか非同期になるかは関数の実装者次第だったため、
使う側はドキュメントを読むか、ソースを読むか、経験で覚えるしかなかった。
そして事故を減らすために setTimeout(fn, 0) で
非同期の振る舞いに強制的に揃える といった技法も使われていた。
Promiseが与えたものは「保証」である
Promiseの本質は、この「制御の逆転」と「Zalgo問題」を解決すために 言語仕様レベルでルールを強制した ことにある。具体的には以下が保証される。
| 保証されること | 意味 |
|---|---|
resolve/rejectは一度しか効かない |
コールバックの多重呼び出し問題が起きない |
.then()は必ず非同期(マイクロタスク)で呼ばれる |
同期/非同期の混在(Zalgo)が起きない |
| 一度確定した状態は不変 | 後から結果が書き換わる心配がない |
Promise内のエラーは.catch()に必ず伝播する |
エラーの握り潰しが起きにくい |
function getUser(useCache) {
if (useCache) {
return Promise.resolve(cache.user); // 結果がすぐ用意できても
}
return fetchFromNetwork();
}
getUser(true).then((user) => {
console.log(user); // 必ず非同期で呼ばれることが保証される
});
console.log("これは必ず上のthenより先に実行される"); // 常にこの順序が保証される
簡単に言えばPromiseは「必ず非同期で結果が一度だけ返ってくることが言語仕様で約束されたオブジェクト」である。
なぜ Promise(約束)という名前なのか
冒頭の「なぜネスト解消に"約束"という名前がついたのか?」という疑問は、ここまでの内容を踏まえると自然と解ける。 そもそもネストを解消するためのものではなかった のだ。 Promiseという名前は「非同期処理の振る舞いに関する保証」につけられた名前だった。
コールバック関数には、「いつ、何回、どのように呼ばれるか」という契約(約束)が一切存在しない。 すべては呼び出し先の関数の振る舞いに委ねられている。
一方、Promiseは「必ず非同期で、結果が一度だけ返ってくることが約束された」 状態と保証を内包した契約オブジェクトである。
この本質(保証・契約)を認識できれば、なるほどふさわしい名前を持ったと感じられるだろう。
Promiseは「コールバックの置き換え」ではなく「頻出パターンの固定化」である
ここで新たな誤解が生まれやすい。 それは「なるほど、Promiseはコールバックの弱点を克服したから、今後はPromiseを使えばいいんだな」といった、 まるでコールバックをすべて置き換えるかのような誤認だ。
Promiseが達成したのは、あくまで コールバックの多種多様な使われ方の中から、最も頻出していた1つのパターン(Promiseパターン)だけを取り出し、それを言語仕様として固定化したこと である。
コールバックは用途に応じて様々な使い方がある。
- 一度だけ呼ばれ、成功か失敗かの結果を返す(ファイル読み込み完了、APIレスポンスなど)
- 何度も繰り返し呼ばれる(クリックイベント、データストリームのチャンクなど)
- 即時かつ同期的に呼ばれる(
Array.prototype.mapやforEachなど) - 進捗のような、最終結果ではない中間状態を伝える(アップロードの進捗%など)
Promiseはこの中の最初のパターン(一度だけ呼ばれ、成功/失敗を返す)だけを切り出して、 「これについては言語が保証を与える」という形で標準化したものである。
つまり、繰り返しイベントや進捗通知のような別のパターンに対してはPromiseは適用されない。
今でもコールバックやEventEmitterが使われ続けている。
これは「Promiseが置き換えに失敗した」わけではなく、
そもそも置き換える対象として設計されていない
だけである。
これが「なぜPromiseとコールバックが共存してるのか?」の答えである。 この認識があれば今後「どんな処理をPromiseにすべきか?」の判断も自ずと見えてくるだろう。
コールバックという原始的で汎用的な仕組み
今でもコールバックが適している場面は多い。
| 種類 | 向いている技術 |
|---|---|
| 一度だけ起きる非同期処理 (API呼び出し、ファイル読み込み完了) |
Promise / async-await |
| 何度も起きるイベント (クリック、データストリーム) |
コールバック / EventEmitter |
| 即時かつ同期的に呼ぶ必要があるAPI ( forEach、mapなど) |
コールバック |
実際、Promiseの内部実装自体も最終的にはコールバックの上に作られている。
async/awaitもPromiseの上に作られたシンタックスシュガーである。
汎用性で言えば callback > Promise > async/await である。
これはgotoがforやifより汎用的であることと同じ構図だろう。
まとめ
- Promiseが解決したコールバックの本当の問題は「制御の逆転」である。 特に「同期/非同期が呼び出し側から見て統一されていない(Zalgo問題)」ことが事故の元だった。
- Promiseの本質は、「必ず非同期で実行され、一度だけ確定する」ことを言語仕様として強制したことにある。 これはコールバックの1パターン(用法)を固定化したものである。
- コールバックは「繰り返し発生するイベント」や「即時の同期処理」には今でも適している。
コールバックは優れた汎用性を持っている反面、自由度が高すぎるがゆえにコードの煩雑化や不具合を生む危険と隣り合わせでいた。
Promiseは、コールバックに頻出する1パターンに制約と保証を課すことでその問題をスマートに解決しようとする言語機能である。 「約束」という名にふさわしく、非同期処理に信頼できる契約をもたらしたと言えるだろう。
参考
- Domenic Denicola, "You're Missing the Point of Promises" (2012)
- Isaac Z. Schlueter, "Designing APIs for Asynchrony" — Zalgo問題の元ネタ
- Daniel Brain, "Intentionally unleashing Zalgo with synchronous promises" (Medium)