コハム

Webクリエイターのコハムが提供する、Web制作に役立つ情報をまとめたブログです。

配列やオブジェクトより便利!ジェネレータとイテレータの基礎知識

Generators and Iterators in JavaScript

記事は上記記事を意訳したものです。

※当ブログでの翻訳記事は元サイト様に許可を得て掲載しています。


イテレーションプロトコル

JavaScriptには、イテレータプロトコルとイテレーブルプロトコルを定義するイテレーションプロトコルがあります。これらのプロトコルは、値のシーケンスを作成し、それらを反復処理するための標準的な方法を提供します。

  • イテレーブル - Symbol.iteratorメソッドを持つオブジェクトです。このメソッドは、オブジェクトの要素を反復処理するために使用できるイテレータオブジェクトを返します。これがイテレーブルプロトコルです。配列やマップなどの一部の組み込みオブジェクトはイテレーブルですが、通常のオブジェクトは違います。

  • イテレータ - nextメソッドを持つオブジェクトで、現在の反復の値と反復が完了したかどうかを示すブール値doneを含むオブジェクトを返します。これがイテレータプロトコルです。

イテレーブルプロトコルを使用して、for...of構文でシーケンスをループする動作を変更または作成できます。

また、非同期イテレーションプロトコルもあり、類似のインターフェースを持ちますが、値をPromiseでラップして返します。イテレーブルのメソッドはSymbol.asyncIteratorです。

イテレーブルとイテレータの例

// イテレーブル
var iterableObject = {
    items: ["🍒", "🍌", "🍎", "🥝", "🥑"],
    [Symbol.iterator]: function () {
        var index = 0;
        return {
            next: () => {
                if (index < this.items.length) {
                    return { value: this.items[index++], done: false };
                } else {
                    index = 0;
                    return { done: true };
                }
            },
        };
    },
};

// 毎回新しいイテレータオブジェクトを返す
console.log(iterableObject[Symbol.iterator]().next()); // { value: '🍒', done: false }
console.log(iterableObject[Symbol.iterator]().next()); // { value: '🍒', done: false }

var iterator = iterableObject[Symbol.iterator]();
console.log(iterator.next()); // { value: '🍒', done: false }
console.log(iterator.next()); // { value: '🍌', done: false }
// ... 以下省略 ...

for (let item of iterableObject) {
    console.log(item);
}

// イテレータ
var iteratorObject = {
    items: ["🍒", "🍌", "🍎", "🥝", "🥑"],
    index: 0,
    next: function () {
        if (this.index < this.items.length) {
            return { value: this.items[this.index++], done: false };
        } else {
            this.index = 0;
            return { done: true };
        }
    },
};

console.log(iteratorObject.next()); // { value: '🍒', done: false }
console.log(iteratorObject.next()); // { value: '🍌', done: false }
// ... 以下省略 ...

// エラーが発生します:iteratorObjectはイテレーブルではありません
// for (let item of iteratorObject) {
//     console.log(item);
// }

ジェネレータ

JavaScriptの通常の関数はすべて「実行完了」セマンティクスを持っています。これは、関数が実行を開始すると、他の関数が割り込んで実行を開始する前に、常にその関数の最後まで実行して終了することを意味します。他の関数を呼び出すことはできますが、誰もこの関数を先制的に中断して別のものを実行することはできません。

これはJavaScriptの最も重要な特徴の1つで、シングルスレッドであり、一度に1つのことしか実行できないことを示しています。

ジェネレータには「実行完了」セマンティクスがありません。これは異なる種類の関数です。イテレータの一種であるジェネレータオブジェクトを返します(イテレータとイテレーブルプロトコルに従います)。ジェネレータ関数(function*)のみがジェネレータオブジェクトを作成できます。

ジェネレータの例

function* print() {
    console.log(1);
    console.log(2);
    console.log(3);

    yield "pause 1";

    console.log(4);
    console.log(5);

    yield "pause 2";

    console.log(6);
    console.log(7);
}

// ジェネレータ関数を実行しない
var iterator = print();
console.log(iterator); // Object [Generator] {}

// ジェネレータ関数を開始し、最初のyieldで一時停止
console.log(iterator.next()); // { value: 'pause 1', done: false }

console.log(iterator.next()); // { value: 'pause 2', done: false }

// 最後のyieldから再開し、関数の実行を完了
console.log(iterator.next()); // { value: undefined, done: true }

console.log(iterator.next()); // { value: undefined, done: true }

// イテレータが使い果たされているため、何も出力されない
for (let value of iterator) {
    console.log(value);
}

