銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

async / await の基本事項 ― やっぱりPromiseは無視できない

f:id:takamints:20180527125137j:plain
photo credit: simmons.kevin4208 Promise via photopin (license)

JavaScriptのES2017で使えるようになった async/await 。 従来Promiseで書いていた非同期処理が、ずいぶん簡潔に書けるようになりました。

AWS Lambdaでも、2018年4月から Node.js v8.10(LTS) が使えるようになっており、新規作成したハンドラーは async 関数になっています。

しかし「async/await を使えば、Promiseについて知らなくてもよい」とは言えません。 むしろしっかり理解しておく必要がありますよと。

つまり、async / await は Promiseを置き換えるものではなく「Promiseによる非同期処理を同期処理的に記述するための記法」なのです。

ということで、ここには async / await の基本事項と気づいた点やら困った点などを、まとめておきたいと思います(Promiseを理解している人向けの内容です)。

関連記事:
takamints.hatenablog.jp

目次:

これからWebをはじめる人のHTML&CSS、JavaScriptのきほんのきほん
たにぐちまこと
マイナビ出版 (2017-03-27)
売り上げランキング: 6,710

async関数とはどういうものか?

async関数は、その記述内容に関わらず、必ずPromiseオブジェクトを返す関数です。 必ずしも非同期動作するわけではないことには軽く注意が必要です。 また、構文的には関数内で後述の await を使えることも示しています。

以降、これらをもう少し細かく書いてます。

見かけによらずPromiseオブジェクトを返します

async関数は、どのように記述されていようと、Promiseオブジェクトを返します。 以下の何もしなさそうに見える関数 foo も、しっかりPromiseを返します。

async function foo() {   }

これをPromiseを明示的に使って書くとこうなります。

function foo() {
    return new Promise(
        (resolve,reject) => {
            resolve();
        }
    );
}

上に示したどちらのfooも、Promiseを返しますので、以下のコードは正しく動作します。

foo().then(()=>{
    console.log("ほらね!");// =>ほらね!
});
async関数 !== 非同期関数

関数定義でasyncを指定しただけで非同期関数にはなりません。 同期的に解決するPromiseはあり得ます。 上の foo も、同期的に解決しています。

Promiseのコンストラクタ内でresolveハンドラが呼ばれており生成されたPromiseを返しているので、明らかに同期的に動作してます。

そのPromiseは見かけ上の戻り値で解決(resolve)される

上のfooが返すPromiseオブジェクトは、undefinedで解決(resolve)されます。 それは見かけ上この関数が何も返していない(≒undefinedを返している)からです。

async function foo() {  return  "ちゃんと動いた!"; }

foo().then( ( result )=>{
    console.log( result ); // => ちゃんと動いた!
});

エラー投入でrejectされる。

Promiseを明示的に生成するときには、エラーの発生はrejectコールバックで伝えましたが、async関数ではErrorをthrowすればよろしい。 そうすると、実質的な戻り値のPromiseオブジェクトはrejectされて、呼び元の catchハンドラが呼ばれます。

async function foo() {
    throw new Error("エラーだよ!");
}
foo().then( () => {} ).catch( err => {
    console.log( err.message ); // => エラーだよ!
});

asyncがコールバック地獄から救ってくれるわけじゃない

妙な言い方になっていますが、つまり、コールバック関数による旧式非同期処理を最新式のPromise化するには直接Promiseを使うしか方法はなく、asyncは直接的に関わらないということです。

例えば、コールバックによる非同期処理の fs.readFile をPromise化するラッパーを作るには、以下のようになります。

