🎰

JavaScriptのIterator / Generatorの整理

18 min read

目的と対象読者

  • IteratorとIterableとGeneratorとGenerator Functionの区別が曖昧な人 (記事前半)
  • Generatorの制御フローを完全理解したい人 (記事後半)

の理解を深めるための記事です。

まとめ

  • IteratorとIterableの関係
    • Iteratorは狭義には呼び出し元の next 呼び出しに応じて要素を出力するインターフェースである。
    • IterableはIteratorを生成するインターフェースである。
    • IterableだからといってIteratorとは限らず、IteratorだからといってIterableとは限らない。しかし実際には多くのIteratorはIterableのインターフェースも実装している。
    • Iterableとコレクションは相互変換可能である。
    • Iterableは for-of ループで処理できる。
  • IteratorとGeneratorの関係
    • Iteratorはジェネレーターのような双方向的なやり取りのために一般化されたインターフェースも提供している。
    • JavaScriptにおけるジェネレーターとはジェネレーター関数の戻り値のことで、これは一般化されたIteratorのインターフェースを実装している。
  • Generator
    • ジェネレーター関数は yield で自己中断可能な特別な関数で、ジェネレーター関数の中断した処理を再開するためのインターフェースがジェネレーターである。
  • Iterator/Generatorのリソース解放処理
    • Iteratorにはリソース解放を行うために return / catch インターフェースが用意されている。
    • ジェネレーター関数内のtry-catchやtry-finallyはジェネレーターに対する return / catch 呼び出しを捕捉できる。
    • for-of もまた、リソース解放のために return を適切に呼び出すようになっている。

狭義のIterator / AsyncIterator

まずECMAScript標準のIteratorを簡略化した以下のインターフェースを考えます。 (標準のIteratorと区別するために名称にStrictをつけています)

interface StrictIterator<T> {
  next(): StrictIteratorResult<T>;
  return?(): StrictIteratorResult<T>;
}

interface StrictAsyncIterator<T> {
  next(): Promise<StrictIteratorResult<T>>;
  return?(): Promise<StrictIteratorResult<T>>;
}

type StrictIteratorResult<T> =
  // まだ値が残っている場合
  | { done?: false; value: T }
  // もう出力すべき値がない場合
  | { done: true };

next がイテレーターの核となる機能で、 return はリソースの解放処理 (デストラクタ) などを挟むための補助的なインターフェースです。

next については以下の呼び出し例を見ると分かりやすいでしょう。

const iter = [1, 2, 3][Symbol.iterator]();
console.log(iter.next()); // => { done: false, value: 1 }
console.log(iter.next()); // => { done: false, value: 2 }
console.log(iter.next()); // => { done: false, value: 3 }
console.log(iter.next()); // => { done: true, value: undefined }
console.log(iter.next()); // => { done: true, value: undefined }

AsyncIteratorは呼んで字のごとく、Iteratorの非同期版です。nextreturn がPromiseを返すようになっています。

IteratorとIterable

Iteratorと混同しがちな概念としてIterableがあります。Iterableは以下のようなインターフェースです。

interface StrictIterable<T> {
  [Symbol.iterator](): StrictIterator<T>;
}

interface StrictAsyncIterable<T> {
  [Symbol.asyncIterator](): StrictAsyncIterator<T>;
}

シグネチャを見ればわかる通り、IterableとIteratorは独立した概念です。つまり、IterableだからといってIteratorとは限らないし、IteratorだからといってIterableとは限りません

IteratorとIterableが混同される理由のひとつは、実際にほとんどのIteratorがIterableも実装しているからです。このために用意されているのが以下の %IteratorPrototype%%AsyncIteratorPrototype% です。

// %IteratorPrototype% = _IteratorBase.prototype
// 実際に _IteratorBase という名前のクラスが存在するわけではない
abstract class _IteratorBase<T> implements Iterator<T>, Iterable<T> {
  [Symbol.iterator](): Iterator<T, TReturn, TNext> {
    return this;
  }