yieldキーワードは、式の途中であっても、その場所で文字通り一時停止します。ジェネレータはこの一時停止状態に入り、他のアクターが再開する時が来たと言うまで無期限にそこにとどまります。

これは全体的なブロッキングではなく、ジェネレータ内部のローカライズされたブロッキングです。

ジェネレータは、状態機械を宣言するための構文的な形式です。状態機械は、ある状態から別の状態へのパターン化された一連のフローを持つ方法であり、すべての状態とそれらの遷移を宣言的にリストアップします。ジェネレータを使用せずに状態機械を実装するのは非常に複雑です。

無限ジェネレータ

ジェネレータは完了する必要がありません。部分的に消費し、必要がなくなれば参照を解除し、ガベージコレクタが削除します。

function* generateUniqueID() {
    var id = 0;
    while (true) {
        yield id++;
    }
}

var idGenerator = generateUniqueID();
console.log(idGenerator.next().value); // 0
console.log(idGenerator.next().value); // 1
console.log(idGenerator.next().value); // 2

ジェネレータを使用した非同期プログラミング

ジェネレータを使用して100%の非同期コードを書くことができ、プロミスチェーンを使用する必要がなくなります。

function getData(number) {
    setTimeout(function () {
        run(number);
    }, 1000);
}

function coroutine(generator) {
    var iterator = generator();
    return function () {
        return iterator.next.apply(iterator, arguments);
    };
}

var run = coroutine(function* () {
    console.log("Started");

    var x = 1 + (yield getData(10));
    console.log("x: " + x);
    var y = 1 + (yield getData(20));
    console.log("y: " + y);
    var answer = yield getData(x + y);
    console.log(answer);
});

run();

プロミスがコールバックの制御の逆転問題を解決するためのものであれば、ジェネレータは非ローカルおよび非順次の理解可能性問題を解決するためのものです。

ジェネレータを使用すると、コードが同期的に見えます。また、try...catchを使用してエラー処理を行うこともできます。コールバック、サンク、プロミスでは多くのネストがありますが、ここではそれがありません。これは大きな利点です!得られるフロー制御は素晴らしいものです。

プロミスとジェネレータ

次の例では、非ローカルおよび非順次の問題は解決していますが、制御の逆転問題には依然として脆弱です。

function getData(number) {
    setTimeout(function () {
        run(number);
    }, 1000);
}

function coroutine(generator) {
    var iterator = generator();
    return function () {
        return iterator.next.apply(iterator, arguments);
    };
}

var run = coroutine(function* () {
    var x = 1 + (yield getData(10));
    var y = 1 + (yield getData(20));
    var answer = yield getData(x + y);
    console.log(answer);
});

run();

誰かがiterator.nextを呼び出して物事を台無しにする可能性があります。この問題は、ジェネレータとプロミスを組み合わせることで解決されます。

パターンは次のようになります - プロミスをyieldし、プロミスがジェネレータを再開します。これにより、非順次非ローカルの問題(ジェネレータを介して)と制御の逆転問題(プロミスを介して)の両方が解決されます。

function* generator() {
    console.log("Started");

    var result = yield Promise.resolve(1);
    console.log(result);

    var result = yield Promise.resolve(2);
    console.log(result);

    console.log("Finished");
}

var iterator = generator();
var promise1 = iterator.next().value;

promise1.then(function (result1) {
    var promise2 = iterator.next(result1 * 2).value;

    promise2.then(function (result2) {
        iterator.next(result2 * 2);
    });
});

これらのジェネレータとプロミスを使用する複雑さは、async...awaitによって解決されます。それ以前は、ライブラリを使用する必要がありました。

ただし、async...awaitには注意点があります。単なる構文糖ではありません。途中で実行を停止したい場合、それはできません。そのため、プロミスを返し、まだ実装されていませんが、提案されているのは、そのプロミスがキャンセル可能であり、async関数に中止メッセージを送信できるようにすることです(良くないアイデアです。キャンセルは重要ですが、この方法は悪い設計です)。

以下は、キャンセル機能を追加する方法です:

function timeout(delay) {
    return new Promise((resolve) => setTimeout(resolve, delay));
}

function makeCancellablePromise(promise) {
    var isCancelled = false;
    var wrappedPromise = new Promise(function (resolve, reject) {
        promise.then(
            function (value) {
                isCancelled ? reject({ isCancelled, value }) : resolve(value);
            },
            function (error) {
                isCancelled ? reject({ isCancelled, error }) : reject(error);
            }
        );
    });

    return {
        promise: wrappedPromise,
        cancel() {
            isCancelled = true;
        },
    };
}

