【TS】今さら聞けないイテレータ・ジェネレータ
はじめに
いきなりですが「イテレータ」と「ジェネレータ」について説明できますか?
「なんか配列的なアレでしょ?」って感じで曖昧に濁していたりしませんか?(私はそうでした)
今回は、Typescript
のサンプルを見ながら「イテレータ」と「ジェネレータ」について解説していこうと思います。
イテレータとは
説明としては以下の1行に集約されます。
「イテレータ」とは、「イテラブル」なオブジェクトから生成されるオブジェクトで、「イテレータリザルト」を返します。
・・・ちょっと何を言っているか分からないですね。
大丈夫です、この説明を聞いた時は私自身も鳩が豆鉄砲食らったような顔をしてました。
いくつか分からない用語が出てきたので、順番に解説していきます。
このあたりは暗記できれば最高ですが、何回か読み返していただいて雰囲気を掴むだけでもOKです。
イテレータリザルト
イテレータリザルトとは、値を表すvalue
とイテレータが完了したかどうかを表すdone
という2種類のプロパティを持つオブジェクトのことです。
よって、以下のようなHoge
型のインスタンスhoge
はイテレータリザルトであるといえます。
type Hoge = {
value: string;
done: boolean;
}
// hogeはイテレータリザルト
const hoge: Hoge = {
value: 'hogehoge',
done: false,
}
イテレータ
イテレータはnext()
という関数を持つオブジェクトです。
next()
は前述のイテレータリザルトを返します。
よって下記のHogeIterator
型のインスタンスhogeIterator
はイテレータといえます。
type Hoge = {
value: string;
done: boolean;
}
type HogeIterator = {
next: () => Hoge;
}
// hogeIteratorはイテレータ
const hogeIterator: HogeIterator = {
next : () => {
return { value: 'hogehoge', done: false }
}
}
イテラブルとは
イテラブルであるとは、[Symbol.iterator]()
を実行することで前述のイテレータを返すオブジェクトである。
ここいうところのSymbol.iterator
は予約語です。
よって下記のHogeIterable
型のインスタンスhogeIterable
はイテラブルであるといえます。
type Hoge = {
value: string;
done: boolean;
}
type HogeIterator = {
next: () => Hoge;
}
type HogeIterable = {
[Symbol.iterator]: () => HogeIterator;
}
// hogeIterableはイテラブル
const hogeIterable: HogeIterable = {
[Symbol.iterator]: () => {
return {
next : () => {
return { value: 'hogehoge', done: false }
}
}
}
}
よく使うイテラブルな型
「イテラブル」という言葉自体はなかなか意識しませんが、下記のような型もイテラブルな型です。
-
Array
型 -
String
型 -
Iterator
(イテレータそのものもイテラブルである。[Symbol.iterator]()
を実行した場合は自身を返す)
この他にもMap
やSet
もそうですし、イテレータの特性を応用した「ジェネレータ」というものもあります。
どんな時にイテレータを使う?
定義としては分かりましたが、では実際にどんなタイミングでこれらを使用するのでしょうか?
よくあるのが「ループ等を使って値を順番に取り出したい場合」が主です。
例えば普段よく利用しているfor...of
構文も内部的にはイテレータを扱っています。
const arr: string[] = ['hoge', 'fuga', 'puni'];
for(const v of arr) {
// 内部的にはarrからイテレータリザルトを順番に取り出して利用している。
// 例えば1回目のループはイテレータリザルトとして
// { value: 'hoge', done: false }
// を取り出している。
}
この他にもスプレッド演算子や分割代入など、Javascript
やTypescript
の世界でよく目にする構文・記法は裏側ではイテレータを利用していることが多いです。
const arr: string[] = ['hoge', 'fuga', 'puni'];
// `...`と組み合わせることで「全てのイテレータリザルト内のvalueの配列」を作っている
const arr2: string[] = [...arr, 'bazu'];
正直なところイテレータを扱った処理を理解していなくても扱うことができるレベルの構文が用意されているため、自作関数内でわざわざイテレータリザルトのvalue
やdone
を参照することは稀です。
また、イテレータ自体を自作することはあまりなく、あったとしても後述のジェネレータを使うケースが殆どという印象です。
ジェネレータとは
ジェネレータはジェネレータ関数から生成されたイテレータのことを指します。
また新しい用語が出てきたので、先に解説をしていきます。
ジェネレータ関数
function*
で宣言される関数のことです。
内部でyield
やyield*
を使うことができます。
ジェネレータ関数の挙動は通常の関数とは異なります。
ジェネレータ関数の実行時にはジェネレータを返却します(ジェネレータを返却するだけで、この時点では関数内の処理を実行しません)。
関数の呼び出し側が、返却されたジェネレータのnext()
を実行するたびに、ジェネレータ関数内のyield
を順番に処理していきます。
例えば以下のようなジェネレータ関数があったとして
function* generatorFunction (name: string) {
console.log("0");
yield name; // 1つ目のyield
console.log("1");
const hoge: {} = { name };
yield hoge; // 2つ目のyield
console.log("2");
const arr: string[] = ['hoge', 'fuga', name] ;
yield arr; // 3つ目のyield
console.log("3");
name='finish';
return name;
}
このジェネレータ関数を実行することで、ジェネレータが生成されます。
// ジェネレータを生成
const generator = generatorFunction('test');
先に述べた通り、この時点ではジェネレータ関数はジェネレータを返すだけなので、generatorFunction
内に埋め込んであるconsole.log()
は実行されません。
作成したジェネレータのnext()
を実行したタイミングではじめて「最初のyield
」までを実行します。
next()
で返却されるイテレータリザルトのvalue
にはyield
で指定した値が入ります。
// next()でgeneratorFunction中の最初のyieldまでが実行される
const first = generator.next();
// ※このタイミングでconsole.log("0")が実行される
console.log(first); // --> { value: 'test', done: false }
以降next()
を実行するたびに、次のyield
までが順番に処理され、その都度イテレータリザルトが返ります。
そしてジェネレータ関数の最後まで実行が行われた段階でdone
がtrue
になったイテレータリザルトが返ります。このときvalue
はジェネレータ関数でreturn
していた値が入ります。
// 2つめのyieldに値がvalueに入る
console.log(generator.next()) // --> { value: { name: 'test' }, done: false }
// 3つめのyieldに値がvalueに入る
console.log(generator.next()) // --> { value: [ 'hoge', 'fuga', 'test' ], done: false }
// 関数の実行が終了したのでdoneがtrueになる
console.log(generator.next()) // --> { value: 'finish', done: true }
ジェネレータの合成
ジェネレータ関数中のyield
には単一の値を渡すことができますが、yield*
を指定することで「イテレータそのもの」を渡すことができます。
例えば、以下のようなジェネレータがあった場合に
function* generatorFunction2() {
yield 'hoge';
yield 'fuga';
yield 'puni';
}
yield
は3回定義されているため以下のように呼び出せます。
const generator = generatorFunction2();
console.log(generator.next()); // --> { value: 'hoge', done: false }
console.log(generator.next()); // --> { value: 'fuga', done: false }
console.log(generator.next()); // --> { value: 'puni', done: false }
console.log(generator.next()); // --> { value: undefiend, done: true }
これをyield*
を使って以下のように書き換えることで、イテレータを渡すことができます。
※実行結果は同じです。
function* generatorFunction2() {
yield* ['hoge', 'fuga', 'puni'];
}
また、yield*
にはイテレータを渡すことができるため、ジェネレータを渡すこともできます。
つまり、少し冗長な表現になりますが上記を書き換えて下記のようにすることもできます。
function* generatorFunction1 () {
yield "hoge";
yield "fuga";
yield "puni";
}
// generatorFunction1からジェネレータを生成
const generator1 = generatorFunction1();
function* generatorFunction2 () {
// 先ほどのジェネレータをyield*に渡す
yield* generator1;
}
// generatorFunction2からジェネレータを生成
const generator2 = generatorFunction2();
// for...ofにはイテレータを渡せるのでジェネレータも渡せる
for(const value of generator2) {
console.log(value);
}
// --> "hoge"
// --> "fuga"
// --> "puni"
エラー「Type'Generator<...>' is not array...」が出る場合
上記ソースを実行した場合、tsconfig
の内容によってはエラーが出る場合があります。
その場合はcompilerOptions
に"downlevelIteration": true
を追加しましょう。
{
"compilerOptions": {
"target": "es5",
"downlevelIteration": true
}
}
どんな時にジェネレータを使う?
イテレータと違い、細かく作り込む場合はジェネレータを使う場面も出てくるかと思います。
ただその場合、ジェネレータの「実行時にはジェネレータを返すだけで、都度next()
を行うことでyield
を順番に処理していく」性質を利用する例が多いです。
何が言いたいのかというと、ジェネレータは関数をyield
区切りで途中中断しながら実行していくことができます。
このyiled
の間に非同期処理を挟むことで、各処理を同期的に扱うことができるようになったりします。
※例えばReact
やReactNative
で使用することがあるredux-saga
というライブラリはこの性質を利用しています。
まとめ
今回は「イテレータ」と「ジェネレータ」についてTypescript
のコードベースでの解説を行いました。
実際のところ内部的な挙動を理解せずとも扱えるレベルまで各構文が整備されているので、なかなか知る機会もないのかなと思います。
知識が必要になってくるのはジェネレータを自作で扱うようになるあたりかなと思うので、その際に参考になれば幸いです。
Discussion