  abstract next(): IteratorResult<T, TReturn, TNext>;
}

// %AsyncIteratorPrototype% = _AsyncIteratorBase.prototype
// 実際に _AsyncIteratorBase という名前のクラスが存在するわけではない
abstract class _AsyncIteratorBase<T> implements AsyncIterator<T>, AsyncIterable<T> {
  [Symbol.asyncIterator](): AsyncIterator<T> {
    return this;
  }

  abstract next(): Promise<IteratorResult<T>>;
}

ECMAScript標準で定義されるイテレーターは(広義のイテレーターも含めて)全て上記のクラス(相当の物体)を継承しています。このようなイテレーターはTypeScriptでは IterableIterator の名前で呼ばれています。

図にすると以下のようになります。

図: IteratorとIterableの関係

狭義のイテレーターを使う機能

狭義のイテレーターを使う機能として以下のようなものがあります。

標準コレクション

標準コレクションはIterableです。 (→ Array, Map, Set)

const iter1 = [1, 2, 3][Symbol.iterator]();
const iter2 = new Map()[Symbol.iterator]();
const iter3 = new Set([1, 2, 3])[Symbol.iterator]();

ジェネレーター関数の戻り値

ジェネレーター関数の戻り値をIterator / Iterableとして使うことができます。 (後述)

for-of, for-await-of

for-of 構文はIterableをとります。 (→ ForIn/OfHeadEvaluation, ForIn/OfBodyEvaluation)

for (const elem of [1, 2, 3]) {
//                 ^^^^^^^^^ 狭義のIterableなら何でもよい
}

for-await-of 構文はAsyncIterableをとります。AsyncIterable ではない Iterable が来た場合はイテレーターの変換が行われる (GetIterator 内、 CreateAsyncFromSyncIterator) ため、 Iterable を渡すこともできます。

for await (const elem of [1, 2, 3]) {
//                       ^^^^^^^^^ 狭義のAsyncIterable, 狭義のIterableなら何でもよい
}

標準コレクションの作成

狭義のIterableからコレクションを作ることができます。 (Array.from, Map, Set, Object.fromEntries)

const array = Array.from([1, 2, 3]);
const map = new Map([["foo", 1], ["bar", 2], ["baz", 3]]);
const set = new Set([1, 2, 3]);
const obj = Object.fromEntries([["foo", 1], ["bar", 2], ["baz", 3]]);

イテレーターを自分で実装する

後述するジェネレーター関数を使うのが簡単ですが、ジェネレーター関数を使わずに自力でイテレーターを実装することも可能です。

class RangeIterator {
    start;
    end;
    constructor (start, end) {
        this.start = start;
        this.end = end;
    }
    [Symbol.iterator]() { return this; }
    next() {
        if (this.start < this.end) {
            return { done: false, value: this.start++ };
        } else {
            return { done: true };
        }
    }
}

// 10から19までの整数が表示される
for (const x of new RangeIterator(10, 20)) {
    console.log(x);
}

対話的イテレーター

ECMAScriptの仕様では、ここまでに述べた「狭義のイテレーター」よりも広い概念をイテレーターと呼んでいます。本稿では狭義のイテレーターとの区別のために対話的イテレーターという名前で呼ぶことにします。この一般化によってジェネレーター関数の戻り値をイテレーターとして扱うことができるようになります。

interface Iterator<T, TReturn = any, TNext = undefined> {
  next(value: TNext): IteratorResult<T, TReturn>;
  return?(value?: TReturn): IteratorResult<T, TReturn>;
  throw?(e?: any): IteratorResult<T, TReturn>;
}

interface AsyncIterator<T> {
  next(value: TNext): Promise<IteratorResult<T, TReturn>>;
  return?(value?: TReturn): Promise<IteratorResult<T, TReturn>>;
  throw?(e?: any): Promise<IteratorResult<T, TReturn>>;
}