async function main() {
    var { promise, cancel } = makeCancellablePromise(timeout(2000));
    setTimeout(() => cancel(), 1500); // 1.5秒後にプロミスをキャンセル

    try {
        await promise;
        console.log("world");
    } catch (error) {
        if (error.isCancelled) {
            console.log("操作がキャンセルされました");
        } else {
            console.log("エラーが発生しました:", error);
        }
    }
}

main();

より良い解決策は、タイムアウトと非同期タスクの間にレース条件を追加することです:

async function timeout(delay) {
    return new Promise((resolve) => setTimeout(resolve, delay));
}

async function runWithTimeout(fn, timeoutDuration) {
    return await Promise.race([
        fn(),
        new Promise(function (_, reject) {
            setTimeout(
                () => reject(new Error("操作がタイムアウトしました")),
                timeoutDuration
            );
        }),
    ]);
}

async function main() {
    await timeout(1000); // 1秒待つ
    console.log("hello");

    var result = await runWithTimeout(() => timeout(2000), 1500);

    if (result instanceof Error) {
        console.log(result.message);
    } else {
        console.log("world");
    }
}

main();

ジェネレータはこの問題を抱えていません。イテレータを返し、それらにはnextメソッドがあります。returnメソッドまたはthrowメソッドを使用してキャンセルしたい場合、手動でジェネレータにエラーを送信できます。

ジェネレータは、async関数が提供するよりも外部からより多くの制御を与えます。

ジェネレータはCSP非同期パターンでも使用されます。

非同期処理のためのジェネレータとプロミスの使用

レストランで前菜、メインコース、デザートを注文したという問題を考えてみましょう。これらはすべて同時に調理されるべきですが、順番に提供される必要があります。

初期コード:

/**
 * 指定された料理タイプに基づいて料理を提供します。
 * @param {string} mealType - 提供する料理のタイプ(starter, main, dessert)
 * @param {function} cb - 料理が準備された後に呼び出されるコールバック関数
 */
function serve(mealType, cb) {
    var meal = {
        starter: "🍤",
        main: "🥘",
        dessert: "🍨",
    };

    var delay = (Math.round(Math.random() * 1e4) % 8000) + 1000;
    console.log(`${mealType}を準備中`);

    setTimeout(function () {
        cb(meal[mealType]);
    }, delay);
}

/**
 * 指定された料理タイプの食事を作ります。
 * @param {string} mealType - 作る料理のタイプ
 */
function makeFood(mealType) {
    serve(mealType, function (food) {
        // ...
    });
}

// 同時に料理を作る
makeFood("starter");
makeFood("main");
makeFood("dessert");

これらを同時に実行し、調整することは複雑さをもたらします。ジェネレータとプロミスを使用してこの複雑さを解決します。

解決策:

/**
 * 指定された料理タイプに基づいて料理を提供します。
 * @param {string} mealType - 提供する料理のタイプ(starter, main, dessert)
 * @param {function} cb - 料理が準備された後に呼び出されるコールバック関数
 */
function serve(mealType, cb) {
    var meal = {
        starter: "🍤",
        main: "🥘",
        dessert: "🍨",
    };

    var delay = (Math.round(Math.random() * 1e4) % 8000) + 1000;
    console.log(`${mealType}を準備中`);

    setTimeout(function () {
        cb(meal[mealType]);
    }, delay);
}

/**
 * 指定された料理タイプの食事を作ります。
 * @param {string} mealType - 作る料理のタイプ
 */
function makeFood(mealType) {
    return new Promise(function executor(resolve) {
        serve(mealType, function (food) {
            resolve(food);
        });
    });
}

function* main() {
    // 同時に料理を作る
    var promise1 = makeFood("starter");
    var promise2 = makeFood("main");
    var promise3 = makeFood("dessert");

    console.log(`提供された料理: ${yield promise1}`);
    console.log(`提供された料理: ${yield promise2}`);
    console.log(`提供された料理: ${yield promise3}`);
}

var iterator = main();
iterator
    .next()
    .value.then(function (starter) {
        return iterator.next(starter).value;
    })
    .then(function (main) {
        return iterator.next(main).value;
    })
    .then(function (dessert) {
        iterator.next(dessert);
    });


// 出力:
// starterを準備中
// mainを準備中
// dessertを準備中
// 提供された料理: 🍤
// 提供された料理: 🥘
// 提供された料理: 🍨

ここではプロミスをyieldしているため、Promise.allや他の抽象化を望むようにyieldできます。これがこのパターンが非常に強力な理由です。

©コハム