Promiseを理解する

Promiseとはなにか?

Promiseは簡単に言えば「いつ終わるかわからない処理」のことである。すぐに終わるかもしれないし、数秒、数分かかるかもしれない。結果が正常に返ってくるかもしれないし、異常終了する可能性もある。「人をおちょくってるとブッ飛ばすぞ!」と思うかもしれないが、正直言ってそういう性質のものである。

新しいPromiseを作るのは簡単で、Promiseオブジェクトのコンストラクタを呼び出すだけである。

new Promise(resolve => {})           // 新しい待機状態のPromiseが生成される
new Promise((resolve, reject) => {}) // rejectはオプション

Promiseオブジェクトには最低でも resolve 引数を受け取るコールバックを指定する。追加でエラーを通知するための reject を受け取ることもできる。もちろん、resolvereject といった名前は便宜的なもので、どういった名前で受け取るかは自由となっている。

Promiseの待機と履行

Promiseを生成するとコールバックが評価され、resolve()reject() が実行されることを期待する。呼び出し側ではPromiseの終了を待機することができ、then() によるノンブロッキング形式と、await によるブロッキング形式を選択できる。もちろん、待たないという選択もある。

// then()で結果を待つ(ノンブロッキング)
new Promise(resolve => {}).then(() => {})
 
// awaitで結果を待つ(ブロッキング)
await new Promise(resolve => {})

さて、ここでさっそく待機の実験をしてみるために、次のようなコードを書いて動かしてみる。すると一向にHello, world!が表示されないことがわかる。なぜなら、いくら待っても resolve()reject() も実行されないからである。

window.addEventListener('DOMContentLoaded', async () => {
    await new Promise(resolve => {}) // ここで待機している!
    console.log('Hello, world!')
})

そこで次のようにコードを書き換えてみる。コールバックの引数として渡される resolve を呼び出すことによってPromiseが待機状態から履行状態になり、await による待機が終了する。

window.addEventListener('DOMContentLoaded', async () => {
    await new Promise(resolve => resolve()) // 解決する
    console.log('Hello, world!')            // 表示される
})

resolve は呼び出しの際に引数を与えることもでき、それがPromiseの履行結果にもなる。

// x = 42になる
const x = await new Promise(resolve => resolve(42))

ここで、ちょっと難しいことをしてみる。Promiseと setTimeout() を組み合わせると面白いことができる。それがこの sleep() 関数である。

function sleep(milliseconds) {
    // setTimeout()でresolve()を遅らせる
    return new Promise(resolve => setTimeout(() => resolve(), milliseconds))
}
 
window.addEventListener('DOMContentLoaded', async () => {
    // 1秒待ってからHello, world!を表示する
    await sleep(1000)
    console.log('Hello, world!')
})

この sleep() 関数は await したときにしか期待する動作をしないものの、await によるPromiseの待機の挙動が理解できることと思う。

ポーリングはほかに方法がない場合にのみ使用してください

実際のコード上で await sleep() を使用することは、ポーリング処理が必要な場合をのぞいて、あまり優れた手段とは言えません。なにかを待つ、という要求に対して、ほとんどの場合はなにかしらのイベントを捕捉できるはずで、そのほうがパフォーマンスが高くなるからです。

Promiseの拒否

Promiseは履行するだけでなく reject() によって拒否もできる。拒否すると resolve() による履行と同じく待機が終了するが、エラーという形で報告される。エラーは await している場合にはtry文で捕捉できるし、await していない場合には、catch() で捕捉できる。

try {
    await new Promise((_, reject) => reject(42))
}
catch (e) {
    console.error(e) // 42
}
 
new Promise((_, reject) => reject(42))
    .then(() => {})
    .catch(e => console.error(e)) // 42

async関数

Promiseを作るにはもうひとつの方法としてasync関数がある。async 修飾子がついた関数の返値はPromiseで包まれることになる。下の例ではコード上では 42 を返しているが、実際には Promise.resolve(42) が返されることになる。

async function f() {
    // 実際はPromise.resolve(42)が返却される
    return 42
}
 
function g() {
    // 見てのとおりPromise.resolve(42)が返却される
    return Promise.resolve(42)
}

async関数はPromiseと同様に then()await で結果を待つことができる。さらに、async関数の中ではPromiseをブロッキングで待機する await が使えるようになる。

then() を使うべきですか?

