TL

イテレータとジェネレータの内部(遅延列の実装)

巨大な列やフィルタ連鎖が遅い・メモリを食う原因は中間配列。イテレータとジェネレータの内部を押さえると、無限列を必要な分だけ流し、配列を作らず処理できる勘所が見えます。

応用イテレータジェネレータ遅延評価状態機械yield最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.イテレータプロトコルの本質は「次の要素を1つ返す next と、終了を表す印」。要素は要求された瞬間に初めて計算される(遅延評価)。
  • 2.ジェネレータの yield はコンパイル時に状態機械へ変換され、yield ごとに状態番号と退避したローカル変数で「中断・再開できる関数」になる。
  • 3.map/filter を遅延イテレータとして合成すると、各要素は1度の通過で全段を通り抜け、中間配列が一切作られない(イテレータ融合)。

イテレータプロトコル:列を「引き出す」抽象

配列やリストは全要素をメモリに並べて持ちますが、イテレータはそうではありません。イテレータの本質は**「次の要素を要求されたら1つだけ計算して返す」**という最小限のプロトコルです。多くの言語でこれは次の2つに集約されます。

next() -> (要素, まだ続くか)     // 1つ進めて値を返す
                                 // もう要素が無ければ「終了」の印を返す

JavaScriptの反復子は{ value, done }を返すnext()、Pythonは値を返すかStopIterationを投げる__next__()、RustはSome(x)Noneを返すnext()、JavaはhasNext()/next()の組です。表現は違っても意味は同じで、「1つ取り出す」操作と「終端」の表現だけがあれば反復は成立します。forループはこのプロトコルへの糖衣にすぎず、内部ではnextを終端まで呼び続けているだけです。

ここで決定的なのは、要素は要求された瞬間に初めて計算される点です。配列は生成時に全要素が確定しますが、イテレータは「次を出せ」と言われるまで何もしない。この性質を**遅延評価(lazy evaluation)**と呼び、これがあるからこそ無限に続く列や、巨大すぎてメモリに載らない列を扱えます。

イテレータとイテラブルは別物

「イテラブル(反復可能オブジェクト)」は次を出す装置を作り出せるもの(配列など)、「イテレータ」はその装置そのもの(状態を持ちnextに応答する)です。多くの言語で配列をforに渡すと、内部でまず配列からイテレータが1つ生成され、それをnextで消費します。イテレータは普通一度きりで、終端まで進むと使い切られます。

ジェネレータ:yield による状態機械への変換

イテレータを手で書くと、状態(どこまで進んだか)を自分でフィールドに保持し、nextが呼ばれるたびに分岐させる必要があり煩雑です。これを劇的に楽にするのがジェネレータで、その中核がyieldです。

function* counter() {
  let i = 0;
  while (true) {
    yield i;   // ここで値を「1つ出して」中断する
    i++;
  }
}

yieldの意味は「値を1つ呼び出し元へ返し、その場で関数を中断して、次にnextが呼ばれたら続きから再開する」です。普通の関数は最後まで走って戻りますが、ジェネレータ関数はyield地点で止まり、ローカル変数(上のi)を保ったまま再開できます。これはasync/awaitとコルーチンの内部実装awaitとまったく同じ「中断・再開」機構であり、コンパイラはyieldを含む関数を**状態機械(state machine)**へ変換することで実現します。

概念的には、上のcounterは次のような構造体+next関数に書き換えられます。

struct CounterState {
  state: int     // 0=開始, 1=yield後の再開地点
  i: int         // yieldをまたいで生きるローカル変数
}

next(s):
  switch s.state:
    case 0:
      s.i = 0
      // while ループ本体へ
    case 1:
      s.i = s.i + 1   // 前回 yield の続き
  // 共通の継続
  value = s.i
  s.state = 1
  return (value, 続く)   // ここで中断して呼び出し元へ戻る

ポイントは2つあります。第一に、yieldをまたいで生きるローカル変数はスタックではなく状態オブジェクト(ヒープ上)に退避される。関数を中断して呼び出し元へ戻ると元のスタックフレームは消えるため、値を別の場所に保持しておかないと再開時に復元できないからです。これはクロージャが外側変数を閉じ込めて生かすのと同じ発想です。第二に、yield地点ごとに状態番号が割り当てられ、再開時はswitchが状態番号を見て続きへジャンプします。手続き的に見える関数の本体が、内部ではこのジャンプ表として制御構文に再構成されているわけです。

ジェネレータはスタックレスなコルーチン

ジェネレータは「1つの関数の状態だけを退避する」スタックレス・コルーチンの一種です。ゆえに中断できるのはジェネレータ関数の本体に直接書いたyieldのみで、呼び出した先の普通の関数の中からはyieldできません(深い呼び出し先から中断するには各段にyield*委譲などが要る)。「スタックレス=専用スタックを持たない/中断点を関数境界で明示する」という対比を押さえましょう。

遅延評価される無限列

遅延評価の威力が最も際立つのが無限列です。先のcounterwhile (true)で止まらないコードですが、状態機械化されているためyieldのたびに中断して呼び出し元へ戻ります。だから「無限ループ」でも固まりません。全体を生成してから使うのではなく、必要な1要素を要求された瞬間だけ計算が進むからです。

