JavaScriptのIteratorを使いこなしたい
JavaScriptのIterator(とGenerator)を使えるようになりたいので、記事を書きました。
今回は、Iterator、Iterable、Generatorとfor...ofについて説明します。
Iteratorとは
Iteratorプロトコルに準拠したオブジェクトのことで、以下のインターフェースを実装したオブジェクトを指します。
// TypeScriptにはIteratorという型が存在します。
// ここではわかりやすさのため型定義をしていますが、実際にはIteratorを使いましょう。
interface TheIterator<T> {
next(): { done: boolean, value: T }
}
Iteratorの利用者は、next関数を呼び出すことで値を取得することができます。
next関数の戻り値は、doneというフラグとvalueという値を含むオブジェクトです。
const iterator: TheIterator<number> // イテレータオブジェクトが定義されているとする。
let result;
do {
result = iterator.next();
console.log(result.value);
} while (!result.done)
↑このように、next関数の戻り値のvalueとdoneをもとに、何度も何度も値を取り出すことができます(場合によっては無限に)。
ただ、これではコードが書きにくいので、Iterableプロトコルが定義されています。
Iterableとは
IterableとはIterableプロトコルに準拠したオブジェクトのことで、以下のインターフェースを実装したオブジェクトを指します。
// Iterableという型が定義されているので、それを使いましょう。
interface TheIterable<T> {
[Symbol.iterator](): Iterator<T>
}
ちょっとわかりづらいかもしれませんが、Symbol.iteratorでアクセスできる関数が生えていて、その関数がIteratorを返すようなインターフェースです。Iterableなオブジェクトはfor...ofで反復処理が可能です。
先ほど、Iteratorはwhile文で処理をしましたが、Iterableは専用の書き方があるので、使いやすくなっています。
例えば、配列はfor...ofで要素ひとつひとつに対して反復処理が可能ですが、これは配列がIterableプロトコルに準拠している(つまり、Iterableインターフェースを実装している)からです。普段なにげなくfor...ofで要素を取り出して処理をしていますが、それらは全てIterableインターフェースを実装しているから処理可能なわけですね。
for...ofの仕様
for...of(for(const item of iterable)
)は内部では次のような処理を行なっています。
- iterable[Symbol.iterator]関数を呼び出して、Iteratorオブジェクトを取得する
- 1で取得したオブジェクトのnext関数を呼び出す
- もし、doneがtrueならループを抜ける
- もし、doneがfalseならvalueをitemにセットする
- 2を繰り返す
ループを抜けるときにとある関数を呼ぶなど特殊な仕様もあったりしますが、今回は細かいことは考えないようにします。
上でも書きましたが、配列などでもfor...ofが利用できます。これは配列が[Symbol.iterator]関数を実装しているからです。試しに、関数を呼んでみるとちゃんとIteratorが取得できます。おもしろいですね。
IterableIteratorとは
IteratorでかつIterableなものです。Iteratorはfor...ofできなくて不便ですよね。IteratorにIterableを実装してしまえばfor...ofできるので使いやすくなります。自分でIteratorを定義するときは、Iterableにしておくと扱いやすくてよいでしょう。
IterableIteratorは以下のようなインターフェースです。
// これもIterableIteratorという定義がありますから、それを使いましょう。
interface TheIterableIterator<T> {
[Symbol.iterator](): Iterator<T>
next(): { done: boolean; value: T }
}
カンのよい方はIterableとIteratorの矛盾に気がついたかもしれません。IterableはIteratorを生成するオブジェクトであるので、親は子でもある、みたいな状態になってしまいます。
ですが、これは簡単に解決できます。[Symbol.iterator]関数で自分自身を返せばよいのです。つまり、以下のようなコードです。
const iterableIterator = {
[Symbol.iterator]() {
return this;
},
i: 0,
next() {
if (i > 5) {
return { done: true, value: this.i };
}
return { done: false, value: this.i++; };
},
}
これで、簡単に数字を生成するIteratorが作れました。Iterableにも準拠しているため、for...ofも使えます。
IterableとIterator
では、常にIterableIteratorを作ればよいのかというと、そうではありません。IterableIteratorの欠点は一度しか利用できないことです。
上記の例で作ったiterableIteratorは一度for...ofで利用すると、使い切ってしまう(iが5になったままになる)ため、再利用できません。
Iterableでは、Iteratorを返す関数を実装するため、よほど変な実装をしなければ再利用できるでしょう。
const iterable: Iterable = {
[Symbol.iterator]() {
let i = 0;
return {
next() {
if (i > 5) {
{ done: true, value: i }
}
return { done: false, value: i++ }
}
}
}
}
↑これならば、[Symbol.iterator]関数を呼び出す度に新たなIteratorを生成するので、何度でもfor...ofで反復処理を実行できます。
Iteratorを単独で作ることは少ないと思います。for...ofできないので、next関数を使って細やかな処理を行いたい場合に向いていると思いますが、そのような場面は少ないでしょう。
Generatorとは
とつぜん全然関係なさそうな名前が出てきましたが、Iteratorにとても関係の深いものです。Generatorはジェネレータ関数によって返される特殊なオブジェクトで、IterableIteratorでもあります。
細かい話をすると、JavaScriptの定義ではジェネレータ関数が生成するオブジェクトのことをGeneratorオブジェクトと言いますが、TypeScriptの型定義ではIterableIterator = Generator(正確には多少異なりますが、ほぼ同じ定義)となっています。コードを書く上では、ジェネレータ関数はIterableIteratorを生成するものだと思っておいてよいでしょう。
ジェネレータ関数は、yieldを使った特殊な構文です。以下のように書けます。
function* generator() {
yield 0;
yield 1;
yield 2;
yield 3;
}
なんと↑この書き方で、0から3までを反復処理できるイテレータを作ることができます。
const iterator = generator();
console.log(iterator.next()) // { done: false, value: 0 }
console.log(iterator.next()) // { done: false, value: 1 }
console.log(iterator.next()) // { done: false, value: 2 }
console.log(iterator.next()) // { done: false, value: 3 }
console.log(iterator.next()) // { done: true }
for...ofでも処理できますが、わかりやすさのため、next関数を呼んでいます。
Redux SagaでもGeneratorは使われていた
Redux SagaはReactの状態管理ライブラリであるReduxの副作用や非同期処理をあつかうためのツールです。あまり詳しくないので詳細については説明できませんが、サンプルコードをみると以下のようにジェネレータ関数が普通に使われています。
function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({type: "USER_FETCH_SUCCEEDED", user: user});
} catch (e) {
yield put({type: "USER_FETCH_FAILED", message: e.message});
}
}
Redux Sagaを使用していた人の中には、IterableやIteratorを扱っていると思わずにコードを書いていた人もいるでしょう。
おそらくですが、Redux Sagaでジェネレータ関数を利用していたのはジェネレータ関数の特殊な仕様を利用して副作用や非同期処理を書きやすくする狙いがあったものと思います。
ジェネレータ関数の特殊な仕様
ジェネレータ関数は特殊な仕様になっており、関数の処理を途中で止めることができます。
以下のコードを見てください。
function* generator() {
console.log("1");
yield 1;
console.log("2");
yield 2;
}
const gen = generator();
console.log(gen.next());
このコードを実行すると、コンソールには以下のように出力されます。
1
{done: false, value: 1}
console.log("2")
以降の行は実行されず、gen.next()
が実行されるのをいまか今かと待っています。逐次的にコードを書いているのに、途中で止めることができる(止まっているように見える)のが副作用や非同期処理を書きやすくしていたのだと思います。
また、さらに不思議なことに、以下のようにも書けます。next関数に値を渡すことができます。ジェネレータ関数の中ではyieldの戻り値で値を受け取ることができます。
function* generator() {
const x = yield 1;
console.log(x);
const y = yield 2;
console.log(y);
const z = yield 3;
console.log(z);
}
const gen = generator();
console.log(gen.next());
console.log(gen.next(10));
コンソールには以下のように出力されます。
{ value: 1, done: false } ... ①
10 ... ②
{ value: 2, done: false } ... ③
まず、1回目のgen.next()
で、yield 1が実行された結果、①のログが出力されます。
つぎに、2回目のgen.next(10)
でconst xに10が入り、②のログが出力されます。まったく直感的ではありませんが、そうなります。そして、yield 2が実行された結果、③のログが出力されます。
const yには3回目のgen.next()
を呼ぶまで値が入りません。いまかいまかと待ち続けることになります。
理由はわかりませんが、初回のnext関数に渡した値はどこからも取得できません。2回目以降に渡した値がyieldの戻り値として取得できます。
ジェネレータ関数を普通の関数に書き直してみる
ジェネレータ関数を普通の関数にトランスパイルすると以下のようになるのでしょう。
function* generator() {
const x = yield 1;
console.log(x);
const y = yield 2;
console.log(y);
}
// ↓
function generator() {
let counter = 0;
return {
next(param?: number) {
counter++;
switch (counter) {
case 1:
return { done: false, value: 1 };
case 2:
const x = param;
console.log(x);
return { done: false, value: 2 };
case 3:
const y = param;
console.log(y);
return { done: true };
default:
return { done: true };
}
}
}
}
よく読めば、同じ挙動をすることがわかると思います。(あるいは、コピペして実行してみてもよいでしょう)
とはいえ、このような複雑な実装をするくらいなら、ジェネレータ関数を使った方がよいように思います。
ここまでで、IteratorからGeneratorまで一通り説明できたかなと思います。
Discussion