type IteratorResult<T, TReturn = any> =
  // まだ値が残っている場合
  | { done?: false; value: T }
  // もう出力すべき値がない場合
  | { done: true, value: TReturn };

違いは以下の通りです。

  • next が引数を取るようになった。
  • done: true な結果が返されたときの value にも意味が与えられた。 (return value)
  • return メソッドのかわりに呼ぶための throw メソッドが追加された。

Iterableや %IteratorPrototype% もこれらをサポートするように一般化されます。

ジェネレーター関数

原理的には対話的イテレーターを自作することもできますが、現実にはジェネレーター関数を使わずに作ることはほぼないでしょう。ジェネレーター関数は function* / async function* という専用の構文を用いて定義される関数で、このようにして定義された関数は対話的イテレーターの一種であるジェネレーターオブジェクトを返します。

ここまでのインターフェースとクラスの関係をまとめると以下のようになります。

図: Iterator, Iterable, Generatorの関係

中断可能な関数

ジェネレーター関数は中断可能な関数です。呼び出された側の意思で中断 (yield) し、呼び出し側の意思で再開 (next) することができます。

// 関数本体のreturnとは関係なく、必ず対話的イテレーターが返される
const gen = (function*() {
  yield 1;
  yield 2;
  yield 3;
})();
console.log(gen.next()); // => { done: false, value: 1 }
console.log(gen.next()); // => { done: false, value: 2 }
console.log(gen.next()); // => { done: false, value: 3 }
console.log(gen.next()); // => { done: true, value: undefined }

gen.next を呼び出すごとに、 function* 内のコードが1行ずつ実行されていることに注意してください。通常の関数では、returnしたらその関数の実行は終わりで、returnした箇所から再開されることはありません。ジェネレーター関数の中で使えるyieldはreturnと異なり、その位置から実行を再開することが可能です。

関数からオートマトンへ

別の見方として、ジェネレーター関数は「言語処理系が普段は秘密裏に管理している状態管理を、アプリケーションプログラマに開放するもの」と見ることもできます。

たとえば以下のようなジェネレーター関数を考えます。

function* foo() {
  for (const x of ["apple", "orange"]) {
    for (const y of ["pie", "juice"]) {
      yield `${x} ${y}`;
    }
  }
  yield "coffee";
}

このジェネレーター関数が返すジェネレーターは5回yieldします。これはこのジェネレーターが制御フローの構造にあわせて 2 × 2 + 1 = 5 個の状態 (+1個の終了状態) を持っていることに対応しています。同様の状態管理を自前で行うにはたとえば以下のような状態型を定義しなければいけないことになります。

type State =
  | [type: "inLoop1", index1: number, index2: number]
  | [type: "beforeCofeeYield"]
  | [type: "end"];

これをJavaScriptの制御構造から自動的に生成してくれるのがジェネレーターの強力さを物語っていると言えます。

対話的な関数

また、ジェネレーター関数を呼び出し側と呼び出された側の対話プロトコルとして見ることもできます。

図: 通常の関数とジェネレーター関数のシーケンス図

この視点で見るとジェネレーターの next に引数を渡せるのも何だか自然に見えるかもしれません。ただ少々面倒な制約として、このシーケンス図で値の受け渡しが発生しないのが2箇所あります。 (藍色でマークした部分)

  • ジェネレーター関数がジェネレーターオブジェクトを返す瞬間。このときcalleeからcallerに制御が移るが、callerが受け取るのはジェネレーターオブジェクトだけで、ジェネレーター関数の実装に由来する値は渡されない。
  • 最初の next 呼び出し。引数を与えてもcallee側に受け取る口がない。

この「最初の1回分の不整合」問題は yield* の振舞いにも関わってきます。

ジェネレーター関数を用いたイテレーターの例