パフォーマンスを気にする段階にない限り、await が使える状況では then() よりも愚直に await を使用したほうが理解しやすいコードになります。また、パフォーマンスを気にするようになっても、性能を引き出すためのメソッドが用意されているので、まずはそれらの利用を検討してください。

asyncコンテキストとawait

await はPromiseをブロッキングで待機できる便利な機能ではあるものの、asyncコンテキストの中でしか使用できない。では then() を使うしかないかというとそうではなく、ここには抜け穴があり、それは「コールバックはasyncになれる」という性質がある。

例えばみなさんご存知の window.addEventListener() はコールバックにasync関数を指定することができる。

window.addEventListener('DOMContentLoaded', async () => {
    // asyncコンテキスト               これが大事⤴︎
    const result = await f()
    console.log(result)
})

asyncコンテキストを作るには?

コールバックでasync関数を指定してasyncコンテキストを生成するしかない、という事実に対してそれほど悲観する必要はありません。

そもそも論として、コンピュータ上の処理はすべてイベント駆動と捉えることができます。電源を投入したとき、準備が終わったとき、なにかをクリックしたとき、タイマーがタイムアウトしたとき、などなど…。無からいきなりなにかが生じることはありません。

とにかく、なにかしらの処理をはじめるためのきっかけ(イベント)が用意されているはずなので、asyncコンテキストを作るのは難しいことではありません。

Thenable

then() メソッドを持つオブジェクトをThenableという。async/awaitがない古いJavaScriptでは非同期処理の完了を then() メソッドで待機していた。当時では当たり前とも言える非同期処理のやりかたではあったものの、ある非同期処理Aのあとに非同期処理Bを行いたいときには、then() によるコールバックが入れ子になることでコードの見通しが悪くなり、さらには正しいタイミングで然るべき処理が行われるかどうかが疑わしいという欠点があった。

しかし、awaitキーワードが登場したことで、非同期コンテキスト内の非同期処理を同期的に待機できるようになった。なので今となってはasyncメソッドとPromiseしか使われないが、ではPromiseとThenableとAwaitable(awaitキーワードが使えること)にはどのような関係があるのだろうか?

PromiseはThenableか?

Thenableである。しかし、同期コンテキストで呼び出す場合でない限り、通常はawaitで待機するべきである。

ThenableはAwaitableか?

つまり then() メソッドが定義されていればawaitできるかどうかだが、これはawaitできる。Promiseと同じように resolvereject のふたつの処理終了を伝えるメソッドが引数として渡されるようになっている。

const answer = {
    then(resolve) {
        resolve(42)
    }
}
 
window.addEventListener('DOMContentLoaded', async () => {
    const result = await answer  // 42
    console.log(result)
})

したがって、次の法則が成り立つ。

  • then() メソッドが定義されているオブジェクトはThenableである。
  • PromiseはThenableである (ThenableならPromiseは成り立たない)。
  • ThenableはAwaitableである。

非同期オブジェクトを判定する

待機可能か? (isAwaitable)

これは then() メソッドの存在を確認すればよく、ほとんどの場合はこれで十分である。ただし、与えられた値が非同期関数かどうかまでは判定できない。なぜなら非同期関数は実行したときにPromiseを返却するからである。

function isAwaitable(value) {
    return typeof value?.then == 'function'
}

Promiseか? (isPromise)

単純にPromise型かどうか判定すればよい。

function isPromise(value) {
    return value instanceof Promise
}

非同期関数か? (isAsyncFunction)

非同期関数として定義されているかどうかを判定する。しかし、非同期関数であることと同期関数の中でPromiseを返却する場合はどちらも実行結果としてはPromiseになるので、非同期関数であることを確認するよりも、実行結果がAwaitableであるかどうかを判定したほうがよい。

function isAsyncFunction(value) {
    return value?.constructor?.name == 'AsyncFunction'
}

PromiseOr

プログラミング言語DartにはPromiseに相当するものとして任意の型Tに対するFuture<T>があり、さらにFuture<T>とTの両方の性質を備えたFutureOr<T>がある。JavaScriptでDartのFutureOrほど厳密なことはしなくてはいいものの、Tであればそのままで、Promise<T>ならTを取り出すまで待機したいという需要は往々にしてある。

こういったときには以下のような awaitOr() メソッドを定義するとうまくいく。

async function awaitOr(value) {
    let result = value
    if (typeof result?.then == 'function') {
        result = await result
    }
    return result
}