function* naturals() { let n = 1; while (true) yield n++; }
function* take(it, k) {
  for (let i = 0; i < k; i++) {  // k 個に達したら next を呼ばずに終える
    const { value, done } = it.next();
    if (done) return;
    yield value;
  }
}
// 無限列から先頭5個だけを取り出す(next は5回しか呼ばれない)
[...take(naturals(), 5)]; // [1,2,3,4,5]

naturalsは概念上「すべての自然数」ですが、take(_, 5)nextを5回しか呼ばないため、naturals内部のループも5回しか進みません。これが遅延列の核心で、「何を計算するか」と「いくつ計算するか」を分離できます。フィボナッチ列、素数列、ストリームから無限に届くイベントなど、終わりの定まらない列を有限の消費側で安全に切り取れるのです。これは結果が変化しない値を扱うイミュータビリティとも相性がよく、各要素を「要求時に生成される確定値」として淡々と流せます。

遅延列は終端を保証しない

無限ジェネレータを[...gen]for無条件に全消費すると、終端が来ないため無限ループになります。無限列には必ずtaketakeWhile・早期breakなど消費側で打ち切る条件を組み合わせてください。またfilterで「条件を満たす要素が二度と現れない」場合、次の1要素を探して無限に進み続けることがあります(停止性は消費側と述語に依存します)。

イテレータ融合:中間配列を消す

遅延評価がもたらす実務的な最大の利得が**イテレータ融合(fusion)**です。配列に対するmapfilterを素朴に連鎖すると、段ごとに中間配列が作られます。

// 各段が新しい配列を確保する(中間配列が2本生まれる)
data.map(f).filter(p).map(g);

100万要素なら、map(f)で100万要素の配列、filter(p)でさらに別配列…とメモリ確保とコピーが段数ぶん発生します。一方、map/filter遅延イテレータとして合成すると様相が変わります。各段は「上流から1つ受け取り、変換して下流へ1つ渡す」だけの薄いラッパになり、nextを1回呼ぶと1個の要素が全段を一気通貫で流れます。

観点素朴な配列メソッド連鎖遅延イテレータの融合
中間配列段ごとに確保(段数ぶん)ゼロ(1要素ずつ通す)
メモリ各段で全要素ぶん1要素ぶんの作業域
走査回数段ごとに全要素を1周(多重周回)全体で1周
早期終了全段を計算後に絞る下流が止めれば上流も即停止
代表例JS の配列 .map/.filterRust の Iterator, Java Stream, Python の generator

融合の効果は2つあります。1つは中間配列の消滅によるメモリ削減で、連続したメモリへのアクセスが増えキャッシュにも優しくなります(メモリ配置とキャッシュ局所性)。もう1つは早期終了の伝播です。融合されたtake(map(filter(...)), 3)は、下流が3個受け取った時点でnextを呼ぶのをやめるため、上流のfiltermapも4個目以降を一切計算しません。素朴な連鎖が「全要素を変換してから先頭3個を取る」のに対し、融合は「3個ぶんしか計算しない」。RustのIteratorやJavaのStreamが高速なのは、まさにこの遅延合成+融合をコンパイラ/ライブラリが徹底しているからです。

融合が崩れる地点を意識する

途中でcollecttoList/スプレッド[...it]のように列全体を実体化する操作を挟むと、そこで遅延が打ち切られ中間配列が生まれます。連鎖は最後まで遅延のまま保ち、実体化は最終結果が必要な1回だけにするのが定石です。逆に「2回以上走査する」「インデックスでランダムアクセスする」用途では、いったん配列へ実体化したほうが速いこともあります(イテレータは原則一度きりの前進専用です)。

まとめ

イテレータとジェネレータの内部は、(1)「次を1つ返すnext+終端の印」という最小プロトコルと遅延評価、(2)yieldyield地点ごとの状態番号とヒープ退避したローカル変数からなる状態機械へ変換する仕組み、(3)map/filterを薄いラッパとして遅延合成し中間配列と多重周回を消すイテレータ融合、という3層で成り立ちます。「列は要求されるまで計算されない」という一点を握ると、無限列が固まらない理由、融合がメモリと走査を削る理由、早期終了が上流まで効く理由が一貫して説明できます。中断・再開機構の全体像はasync/awaitとコルーチンの内部実装と合わせて押さえると理解が盤石になります。

プログラミング Article

イテレータとジェネレータの内部(遅延列の実装)を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

イテレータ

比較で見る軸

難易度: advanced / カテゴリ: プログラミング / タグ数: 5

導入後に効く点

ジェネレータの yield はコンパイル時に状態機械へ変換され、yield ごとに状態番号と退避したローカル変数で「中断・再開できる関数」になる。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
プログラミング
タグ数
5

判断チェックリスト

  • 自社の用途が「イテレータ / ジェネレータ」に近いか確認する。
  • 強みである「イテレータプロトコルの本質は「次の要素を1つ返す next と、終了を表す印」。要素は要求された瞬間に初めて計算される(遅延評価)。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

イテレータジェネレータ遅延評価状態機械yieldイテレータジェネレータ遅延評価