ジェネレーター関数を使うとイテレーターを簡単に作れる場合があります。イテレーターを作るためのジェネレーター関数は通常以下のような見た目をしています。

  • yield 式の結果を使わない。
  • return に値を渡さない。
function* range(start, end) {
  while(start < end) yield start++;
}
for (const x of range(10, 20)) {
  console.log(x);
}

yield*

ジェネレーター関数の戻り値であるジェネレーターオブジェクト (対話的イテレーターインターフェースを実装している) の対話性を活かした使い方はECMAScript内ではほとんど定義されていません。 (redux-sagaのような面白い使い道を皆さんもぜひ考えてみてください)

唯一ECMAScriptで定義されている利用法は、別のジェネレーター関数内での再利用 にあたる yield* ですyield* はIterableを引数に取り、当該Iterableを消化し切るまで yield を繰り返します。

図:  のシーケンス図

function* h() {
  yield "h() started";
  const val = yield "foo";
  yield `bar ${val}`;
  return "h() is done";
}

function* g() {
  yield "g() started";
  const result = yield* h();
  yield `h() returned with ${result}`;
  return "g() is done";
}

const gen = g();
console.log(gen.next()); // => { done: false, value: "g() started" }
console.log(gen.next()); // => { done: false, value: "h() started" }
console.log(gen.next()); // => { done: false, value: "foo" }
console.log(gen.next(42)); // => { done: false, value: "bar 42" }
console.log(gen.next()); // => { done: false, value: "h() returned with h() is done" }
console.log(gen.next()); // => { done: true, value: "g() is done" }
console.log(gen.next()); // => { done: true, value: undefined }

yield* の特筆すべき点は以下です。

  • yield* は呼び出し元から渡された next の値を子イテレーターに転送しますが、初回実行時はそのような値はないためundefinedがかわりに渡されます。
    • 多くの場合は yield*yield* anotherGeneratorFunction() の形で使われます。この場合先述のように最初の next の引数は無視されるので、うまくここでの効果が打ち消されることになります。
  • 後述するように、 yield* は単なる yield の構文糖衣ではなく、呼び出し元からの return をキャンセルする能力を持ちます。
  • yieldyield* はシーケンス図では逆向きであることは注目に値します。 yield ではまず呼び出し元に制御が移りますが、 yield* では子イテレーターに制御が移ります。戻ってくるときも同様です。これは yield* にサイズ0のIterableを渡したときに呼び出し元に制御が移らないことと整合的です。

returnとthrow

ジェネレーターオブジェクトは return メソッドと throw メソッドを定義しています。 next のかわりにこれらを呼び出すと、サスペンド位置で return 文または throw 文が実行されたのと同等の挙動になります。

function* f() {
  yield 1;
  try {
    yield 2;
  } catch (e) {
    console.log(e);
  }
  yield 3;
}

// 例1
const gen = f();
console.log(gen.next()); // => { done: false, value: 1 }
gen.throw(new Error("foo")); // エラーがthrowされる

// 例2
const gen = f();
console.log(gen.next()); // => { done: false, value: 1 }
console.log(gen.next()); // => { done: false, value: 2 }
gen.throw(new Error("foo")); // エラーはcatchされ、 { done: false, value: 3 } が返る

return は一見すると無意味に見えますが、try-finallyで捕捉されるという特徴があります。

function* f() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    console.log("We put at least one element");
  }
}

const gen = f();
console.log(gen.next()); // => { done: false, value: 1 }
console.log(gen.return(42)); // finally節が実行され、 { done: true, value: 42 } が返る

returnとthrowが呼ばれるタイミング

break, return, throw などで for-of ループが中断されたときはイテレーターのreturnが呼ばれます。 (for-await-of も同様)

function* iter() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    console.log("Iteration has finished");
  }
}

for(const x of iter()) {
  console.log(x);
  if (x === 2) break;
}
// => 1
// => 2
// => Iteration has finished