function readFile(filename) {
    return new Promise((resolve, reject)=>{
        fs.readFile(filename, (err, data) => {
            if(err) {
                 reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

これを、async関数にもできますが下のようになる程度。 上の実装で既にawaitできますから「確実にPromiseを返すことを宣言する」以外に意味はありません。

async function readFile(filename) {
    return await new Promise((resolve, reject)=>{
        fs.readFile(filename, (err, data) => {
            if(err) {
                 reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

awaitはPromiseの解決を待つ記法

await は、async関数の中だけで使えて、Promiseオブジェクトの解決した値を同期的な記述で得る記法です。 あくまでも記法なので、Promiseと本質的に動作の違いはありません。だからawaitは処理をブロックしたりしません。

だから async関数でなくても await できます。そもそも関数でなくてもかまいません。

async 関数はPromiseを返す関数だからawait可能なだけなんですね。 Promiseを明示的に返す関数でも await できるし、Promiseオブジェクトを直接 await したってかまいません。

これらは結構最初に勘違いしやすいところだと思います。

以下のconsole.log はすべてOK!を表示します。

async function foo() { return "OK!"; }
function bar() {
    return new Promise( resolve => { resolve("OK!"); });
}
async function main() {
    let result = await foo();
    console.log(result);
    let p = foo();
    console.log(await p);
    console.log(await bar());
    console.log(await (new Promise( resolve => {
            resolve("OK!");})));
    // => OK!
    // => OK!
    // => OK!
    // => OK!
}

main();

※ awaitはasync関数でしか使えないので、async mainを定義しています。無名関数の即時実行でも構いません。

上のmainをPromiseで書き直すと以下のようになりますね。 長いだけじゃなくて、処理のまとまりがわかりにくいですね。 thenを分割すれば、さらにコードが長くなります。

function main() {
    foo().then( result => {
        console.log( result );
        let p = foo();
        return p;
    }). then( result => { 
        console.log( result );
        return bar();
    }). then( result => { 
        console.log( result );
        return new Promise( resolve => {
            resolve("OK!");});
    }). then( result => { 
        console.log( result );
    });
}

awaitしているPromiseがrejectされたらエラー投入

async関数と対になっている感じですけど、awaitしているPromiseがrejectされたら、エラーがthrowされます。

エラーを捕捉したければ、普通に try~catch で括ればよろしいです。

await は async関数の中だけで使えますから、try~catchがないなら、前述の通り async関数が暗黙的に返すPromiseオブジェクトはrejectされます。

その他いろいろ

Promise.all をawaitするのが便利ですね

複数のPromise全部が完了するまで待つための Promise.all もawaitできます。 Promise.all の戻り値は、Promise ですから。

このPromiseは、Promise配列の各要素で解決された値の配列になっています。 これらをきちんと理解すれば、かなり楽に書けますね。

function delayedSquare(n) {
    return new Promise(
        (resolve, reject) => {
            setTimeout( ()=> {
                resolve(n * n);
            }, 3000);
        });
}

(async () => {
    let result = await Promise.all(
        [0,1,2,3].map( async n => {
            return await delayedSquare(n);
        }));
    console.log(JSON.stringify(result));
})();

await書き忘れ問題(未解決)

awaitを書き忘れることがたまにありますが、これ厄介です。 eslintなどでもエラーにならないんですよね。 実行時にも単に処理を待っているだけの(値をawaitしていない)場合はエラーになりません。

async function foo() {
    await bar();
    await baz();
}

上のように順次処理を書いてるつもりが、下のようにawaitをすっかり忘れてもエラーにならず、単に2つのPromiseが宙に浮いて非同期実行されるだけになってしまう。

async function foo() {
    bar();
    baz();
}

リファクタリングしている場合に何度かハマりました。処理順がごちゃごちゃになってしまうんですよね。 どうにかならんかなと思っていますが、未解決です。

async 関数内にしか await が書けない理由?

(これは独り言)

awaitが、async宣言された関数内でしか使えないというのは、必ずしも設けなくてもよい制限のように思えますが、非同期処理の間違いを防ぐための仕様なのだろうと思っています。

awaitを使っているということは明示されていないPromiseオブジェクトのthenのハンドラーで処理されています。 async関数以外では、この暗黙のPromiseを返さないように記述できてしまいますが、そうするとawaitの結果が宙に浮いてしまうことになります。 暗黙的なPromiseの結果を待つ await を使用するのは、暗黙的にPromiseを返す関数、つまり async 関数の中だけに限定しているのではないかなと。

まとめ

上に書いた以外にも、「async取り除き忘れ問題」とか、「asyncいちいちつけるのメンドクセー問題」とか言いがかりに近いものも含めるといろいろありますが、「async / awaitで非同期処理を簡潔で分かりやすく書けるようになった」のは事実。 しかしPromiseのことが分かっていないとコードを追いかけられないというのもまた事実なんですね(←これが言いたかった)。