JavaScriptのPromiseは、非同期処理が終わるまで、その処理の代理人としてがんばってくれるオブジェクトである。おかげで、非同期処理を同期処理のようにシンプルに書ける。

導入理由

JavaScriptにPromiseが導入されたのは、非同期処理をわかりやすく書きたい、という強いモチベーションがあったからだ。古き良きsetTimeout()のような関数(正確にはwindowオブジェクトのメソッド)であれば、コールバックによる非同期処理でもさしたる問題はなかった。

js
setTimeout(() => console.log("こんにちは非同期処理"), 1000);

しかし、Node.jsランタイムの登場で非同期処理の活用シーンが爆発的に増加し、従来のコールバックによる非同期処理ではソースがえらく分かりづらくなった。

例えば以下のようなイメージである。

js
fs.readdir(source, function (err, files) {
  if (err) {
    console.log("Error finding files: " + err);
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename);
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log("Error identifying file size: " + err);
        } else {
          console.log(filename + " : " + values);
          aspect = values.width / values.height;
          widths.forEach(
            function (width, widthIndex) {
              height = Math.round(width / aspect);
              console.log(
                "resizing " + filename + "to " + height + "x" + height
              );
              this.resize(width, height).write(
                dest + "w" + width + "_" + filename,
                function (err) {
                  if (err) console.log("Error writing file: " + err);
                }
              );
            }.bind(this)
          );
        }
      });
    });
  }
});

引用元:Callback Hell

いわゆるコールバック地獄と呼ばれるコードだ。

ネストは浅い方が見通しが良い。書くのも楽だ。先行言語にはすでにFutureあるいはPromiseと呼ばれる仕組みがある。それらを参考に直感的に書ける独自のPromise APIを実装するNode.jsライブラリなどが登場した。

これは標準にすべきだ、という潮流が生まれ、現在に至る。そんな感じだろう(あれこれ読んだ上での推測)。

作成方法

JavaScriptでは、newキーワードを使ってPromise()コンストラクタを呼び出すことでPromiseオブジェクトを作成する。引数には、resolverejectという2つの引数を取る関数を渡す。余談だがこの関数はexecutorと呼ばれる。実行したい非同期処理は、executorの中に書く。

js
function createPromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      Math.random() < 0.5 ? resolve("成功した時の値") : reject("失敗した理由");
    }, 100);
  });
}

resolverejectは、非同期処理が終わった場合に、処理を次に進めるための関数オブジェクトである。処理が成功した場合はresolve()関数にその値を渡し、失敗した場合はreject()関数にその理由(普通はErrorインスタンス)を渡して実行する。

上記のサンプルコードでは、1/2の確率でどちらかが実行される。

値は空でもいいが、嘘だった。TypeScriptの場合、型を指定せずに呼び出すと警告が出る。例えば空で呼び出す場合は以下のようにする。

ts
const promise = new Promise<void>((resolve) => {
  setTimeout(() => {
    resolve();
  }, 1000);
});

resolve()あるいはreject()のいずれかを呼び出さないと、Promiseが抱える非同期処理は終わらない。果たされない約束として電子の海に留まることになる。なお、executorが返す値は無視される。可哀想。

使い方

Promiseインスタンスは、いわば非同期処理の代理人(Proxy)である。作っただけでは何も得られない。この代理人に、処理が終わったらどうするか指定しなければならない。

そのために、同インスタンスのthen()メソッド、あるいはawaitキーワードを使って、結果を得る処理を書く必要がある。

then()メソッド

Promiseインスタンスのthenメソッドは、2つの関数を引数に受け取る。1つ目はresolve()関数が実行された場合に実行されるもので、2つ目はreject()関数が実行された場合に実行される。

js
function createPromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      Math.random() < 0.5
        ? resolve("成功時の値")
        : reject(new Error("失敗した理由"));
    }, 100);
  });
}

const p = createPromise();
p.then(
  (v) => console.log("resolveに渡された値:" + v),
  (r) => console.log("rejectに渡された値:" + r)
);

// 結果1:resolveに渡された値:成功時の値
// 結果2:rejectに渡された値:Error: 失敗した理由

Promiseインスタンスのメソッドにはthenの他にcatch()finally()があるがここでは簡便のために割愛する。勘弁だ。どちらかというとエラーハンドリングの領域だし。

結局コールバックやないかい、と感じるか、わかりやすいと感じるかは、個人の感性によるところかと思う。

awaitキーワード

awaitキーワードを使うと、より同期処理に近い形で非同期処理を書くことができる。

js
function createPromise() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      Math.random() < 0.5
        ? resolve("成功時の値")
        : reject(new Error("失敗した理由"));
    }, 100);
  });
}

try {
  const p = await createPromise();
  console.log("resolveに渡された値:" + p);
} catch (err) {
  console.log("rejectに渡された値:" + err);
}
// 結果1:resolveに渡された値:成功時の値
// 結果2:rejectに渡された値:Error: 失敗した理由

このようにawaitをつけるだけで、非同期処理の代理人はデキる紳士のようになる。具体的には、resolve()関数に渡された結果をそのまま返してくれる。reject()が実行された場合は例外が投げられるので、なるべくならtry{}catch{}ブロックの中で実行した方が無難だ。

また、awaitキーワードはモジュールのトップレベルか、asyncキーワードをつけて定義された関数の中でしか使えない。加えて、awaitで待機できるのは、最も近いasyncキーワード関数の中だけである。

つまりasync関数の外側の処理は待機されない。注意したい。

参考資料