Loading
BLOG 開発者ブログ

2023年8月7日

なぜPromiseは難しいのか

JavaScript

はじめに

こんにちは。
クラウドソリューショングループのnakamura.tです。

JavaScriptを触っていると避けては通れないのが非同期処理の概念ですね。
しかしネットや本で調べてもいまいち言葉が難しくて初学者の方は躓きやすいところだと思います。
なかでも今回は特にとっつきにくいPromiseについて取り扱っていきます。

目次

非同期処理とは?

JavaScriptは非同期プログラムであり、Promiseの話に入る前に最低限理解している必要があるため簡単に同期/非同期処理について簡単に解説します。

● 同期処理
同期処理とは、タスクの実行が順番に進行し、前の処理が完了するまで次の処理が開始されない処理のことを指します。
レストランを例に挙げると以下のようになります。
この例では一つの注文が完了するまで次の注文を受け取ることができず、注文と料理提供が順番に行われます。

  1. お客さんAが注文を出す。
  2. ウェイターが注文をキッチンに伝える。
  3. キッチンで料理が作られる。
  4. 料理が完成したら、ウェイターがお客さんAに提供する。
  5. お客さんAが料理を受け取り、食事を楽しむ。

● 非同期処理
非同期処理は反対に前のタスクの処理完了を待たずに、次の処理が開始される処理のことを指します。
同じくレストランで例を挙げると以下のようになります。
この例では複数の注文が同時に処理され、各タスクが順不同に進行します。注文と料理提供が同時に行われるのが特徴です。

  1. お客さんAが注文を出す。
  2. ウェイターが注文をキッチンに伝える。
  3. 同時に、お客さんBが注文を出す。
  4. ウェイターが注文をキッチンに伝える。
  5. キッチンでお客さんAの料理が作られる。
  6. 料理が完成したら、ウェイターがお客さんAに提供する。
  7. キッチンでお客さんBの料理が作られる。
  8. 料理が完成したら、ウェイターがお客さんBに提供する。

Promiseとは?

Promiseとは一体何でしょうか。非同期処理やコールバック関数について調べたら出てくる言葉だな~くらいに思われている方もいるかもしれません。
一言で表すと、「非同期処理を効率的に扱えるようにするオブジェクト」です。
どういうことかもう少しかみ砕くと、Promiseに対して出来る事というのは「値の準備ができたときに、コールバック関数を呼び出すように指示を出すこと」です。
コールバック地獄なんて聞いたことがあるかもしれませんが非同期プログラミングの問題である、コールバックの中にコールバックがある深い入れ子状態になることで、可読性が損なわれたり、例外の処理が難しくなる事象を解決するために生まれたものであるわけです。

Promiseの躓きポイント

それではここから本題であるPromiseの躓きポイントを4つ紹介していきます。

①Promiseは単一の非同期の計算処理結果を表し、繰り返しコールバックを呼び出すような処理の実現は出来ない

PromiseはsetTimeout()関数を使用したような単一の非同期の計算処理結果を表すことはできますが、setInterval()関数のような繰り返し同じコールバック関数を呼ぶような処理は想定されていません。

②new Promiseの記述がないのに、then()やcatch()を使うことができる

一般的に初めてPromiseの書き方を調べると、

// 例1)
// Promiseを新規作成
const promise = new Promise((resolve, reject) => {
  // 非同期処理を行う
  // 成功時は resolve() を呼び出し、結果を渡す
  // 失敗時は reject() を呼び出し、エラーを渡す
});
// 成功時と失敗時のハンドリング
promise.then((result) => {
  // 成功時の処理
  console.log('成功:', result);
}).catch((error) => {
  // 失敗時の処理
  console.error('エラー:', error);
});

このようにまず初めに新規でPromiseを作成し、その後then()やcatch()で成功時失敗時の処理を記述していく流れを見ることが多いかもしれません。

// 例2)
fetch(URL)
  .then(コールバック1)
  .then(コールバック2)
  .catch(例外処理);

しかし、実際にソースコードを読んでいると例2のようにPromiseを作成していないのにthen()やcatch()を使用している記述を見ることがあります。
これは何が起きているのでしょうか。Promiseと記法が似ている全く違う構文なのでしょうか。

答えは否です。これもれっきとしたPromiseの構文です。

ここで理解が必要なことは、then()やcatch()はPromiseオブジェクトを返すものに対して使用することができるということです。(catch()などはtry/catch構文でも使用しますがここでは省略します。)
例1のように明示的に記述してあれば、これはPromiseオブジェクトを返すんだなと直感的に理解できると思います。ですが、例2はぱっと見ただけでは理解しずらいですよね。
それは例2で使用されているfetch()関数はPromiseベースで作成されたFetch APIだからです。この関数にURLを渡すとPromiseオブジェクトが返却されるようになっているわけですね。
Promiseオブジェクトが返されているのがわかれば後述の処理も理解できるようになりましたね。
他にもPromiseオブジェクトが返される関数としてjson()やtext()などがあります。

