JavaScriptの「async/awaitは糖衣構文」の真意について
async/awaitは糖衣構文
async/awaitの話題になるとしたり顔の古参エンジニアが現れて「async/awaitはただの糖衣構文だからねwPromiseをちゃんと理解したほうがいいよw」と捨て台詞を吐いて去ってゆくのはもはや風物詩であるが、この「async/awaitは糖衣構文」を誤解している人も多いのではないか。
何が言いたいかというと
function main() {
return promise1()
.then(() => promise2())
.then(() => promise3())
}
の糖衣構文が
async function main() {
await promise1();
await promise2();
await promise3();
}
という意味ではない[1]。この記事では「async/awaitは糖衣構文」と謂う人が何をもってそういうことを言ってるのかというのを説明する。
Promiseの時代
Promiseは非同期処理を扱うオブジェクトで、then
でコールバックを渡すことで、非同期処理を繋げることができる。
fetch("https://example.org")
.then(res => res.text())
.then(text => console.log(text))
Promiseのおかげでネストが深くなりまくるいわゆる「コールバック地獄」を避けることはできるようになったが、依然としてコールバックは必要だし、別にPromiseと似たようなものはユーザー側でも実装できて、例えばjQueryは自前でDeferredを実装していた。一方で、Promiseを使っても if
や for
のような制御構文を扱うには工夫が必要で、劇的に便利になったというよりは、Promiseという共通の実装ができたおかげでエコシステムが作りやすくなった恩恵が大きかったと思う。
さて、このPromiseの弱点である「コールバックが必要」「通常の制御構文が使えない」を克服するためにはasync/awaitの登場を待つ必要がある……わけではない。
Generatorの登場、coの時代
ここで颯爽と登場するのがGeneratorである。え、なにそれ……となるのも無理はない。昨今のフロントエンド開発でGeneratorを使う機会なんてほとんどない。一部の酔狂な人が飛び道具[2]として使っているのみである。
Generatorに関する正確な説明はググったりしてほしいが、一言で言えば、関数を途中で中断したり再開できる機能である。*
や yield
のキーワードを使う。
function* numbers(from, to) {
for (let i = from; i <= to; i++) {
yield i;
}
}
for (let num of numbers(2, 5)) {
console.log(num);
}
上記のコードを実行すると、yieldのタイミングでforループに戻ってきて、順番に数字が出力される。
2
3
4
5
さて、これだけであれば非同期と関連がなさそうに見えるのだが、これとPromiseを組み合わせることで非同期処理を同期っぽく書くことができる。結論を下に書いてしまうが、アイディアはGeneratorでPromiseを返し、そのPromiseを順番に処理していくことである。
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function* asyncWait3Seconds() {
console.log("Start waiting");
yield wait(1000);
console.log("1 second passed");
yield wait(1000);
console.log("2 seconds passed");
yield wait(1000);
console.log("3 seconds passed. Boom!");
}
function runAsync(generator) {
const iterator = generator();
const iterate = (result) => {
if (result.done) {
return;
} else {
result.value.then(() => iterate(iterator.next()));
}
};
iterate(iterator.next());
}
runAsync(asyncWait3Seconds);
これを実行すると、1秒おきにログが流れる。
この時点でだいぶasync/awaitっぽいが、Generatorはyieldの返り値や関数自体の返り値も扱えるので、その辺をいい感じに書くと、いかにもasync/awaitっぽくなる。
function* asyncFetchExampleOrg() {
console.log("Start fetching");
const res = yield fetch("https://example.org");
console.log(`Fetched response: ${res.status}`);
return res.text();
}
function runAsync(generator) {
const iterator = generator();
return new Promise((resolve) => {
const iterate = (result) => {
if (result.done) {
resolve(result.value);
} else {
result.value.then((value) => iterate(iterator.next(value)));
}
};
iterate(iterator.next());
});
}
runAsync(asyncFetchExampleOrg).then((text) => {
console.log(`Fetched text: ${text.slice(0, 64)}...`);
});
実行結果は下記である。
Start fetching
Fetched response: 200
Fetched text: <!doctype html>
<html>
<head>
<title>Example Domain</title>
...
このGeneratorでの非同期の取り扱いだが、async/await以前はcoなどが有名なライブラリとして存在していた。実はこの時点で「コールバックが必要」「通常の制御構文が使えない」という弱点は克服していたのである。
async/awaitの登場
そして満を持してのasync/awaitの登場である。が、これは実際のところGeneratorでの非同期処理の糖衣構文でしかなかった。これはどういうことかというと、例えばcoを使った非同期処理
function* asyncFetchExampleOrg() {
const res = yield fetch("https://example.org");
return res.text();
}
co(asyncFetchExampleOrg);
をasync/awaitで書き直すと
async function asyncFetchExampleOrg() {
const res = await fetch("https://example.org");
return res.text();
}
asyncFetchExampleOrg();
となるということである。これは *
や yield
が async
, await
のキーワードに変わった他、呼び出し部分が簡略化されたぐらいの違いしかない[3]。これが「async/awaitは糖衣構文」の真意である。
JavaScriptのエンジンが実際にasync/awaitをGeneratorに置き換えて実行してるかは知らないし、エラーハンドリングなど細かい違いはあるかもしれない。ただ、例えばmdnの解説でもasync/awaitはGeneratorの仕組みに似ていると言っていたり、そもそものプロポーサルからして自前のGeneratorのメカニズムを用意しなくて済むようにするものだよと言っているので、糖衣構文と呼んでも差し支えないかなと思う。
-
そういう意味で言ってる人ももしかしたらいるかもしれない。 ↩︎
-
https://effect.website/docs/getting-started/using-generators/ とか。 ↩︎
-
なので、async/awaitが「何」の糖衣構文かと問われたら、それはPromiseというよりGeneratorだと思う。 ↩︎
Discussion