JavaScriptのIterator / Generatorの整理
目的と対象読者
- IteratorとIterableとGeneratorとGenerator Functionの区別が曖昧な人 (記事前半)
- Generatorの制御フローを完全理解したい人 (記事後半)
の理解を深めるための記事です。
まとめ
- IteratorとIterableの関係
- Iteratorは狭義には呼び出し元の
next
呼び出しに応じて要素を出力するインターフェースである。 - IterableはIteratorを生成するインターフェースである。
- IterableだからといってIteratorとは限らず、IteratorだからといってIterableとは限らない。しかし実際には多くのIteratorはIterableのインターフェースも実装している。
- Iterableとコレクションは相互変換可能である。
- Iterableは
for
-of
ループで処理できる。
- Iteratorは狭義には呼び出し元の
- IteratorとGeneratorの関係
- Iteratorはジェネレーターのような双方向的なやり取りのために一般化されたインターフェースも提供している。
- JavaScriptにおけるジェネレーターとはジェネレーター関数の戻り値のことで、これは一般化されたIteratorのインターフェースを実装している。
- Generator
- ジェネレーター関数は
yield
で自己中断可能な特別な関数で、ジェネレーター関数の中断した処理を再開するためのインターフェースがジェネレーターである。
- ジェネレーター関数は
- Iterator/Generatorのリソース解放処理
- Iteratorにはリソース解放を行うために
return
/catch
インターフェースが用意されている。 - ジェネレーター関数内のtry-catchやtry-finallyはジェネレーターに対する
return
/catch
呼び出しを捕捉できる。 -
for
-of
もまた、リソース解放のためにreturn
を適切に呼び出すようになっている。
- Iteratorにはリソース解放を行うために
狭義の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の非同期版です。next
や return
が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
の名前で呼ばれています。
図にすると以下のようになります。
狭義のイテレーターを使う機能
狭義のイテレーターを使う機能として以下のようなものがあります。
標準コレクション
標準コレクションは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*
という専用の構文を用いて定義される関数で、このようにして定義された関数は対話的イテレーターの一種であるジェネレーターオブジェクトを返します。
ここまでのインターフェースとクラスの関係をまとめると以下のようになります。
中断可能な関数
ジェネレーター関数は中断可能な関数です。呼び出された側の意思で中断 (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
をキャンセルする能力を持ちます。 -
yield
とyield*
はシーケンス図では逆向きであることは注目に値します。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ハンドラを持たない場合は親ジェネレーター側の
これ以外に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は狭義には呼び出し元の
- IteratorとGeneratorの関係
- Iteratorはジェネレーターのような双方向的なやり取りのために一般化されたインターフェースも提供している。
- JavaScriptにおけるジェネレーターとはジェネレーター関数の戻り値のことで、これは一般化されたIteratorのインターフェースを実装している。
- Generator
- ジェネレーター関数は
yield
で自己中断可能な特別な関数で、ジェネレーター関数の中断した処理を再開するためのインターフェースがジェネレーターである。
- ジェネレーター関数は
- Iterator/Generatorのリソース解放処理
- Iteratorにはリソース解放を行うために
return
/catch
インターフェースが用意されている。 - ジェネレーター関数内のtry-catchやtry-finallyはジェネレーターに対する
return
/catch
呼び出しを捕捉できる。 -
for
-of
もまた、リソース解放のためにreturn
を適切に呼び出すようになっている。
- Iteratorにはリソース解放を行うために
更新履歴
- 2021/09/09 「引数の処理」セクションを追加。
Discussion