③catch()はただエラーを報告するためのものではなく、エラーから回復するためのものでもある

catch()の使い方なんてJavaScriptを使っている方なら当然知っていますよね?そうです例外処理をする際によく使用しますね。今回のテーマであるPromiseでなくてもtry/catchなどでも使用することがあり馴染みのあるものだと思います。
しかし、Promiseで使用するcatch()はエラーの例外処理をする以外活用方法があります。それは、エラーからリカバリすることです。
どういうことか見ていきましょう。

// 例1)
fetch(URL)
  .then(コールバック1)
  .then(コールバック2)
  .catch(例外処理);

例1は一般的に見慣れた書き方だと思います。処理の流れを1つずつ見ていくと

  1. fetch()を実行し、Promiseオブジェクトを返却
  2. 正常だった場合コールバック1を実行
  3. 正常だった場合コールバック2を実行
  4. 流れのどこかで不具合が起きた場合catch()で受取り例外処理を走らせる

では次の例ではどうでしょうか。

// 例2)
fetch(URL)
  .then(コールバック1)
  .catch(e => wait(1000).then(コールバック1)) // リカバリ処理
  .then(コールバック2)
  .then(コールバック3)
  .catch(例外処理);

例2ではcatch()が途中にも挟まっていますね。ここで知っておきたいことはcatch()は一番最後に置くものだと思っている方もいらっしゃるかもしれませんが、あくまでcatch()を記載した部分より上でエラーが発生した際にその内容を拾って処理を走らせるものなわけですね。
そのため一番後ろではなくても途中に入れ込むことも可能なのです。途中で入れ込むことによって例2のように特定の処理のみを拾ってリカバリをしてあげることが可能になります。
では、どのようにリカバリすることができるのか考えていきます。

例えば、あるシステムがありそこのサーバーはネットワーク負荷によって極低確率で処理が失敗することがあります。単純な解決策として何度かお問い合わせをやり直せば正常に動作することが考えられるのであれば、catch()を使用して一定時間置いた後に再実行してあげることで正常な流れに戻してあげることができます。

少し難しいですが、以上を踏まえて例2の処理を1つずつ追っていきましょう。

  1. fetch()を実行し、Promiseオブジェクトを返却
  2. 正常だった場合コールバック1を実行
  3. コールバック1がネットワーク負荷でエラーで返却されたため、1つ目のcatch()で1秒おいて再度コールバック1を実行
  4. リカバリ処理が正常だった場合コールバック2を実行
  5. 正常だった場合コールバック3を実行
  6. 流れのどこかで不具合が起きた場合catch()で受取り例外処理を走らせる

リカバリ処理を入れないと本来であればコールバック1でエラーが返されたタイミングでエラーが報告され処理が終了してしまいます。しかし、あらかじめリカバリ処理を入れておくことでそのエラー報告を回避することができるようになりました。

④アロー関数の使い方に気を付ける

Promiseを用いた構文でももちろんアロー関数を使用することができます。しかし、使い方を誤ると一見無害に見えるのに想定通り動かないといった事象が起こりえるので、この章で解説していきます。

// 例1)正しく動く例
.catch(e => wait(1000).then(コールバック))

まず、上記の例は正しく動く例となっています。引数は1つのみのため丸括弧は省略することができます。
次に以下の例を見てみましょう。

// 例2)想定外の動きをする可能性のある例
.catch(e => { wait(1000).then(コールバック) })

違いがわかりましたでしょうか。
例2には例1にはなかった中括弧がついていますね。中括弧がつくと、戻り値が自動的には返還されなくなります。
つまりこの関数はPromiseを返すのではなくundefinedを返すようになってしまいます。
これが処理の最後であるならば問題はないかもしれません。しかし、前章で紹介したようにcatch()をリカバリー目的で使用している場合は話が変わります。
catch()に続く次の処理が呼び出される場合、その入力にはundefinedが渡されることとなります。
これでは想定した動きにはならない可能性が高いですよね。
この不具合は発見が難しいため、ここでしっかりと押さえておきましょう。

おわりに

Promiseは基本的な書き方を実装するだけであればそこまで苦ではないかもしれませんが、ちゃんと理解しようとするととても難解です。
特にJavaScriptに触れたばかりの方や基本を学習し終えたばかりの方からすると遠ざかるになる元凶かもしれません。
しかし非同期をきちんと理解すれば、今よりはるかに上達することができるのであきらめずに学んでいきましょう。


nakamuratのブログ