🍄

【入門】『スーパーマリオ』で学ぶ、JavaScriptの非同期処理

2023/07/15に公開

はじめに

今回の記事では、JavaScriptの学習における最大の鬼門の一つ「非同期処理」を、任天堂のゲーム『スーパーマリオ』を具体例に、初心者でもわかりやすく解説する。

対象とする読者

  • プログラミング初心者
  • 非同期処理が全くわからない初心者
  • タイトルで気になったひと

同期処理と非同期処理

まずは、「同期」と「非同期」のそれぞれの定義や違いについて解説する。同期処理とは、コードを上から下まで順番に処理することを意味する。一方で、非同期処理ある処理が終わるのを待たずに、別の処理を実行することを意味する。

参考までに、「分かりそう」で「分からない」でも「わかった」気になれるIT用語辞典では、以下のように説明されている。

非同期(読:ヒドウキ 英:asynchronous)とは相手との足並みを揃えないこと。あるいは、相手の反応を待たないで、ひょいひょい行動すること

同期は何かと何かを「同じにする」とか「揃える」とか、そんな意味で使われる用語です。

これだけ言ってもわからないので、任天堂のアクションゲーム『スーパーマリオ』を具体例に考えてみよう。

具体例:『スーパーマリオ』で考えてみる

『スーパーマリオ』では、プレイヤーはマリオを操作し、さまざまなアクションをとる。たとえば、ボタンを押すとマリオがジャンプし、コインを取るとスコアが増える。それぞれのアクションは一部の時間を必要とするものの、それらは互いに独立して動く。これが「非同期処理」の一例だ。

サンプルコード&解説

JavaScriptでは、非同期処理は通常、Promiseasyncawait、またはコールバックを用いて実装される。

// マリオがジャンプする関数
function jump() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('マリオがジャンプしました!');
            resolve('ジャンプ終了');
        }, 1000);
    });
}

// マリオがコインを取る関数
function getCoin() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('マリオがコインを取りました!');
            resolve('コイン取得');
        }, 500);
    });
}

async function playMario() {
    const jumpResult = await jump();  // マリオがジャンプするのを待つ
    const coinResult = await getCoin();  // マリオがコインを取るのを待つ

    console.log(jumpResult);
    console.log(coinResult);
}

playMario();

上述は、非同期処理で「マリオがジャンプしてコインを取る」流れをコンソール上で実行するプログラムである。jump()getCoin()という2つの非同期関数を定義し、それぞれの関数は一定時間が経過した後にメッセージを表示する。playMario()関数では、これらの非同期処理を順番に実行し、結果をコンソール上に表示する。

ただし、先程のコードではjump()getCoin()の両方の関数が順番に実行される。「マリオが先にジャンプして、後からコインを取る」というように、どちらか一方が先に終わった後でももう一方の操作が実行されるようにするためには、以下のようにコードを書く必要がある。

async function playMario() {
    const jumpPromise = jump();
    const coinPromise = getCoin();

    const jumpResult = await jumpPromise;
    const coinResult = await coinPromise;

    console.log(jumpResult);
    console.log(coinResult);
}

playMario();

上述のコードでは、jump()getCoin()の2つの関数が両方とも同時に実行され、それぞれが終了するとすぐに結果が表示される。これは非同期処理の強力な強みの一つだ。

『スーパーマリオ』の具体例に戻って考えてみると、上述のコードを付け加えることで「マリオがジャンプしてコインを取る」という一連の流れをスムーズに実行できるのだ。

非同期処理がないとどうなる?

先程、『スーパーマリオ』の具体例に非同期処理の流れを簡潔に解説した。それでは、非同期処理がないとどうなるのだろうか

例えば、マリオがクリボーを踏んでジャンプするアクションと、マリオがコインを拾うアクションが同時に発生することを仮定しよう。

非同期処理がない場合、ジャンプアクションが終わるまでコインを拾うアクションやクリボーを踏むアクションを待たなくてはならない。言いかえれば、ジャンプが終わるまでコインが拾えなくなったり、クリボーを踏めなくなったりするのだ。

サンプルコード&解説

先程の例を具体例に、非同期処理がない場合を以下のサンプルコードで示す。

function jump() {
    const start = Date.now();
    while (Date.now() - start < 1000) {  // 1秒間待つ
        // ループ内で時間を消費する
    }
    console.log('マリオがジャンプしました!');
}

function getCoin() {
    const start = Date.now();
    while (Date.now() - start < 500) {  // 0.5秒間待つ
        // ループ内で時間を消費する
    }
    console.log('マリオがコインを取りました!');
}

function playMario() {
    jump();
    getCoin();
}

playMario();

こちらの同期的なコードでは、一度に一つのタスクしか実行できない。こちらのコードでは、jump()関数が終了するまでgetCoin()関数が開始されない。言いかえれば、ジャンプが終わるまでコインを取れないことになる。

このように、非同期処理がないと、「マリオがジャンプしてコインを取る」というような流れが「マリオがジャンプし終えないと、コインを取れない」というおかしな流れになるのだ。

用語解説

Promise

Promiseとは、非同期処理をより簡単かつ可読性が上がるように書けるようにしたJavaScriptのオブジェクトだ。

