JavaScriptの学習者の間では、私自身を含めて、非同期処理のPromiseのしくみがどうも解りにくい印象があるようです。この記事では、Promiseのしくみを直観的に理解することを目的として、同期処理と非同期処理のおさらいからPromiseまでを、食堂(ショッピングモールなどにあるフードコート)の注文をモチーフにして書いてみたいと思います。
話をシンプルにするためにエラー処理に関してはほぼ記述しないことにします。また、プログラミングのコードは説明目的で書いた架空のコードで、コードそのものはあまり役に立たないかもしれません。
同期食堂
その昔「同期食堂」という食堂がありました。食べ物は美味しいのですが、注文のやりかたが少し不評でした。お客がメニューを注文すると、その場でじっと完成するまで待たされるのです。
1つのメニューに10分待たされるとすると、すべの料理ができるまで40分かかります。
1 | // 12:00に注文開始 |
お客さんは待っている間に他のことが何もできなくなって(ブロッキングされて)しまうのです。そこでその問題を解決すべく、非同期という考えを使った新たな食堂が生まれました。
コールバック食堂
コールバック食堂では「非同期」という概念が取り入れられて、お客さんは注文を済ませたらその場を離れてもいいというルールになりました。
その場を離れていても、料理が完成した時にお店の人が「お客さーん、できましたよ」と呼び戻してくれるので、呼ばれるまでの時間は他のことをして自由に過ごすことができるのです。(ノンブロッキング)
しかし、少し分かりにくいという声もありました。注文をするときに、「料理を受け取ったらどうするか」という計画を「コールバック関数」という形でお店に渡す必要があるのですが、その書き方がどうも直感的に分かりにくいのです。
タイミングがわかりにくい問題
先ず、直感的に少し分かりにいという声があった点は、コールバック関数の実行タイミングについてでした。例えば、次のような非同期に実行結果を返すasyncOrder関数があったとします。
1 | function asyncOrder(menu, callback){ |
この関数を以下のように呼び出してみます。
1 | console.log("注文"); |
このコードを実行すると、以下の順番でメッセージが表示されます。
1 | 注文 |
「ピザを受け取って食べる」は非同期処理が完了してから最後に遅れて表示されますが、この点がどうも間違いやすい気がします。
コールバック関数が深くなる問題
さらに、コールバック食堂に問題がでてきました。それは複数のメニューを「直列に」注文するときに起こりました。「直列」というのは例えば、先ずピザを注文して、出来上がったピザを見てから次の料理を注文するというように、時間軸に対して直列に、注文→完成→次の注文というイメージです。
もし、かつての同期食堂だったら以下のように書くだけで済むので、記述のわかりやすさという点ではこちらの方がよかったかもしれません。
1 | /* |
これを、コールバック食堂では次のように書きます。
1 | /* |
この書き方だと、直列の注文が増えるとコールバック関数のネストがどんどん深くなってしまいます。これらの問題は、お客たちの間で「コールバック地獄」と揶揄されるようになってしまいました。そして数年の月日が流れ、この問題を解決すべく新たな食堂が誕生しました。
プロミス食堂
プロミス食堂では、お客が料理を注文をしたときに「プロミスオブジェクト」という呼び出しベルのようなものを渡すことにしました。お客さんはそのオブジェクトを受け取って、料理が完成するのを待ちます。
このオブジェクトには調理の状況(status)を表すランプが3つ付いています。
・準備中(Pending)
・完成(Fulfilled)
・失敗(Rejected)
プロミスオブジェクトを受け取ったときの初期値はPendingになっています。料理が完成したときに、コックさんが「resolve でーす」と言って料理を差し出すとstatusはFulfilledに変わります。お店側で何かトラブルが発生して料理ができないときには「reject します」と言って、statusはRejectedに変わります。
また、このプロミスオブジェクトはただの呼び出しベルとは違い、then()というメソッドを持っています。then()は料理ができた時に実行され、料理を届けてくれます。
プロミス食堂では、料理を受け取るときの処理(コールバック関数)をthen()のところに書きます。
1 | // 1枚目のピザを注文する |
また、then関数→次の処理→then関数→次の処理→というようにチェーンを使って書くこともできます(Chaining)。なぜ、Chainingできるのかというと、then関数の戻り値でプロミスオブジェクトを返し、また次のthen関数の戻り値でもプロミスオブジェクトを返すというようなしくみになっているからです。
1 | // 1枚目のピザを注文する |
このようにプロミス食堂では深いネストを使わずに処理を書くことができるようになりました。
上の例では、お店の客側の立場としての処理を書きましたが、料理を作る側の処理も少し見ておきましょう。
お店側の流れ
お店側はどのような流れになっているでしょうか。注文をとったときにプロミスオブジェクトを返すところを見てみましょう。
- ピザの注文が入る
- レシピを「プロミスオブジェクト生成機(Promiseクラス)」に渡す。レシピには調理の手順が書いてある。完成したらresolve()を実行し、失敗したらreject() を実行する。
- プロミスオブジェクトをnewする
- プロミスオブジェクトを返す(お客さんに渡す)
1 | /* |
レシピとなる関数の目的は調理の計画を登録することで、基本的なフォーマットは決まっています。
先ず、パラメータを2つ受け取ります。
第1引数:料理が成功した時に実行するresolve関数。
第2引数:料理が失敗した時に実行するreject関数。
※この部分は引数名なので、他の名前(例:res,rej)にすることもできます。
1 | // レシピ |
そして、調理の手順を書いて。。。
成功したら resolve() 、失敗したら reject() を実行します。
1 | // レシピ |
このレシピ関数を、「プロミスオブジェクト生成機(Promiseクラス)」へのパラメータとして渡すので、asyncOrderPromise関数は以下のような形になります。
1 | /* |
なかなか複雑ですね。
プロミス食堂のコードまとめ
プロミス食堂のコードをまとめると以下のようになります。
1 | /* |
実行結果は以下のようになります。
1 |
|
これで、Promise.all の話をする準備が整いました。
Promise.all
プロミス食堂に家族連れが来て「ピザ2枚とうどん2杯」を注文しました。
今回は4つの料理を同時に作ってもいいという話です。つまり直列ではなく並列です。
注文の受け付けでは、すぐに4つのプロミスオブジェクトを返しました。
1 | var promise_1 = asyncOrderPromise("Pizza");// ピザ1枚目 |
お客さんは4つのプロミスオブジェクトを受け取りました。
ここで、2つのパターンの受け取りかたを考えてみます。
1つずつ受け取るパターン
1つ目は、料理が完成した都度、個別に料理を受け取るパターンです。
最初にピザが1枚だけ完成した場合は、こんな感じのイメージ図になります。
まとめて受け取るパターン
2つめのパターンは、料理が全て揃うまで待ってから受け取るパターンです。4つの料理ができる順番はどうでも構いません。
このように、複数の並列処理が全て完了するのを待ってから次の処理に移りたいときに、とても便利な関数があります。それがPromise.all です。
Promise.all は、複数のプロミスオブジェクトを1つにまとめて、5番目のプロミスオブジェクトをつくってくれます。
1 |
|
しかも気が利いているのは、5番目のthenでは、全ての料理を配列にいれて渡してくれるのです。
1 | promise_5.then(function(values){ |
今回の記事では、JavaScriptの非同期のしくみを食堂をモチーフに説明してみました。 次回は、この流れでasync/awaitの話を記事にしてみたいと思います。