yield* 状態のジェネレーターに対してreturnやthrowが呼ばれた場合は、 yield* で指定されたイテレーターにreturn/throwが伝搬されます。

  • returnは子イテレーターと親ジェネレーターの両方の中断を試みますが、子イテレーター (ジェネレーターとは限らないことに注意) がreturnに対してイテレートを停止しなかった場合はreturn処理はキャンセルされ、親ジェネレーターは yield* 位置から続行されます。
    • このルールがあるため、 yield* は単なる yield の構文糖衣にとどまらず、returnを抑止する能力を持ちます。
  • 子イテレーターに伝搬されたthrowが親ジェネレーターの処理を止めるかどうかは通常の例外処理に準じます。つまり、子イテレーターが例外を無視することも可能で、その場合は子イテレーターの次のyield/returnの値に応じて親ジェネレーターの yield* が続行します。
    • 特別ケースとして、子イテレーターがthrowハンドラを持たない場合は親ジェネレーター側の yield* の位置でthrowされた扱いになりますが、その前に子イテレーターのクリーンアップのためにreturnハンドラの呼び出しが試みられます。

これ以外にthrowが呼ばれるユースケースはECMAScript内では定義されていませんが、async/awaitをジェネレーター関数にトランスパイルする場合には有用だと考えられます。

引数の処理

ジェネレーター関数を呼び出すと、その実行は関数本体の冒頭で中断した状態で返ってきます。つまりジェネレーター関数を呼び出した瞬間にはほとんど何も起こらないのですが、引数の処理だけは発生する点に注意が必要です。

// 何も起きない
(function*() {
  console.log("foo");
  const { foo } = undefined;
})();

// エラーになる
(function*({ foo }) {})();

// barが出力される
(function*({ bar }) {})({
  get bar() {
    console.log("bar");
  }
});

同期版と非同期版

ここまで説明したことのほとんどは非同期イテレーターや非同期ジェネレーターにもそのまま適用できます。ただし、

  • AsyncIterable[@@asyncIterator] メソッドは同期的な関数です。これは通常状態を初期化する処理だけが必要で、非同期である必要はありません。
  • 標準コレクションは同期的なIterableです。ただし、AsyncIterableが求められる場面では通常Iterableを渡しても自動変換されるようになっているため、これが大きな問題になることはないでしょう。
  • Array.from などIterableから標準コレクションを作成する機能の非同期版は現在のところ定義されていないようです。

またジェネレーター関数があればasync関数 (async/await)asyncジェネレーター関数は比較的簡単にシミュレートすることができます。

まとめ

  • IteratorとIterableの関係
    • Iteratorは狭義には呼び出し元の next 呼び出しに応じて要素を出力するインターフェースである。
    • IterableはIteratorを生成するインターフェースである。
    • IterableだからといってIteratorとは限らず、IteratorだからといってIterableとは限らない。しかし実際には多くのIteratorはIterableのインターフェースも実装している。
    • Iterableとコレクションは相互変換可能である。
    • Iterableは for-of ループで処理できる。
  • IteratorとGeneratorの関係
    • Iteratorはジェネレーターのような双方向的なやり取りのために一般化されたインターフェースも提供している。
    • JavaScriptにおけるジェネレーターとはジェネレーター関数の戻り値のことで、これは一般化されたIteratorのインターフェースを実装している。
  • Generator
    • ジェネレーター関数は yield で自己中断可能な特別な関数で、ジェネレーター関数の中断した処理を再開するためのインターフェースがジェネレーターである。
  • Iterator/Generatorのリソース解放処理
    • Iteratorにはリソース解放を行うために return / catch インターフェースが用意されている。
    • ジェネレーター関数内のtry-catchやtry-finallyはジェネレーターに対する return / catch 呼び出しを捕捉できる。
    • for-of もまた、リソース解放のために return を適切に呼び出すようになっている。

更新履歴

  • 2021/09/09 「引数の処理」セクションを追加。

この記事に贈られたバッジ

Discussion

ログインするとコメントできます