Promiseには以下の3つの状態を持つ。

  • pending:非同期処理が実行中の状態
  • fulfilled:非同期処理が問題なく終わった状態
  • rejected:非同期処理に問題が発生した状態

新しくPromiseを作るとき、これを実行する関数は自動的に呼び出され、その関数にはresolverejectの2つの引数が渡される。

  • resolve:非同期処理が成功したときに呼び出される。
  • reject:非同期処理が失敗したときに呼び出される。

たとえば、『スーパーマリオ』でマリオがコインを拾うアクションと敵を倒すアクションを考えてみる。これをPromiseを用いて実装する。

mario_sample.js
// マリオがコインを拾う関数
function pickUpCoin() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('マリオがコインを拾いました!');
        }, 1000); // 1秒後に結果が出力
    });
}

// マリオが敵を倒す関数
function defeatEnemy() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('マリオが敵を倒しました!'); // 2秒後に結果が出力
        }, 2000);
    });
}

Promise.all([pickUpCoin(), defeatEnemy()])
    .then((results) => {
        console.log(results[0]);  // マリオがコインを拾いました!
        console.log(results[1]);  // マリオが敵を倒しました!
    });

上述のmario_sample.jsのコードで、以下の部分に注目してみよう。

function pickUpCoin() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('マリオがコインを拾いました!');
        }, 1000); // 1秒後に結果が出力
    });
}

こちらのコードを『スーパーマリオ』に当てはめて考えてみよう。マリオがコインを拾う操作には一定時間かかる。この操作が始まると、すぐに結果がわからない。操作が終了したら、すぐにその結果を知りたいだろう。

ここで、Promiseresolveを使うと、マリオがコインを拾い終えたときにその事実を通知できる。その時に用いるsetTimeout()関数は一定時間が経過したらresolve関数を呼び出し、その結果を通知する。要は、マリオがコインを拾いました!というメッセージがPromiseの結果として関連付けられるのだ。

Promise.all([pickUpCoin(), defeatEnemy()])
    .then((results) => {
        console.log(results[0]);  // マリオがコインを拾いました!
        console.log(results[1]);  // マリオが敵を倒しました!
    });
    // 非同期処理が失敗した時の処理をここに書いておく
    .catch((error) => {
        console.log(error) // エラーを出力
    })

こちらのPromise.all()関数は、指定されたすべてのPromiseresolveされるのを待ち、その結果を配列として出力する。このコードでは、マリオがコインを拾うアクションと、敵を倒すアクションがどちらも終了したときに、その結果がコンソールに表示される。

setTimeout

JavaScriptのsetTimeoutはWeb APIの一つで、非同期処理を行うための方法の一つだ。この関数を用いると、指定した時間が経過した後に関数やコードを実行できる。

setTimeout(() => {
    console.log('3秒後にこれが表示されます');
}, 3000);

setTimeout()関数は、最初の引数に実行したい関数を、2番目の引数に遅延時間(処理を実行するまでの時間)をミリ秒単位で書く。遅延時間が経過すると、指定された関数が実行される。

こちらのコードでは、3000ミリ秒、つまり3秒後に指定した関数が実行され、メッセージをコンソールに出力する。

setTimeoutは簡単な非同期処理を実装する基本的な方法だ。

async/await

JavaScriptのasyncawaitは、Promiseをより直感的に書くための構文だ。

以下のサンプルコードは、asyncawaitでマリオのアクションを非同期的に制御するものである。

function pickUpCoin() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('マリオがコインを拾う');
        }, 1000); // 1秒後に結果が出力
    });
}

function defeatEnemy() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('マリオが敵を倒す');
        }, 2000); // 2秒後に結果が出力
    });
}

async function playMario() {
    const coinResult = await pickUpCoin();
    console.log(coinResult);  // マリオがコインを拾う
    
    const enemyResult = await defeatEnemy();
    console.log(enemyResult);  // マリオが敵を倒す
}

playMario();

こちらのコードで、playMario()関数はasyncキーワードを使って宣言されている。これによって、その関数内部でawaitキーワードを使えるのだ。

async function playMario() {
    const coinResult = await pickUpCoin();
    console.log(coinResult);  // マリオがコインを拾う
    
    const enemyResult = await defeatEnemy();
    console.log(enemyResult);  // マリオが敵を倒す
}

そして、pickUpCoin()defeatEnemy()関数の結果を待つために、それぞれの関数呼び出しの前にawaitキーワードを置く。これにより、それぞれのPromiseresolve(解決)されるまで処理を一時停止し、その結果を取得するのだ。

したがって、このコードはまずpickUpCoin()関数を呼び出し、その結果を待ってからdefeatEnemy()関数を呼び出す。その結果、コンソールにはまずマリオがコインを拾うが表示され、その後にマリオが敵を倒すが表示される。

このように、asyncとawaitを使うと、非同期処理をより直感的にコントロールでき、コードの読みやすさも向上する。

参考サイト

https://qiita.com/ryosuketter/items/dd467f827c1b93a74d76

https://wa3.i-3-i.info/word1623.html

GitHubで編集を提案

Discussion