【TS】今さら聞けないイテレータ・ジェネレータ

7 min読了の目安(約6600字TECH技術記事

はじめに

いきなりですが「イテレータ」と「ジェネレータ」について説明できますか?
「なんか配列的なアレでしょ?」って感じで曖昧に濁していたりしませんか?(私はそうでした)

今回は、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]()を実行した場合は自身を返す)

この他にもMapSetもそうですし、イテレータの特性を応用した「ジェネレータ」というものもあります。

どんな時にイテレータを使う?

定義としては分かりましたが、では実際にどんなタイミングでこれらを使用するのでしょうか?
よくあるのが「ループ等を使って値を順番に取り出したい場合」が主です。

例えば普段よく利用しているfor...of構文も内部的にはイテレータを扱っています。

const arr: string[] = ['hoge', 'fuga', 'puni'];

for(const v of arr) {
  // 内部的にはarrからイテレータリザルトを順番に取り出して利用している。
  // 例えば1回目のループはイテレータリザルトとして
  // { value: 'hoge', done: false }
  // を取り出している。
}

この他にもスプレッド演算子分割代入など、JavascriptTypescriptの世界でよく目にする構文・記法は裏側ではイテレータを利用していることが多いです。

const arr: string[] = ['hoge', 'fuga', 'puni'];
// `...`と組み合わせることで「全てのイテレータリザルト内のvalueの配列」を作っている
const arr2: string[] = [...arr, 'bazu'];

正直なところイテレータを扱った処理を理解していなくても扱うことができるレベルの構文が用意されているため、自作関数内でわざわざイテレータリザルトのvaluedoneを参照することは稀です。
また、イテレータ自体を自作することはあまりなく、あったとしても後述のジェネレータを使うケースが殆どという印象です。

ジェネレータとは

ジェネレータはジェネレータ関数から生成されたイテレータのことを指します。
また新しい用語が出てきたので、先に解説をしていきます。

ジェネレータ関数

function*で宣言される関数のことです。
内部でyieldyield*を使うことができます。

ジェネレータ関数の挙動は通常の関数とは異なります。
ジェネレータ関数の実行時にはジェネレータを返却します(ジェネレータを返却するだけで、この時点では関数内の処理を実行しません)

関数の呼び出し側が、返却されたジェネレータ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までが順番に処理され、その都度イテレータリザルトが返ります。
そしてジェネレータ関数の最後まで実行が行われた段階でdonetrueになったイテレータリザルトが返ります。このとき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を追加しましょう。

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "downlevelIteration": true
  }
}

どんな時にジェネレータを使う?

イテレータと違い、細かく作り込む場合はジェネレータを使う場面も出てくるかと思います。
ただその場合、ジェネレータの「実行時にはジェネレータを返すだけで、都度next()を行うことでyieldを順番に処理していく」性質を利用する例が多いです。

何が言いたいのかというと、ジェネレータは関数をyield区切りで途中中断しながら実行していくことができます。
このyiledの間に非同期処理を挟むことで、各処理を同期的に扱うことができるようになったりします。
※例えばReactReactNativeで使用することがあるredux-sagaというライブラリはこの性質を利用しています。

まとめ

今回は「イテレータ」と「ジェネレータ」についてTypescriptのコードベースでの解説を行いました。
実際のところ内部的な挙動を理解せずとも扱えるレベルまで各構文が整備されているので、なかなか知る機会もないのかなと思います。
知識が必要になってくるのはジェネレータを自作で扱うようになるあたりかなと思うので、その際に参考になれば幸いです。

参考