巨大なデータを扱うとき、すべてを一度にメモリへ読み込むと速度低下やメモリエラーを招きます。
そこで役に立つのがジェネレータです。
ジェネレータは必要になった瞬間に要素を1つずつ生成する仕組みで、遅延評価によりメモリ使用量を大幅に抑えられます。
本記事では、ジェネレータの基礎から実用パターン、注意点まで段階的に解説します。
ジェネレータの基礎
ジェネレータとは?
ジェネレータとは、yield
を含む関数や、括弧を使ったジェネレータ式により作られる、要素を逐次的に生成するオブジェクトです。
作成されたジェネレータはイテレータプロトコル( __iter__
と __next__
)に準拠し、for
ループやnext()
で1要素ずつ取り出せます。
すべての要素を前もって保持しないため、巨大なデータ列でも省メモリに扱える点が特徴です。
- 関連記事:returnとの違いは?yieldとジェネレータ活用術
- 公式ドキュメント: 組み込み関数 next() / 例外 StopIteration
- 公式ドキュメント: 式 — ジェネレータ式 / yield 式
遅延評価で省メモリ
ジェネレータの中核は遅延評価です。
next()
やfor
で要素が要求された瞬間にだけ値を作ります。
これにより、例えば1億件の数値を処理しても、一度に保持するのは多くても数個分です。
すべてをリストにしてから処理するやり方は、メモリを圧迫するため避けるべき場合が多いです。
イテレータとの違い
ジェネレータはイテレータの一種ですが、次の点が実務での違いとして意識されます。
- ジェネレータは
yield
かジェネレータ式で簡潔に書けます。イテレータは通常、クラスで__iter__
/__next__
を実装します。 - ジェネレータは状態管理や停止条件を
yield
の位置で直感的に表現でき、読みやすいコードになりやすいです。 - どちらも1回限りの順次消費である点は共通です。
以下に簡単な比較表を示します。
項目 | ジェネレータ | 一般的なイテレータ(クラス) |
---|---|---|
作り方 | yield やジェネレータ式 | __iter__ /__next__ を自前実装 |
記述量 | 少ない | 多い |
状態管理 | ローカル変数とyield で自然に表現 | フィールドで明示管理 |
再利用 | 不可(使い切り) | 不可が基本(使い切り) |
使いどころ(巨大データ/I/O)
ジェネレータが特に効力を発揮するのは次のような場面です。
I/Oを伴う処理は待ち時間が発生しやすく、生成と消費を重ねるストリーミングで全体の効率を上げられます。
- 巨大ファイルを1行ずつ解析する
- ネットワークやDBからの逐次取得を加工しながら処理する
- 画像やログなどをチャンク分割しながら変換する
- 無限列(ストリーム)を必要な分だけ取り出す
使い方の基本
yieldの最小例
yield
を使った最小のジェネレータ関数です。
呼び出すとジェネレータオブジェクトが返り、next()
で要素を取り出します。
# 最小のジェネレータ例
def simple_gen():
# 値を1つずつ「遅延生成」して返す
yield 1
yield 2
yield 3
g = simple_gen()
print(next(g)) # 1つ目を取り出す
print(next(g)) # 2つ目
print(next(g)) # 3つ目
# これ以上は要素がないので StopIteration が送出される
try:
print(next(g))
except StopIteration:
print("StopIteration が発生しました")
1
2
3
StopIteration が発生しました
ジェネレータ式(generator expression)
内包表記に似た書き方で、括弧を用いるとジェネレータ式になります。
リスト内包表記と異なり、結果を逐次生成します。
# リスト内包表記は全要素をメモリに載せる
squares_list = [x * x for x in range(5)] # [0, 1, 4, 9, 16]
# ジェネレータ式は要素を都度生成
squares_gen = (x * x for x in range(5))
print("list:", squares_list)
print("gen 1つ目:", next(squares_gen))
print("gen 2つ目:", next(squares_gen))
list: [0, 1, 4, 9, 16]
gen 1つ目: 0
gen 2つ目: 1
next()とforで消費する
ジェネレータはnext()
で手動消費しても、for
で自動消費しても構いません。
通常は可読性の高いfor
を使います。
def up_to(n):
for i in range(n):
yield i
g = up_to(3)
# next()で手動消費
print(next(g)) # 0
print(next(g)) # 1
# 残りをforで消費
for x in g:
print("for:", x) # 2 が出力されて終了
0
1
for: 2
一度きりの消費と再利用の注意
ジェネレータは使い切りです。
同じジェネレータを2回ループしても、2回目は何も出てきません。
def nums():
for i in range(3):
yield i
g = nums()
print(list(g)) # [0, 1, 2] で使い切り
print(list(g)) # 空になる(すでに消費済み)
[0, 1, 2]
[]
再度使うには新しいジェネレータを作り直すか、どうしても複数回使いたい場合はitertools.tee
の利用を検討します(後述の注意点参照)。
- 関連記事:イテレーションを効率化 (chain,islice,permutations)の使い方
- 関連記事:複雑なforループは卒業!itertoolsで多重ループ整理
- 公式ドキュメント: itertools.tee
rangeとの使い分け
range
はメモリ効率の良い数列オブジェクトで、全要素を保持せずに長さやインデックスアクセスが可能です。
一方、ジェネレータは計算やI/Oを伴う任意の生成処理を記述できます。
比較項目 | range | ジェネレータ |
---|---|---|
対象 | 整数の等差数列 | 任意の生成処理(計算やI/Oを含む) |
メモリ効率 | 高い(一定) | 高い(逐次生成) |
インデックス | 可能(例: range(10)[3] ) | 不可 |
長さ | 取得可能(len(range(...)) ) | 不可 |
再利用 | 可能(何度でもイテレート可) | 不可(使い切り) |
- 数の連続列だけが必要なら
range
が最適です。 - ファイル読み込みやフィルタ、動的生成が絡むならジェネレータが適しています。
実用パターン
大きなファイルを1行ずつ処理
巨大ファイルを丸ごとread()
で読むのではなく、1行ずつ処理します。
以下はデモのためio.StringIO
を使いますが、実運用ではopen("path")
に置き換えてください。
from io import StringIO
def iter_lines(handle):
# with のスコープはジェネレータの寿命に合わせて維持されます
# 実ファイルなら: with open(path, "r", encoding="utf-8") as f:
with handle as f:
for line in f:
line = line.rstrip("\n")
# 空行をスキップしつつ、必要なら加工
if line:
yield line
# デモ用: メモリ上のファイル風オブジェクト
fake_file = StringIO("alpha\n\nbeta\ngamma\n")
for ln in iter_lines(fake_file):
print(ln)
alpha
beta
gamma
ポイント: 1行ずつ処理することで、ファイルが巨大でも常に少量のメモリで済みます。
変換とフィルタのパイプライン
ジェネレータを段階的につなぐと、ストリーミング処理のパイプラインが作れます。
各段階が都度1件ずつ処理するため、待ち時間やメモリを抑えつつ読みやすい構造になります。
from io import StringIO
def read_lines(handle):
with handle as f:
for line in f:
yield line.strip()
def parse_ints(lines):
for s in lines:
if s: # 空行を除外
# 変換に失敗したものはスキップ
try:
yield int(s)
except ValueError:
continue
def filter_positive(nums):
for n in nums:
if n > 0:
yield n
def square(nums):
for n in nums:
yield n * n
# デモ入力
fake = StringIO("10\n0\n-3\nfoo\n5\n")
pipeline = square(filter_positive(parse_ints(read_lines(fake))))
for v in pipeline:
print(v)
100
25
チャンク分割で逐次処理
データを一定サイズの塊(チャンク)ごとに処理すると、I/Oやバッチ処理を効率化できます。
def chunked(iterable, size):
"""iterableをsize件ずつのリストにまとめて逐次返すジェネレータ"""
chunk = []
for item in iterable:
chunk.append(item)
if len(chunk) >= size:
# ここでチャンクを1つ返してリセット
yield chunk
chunk = []
if chunk:
# 端数があれば最後に返す
yield chunk
# 例: 100件のIDを10件ずつDBにバルク挿入するイメージ
ids = range(1, 21) # デモとして20件
for group in chunked(ids, size=6):
print("insert bulk:", group)
insert bulk: [1, 2, 3, 4, 5, 6]
insert bulk: [7, 8, 9, 10, 11, 12]
insert bulk: [13, 14, 15, 16, 17, 18]
insert bulk: [19, 20]
無限シーケンスの生成
無限列もジェネレータなら容易です。
必要な分だけ取り出します。
def fib():
"""フィボナッチの無限列"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# 先頭10個だけ消費
from itertools import islice
first10 = list(islice(fib(), 10))
print(first10)
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
注意点とベストプラクティス
list化は最後に行う
安易にlist(gen)
すると、せっかくの省メモリ性が失われます。
可視化や最終結果としての材料化が必要な段階でのみリスト化しましょう。
def numbers(n):
for i in range(n):
yield i * i
# 悪い例: 最初に全部リスト化してしまう
# big = list(numbers(1_000_000))
# 良い例: ストリーミングで合計だけ計算
total = sum(numbers(1_000_000))
print("sum:", total)
sum: 333332833333500000
複数回使うなら再生成する
ジェネレータは1回限りです。
複数回必要なら、関数をもう一度呼んで新しいジェネレータを作る設計にするか、itertools.tee
を使います。
ただしtee
は内部バッファを持つため、片方の消費が極端に遅いとメモリ消費が増える点に注意が必要です。
from itertools import tee
def gen():
for i in range(5):
yield i
g1, g2 = tee(gen(), 2)
print(list(g1)) # [0, 1, 2, 3, 4]
print(list(g2)) # [0, 1, 2, 3, 4]
[0, 1, 2, 3, 4]
[0, 1, 2, 3, 4]
例外時のクリーンアップ(close)
ジェネレータはclose()
で明示的に終了させられます。
また、ジェネレータ内部でtry/finally
を使うと、消費途中で例外が起きても確実に後始末できます。
def managed_resource():
print("open resource")
try:
for i in range(3):
yield i
finally:
# 例外やclose()呼び出し時に必ず実行される
print("cleanup resource")
g = managed_resource()
print(next(g)) # open
g.close() # 明示クローズ -> finally 実行
# 再デモ: 途中で例外が発生するケース
g2 = managed_resource()
try:
for x in g2:
print("use:", x)
if x == 1:
raise RuntimeError("oops")
except RuntimeError:
print("handled error")
open resource
0
cleanup resource
open resource
use: 0
use: 1
cleanup resource
handled error
実ファイルやネットワークリソースを扱う場合は、with
ブロックをジェネレータ内に置く、または外側でコンテキストマネージャ化するなど、クリーンアップが自動で走る構造にしましょう。
- 関連記事:try-except-finallyの正しい使い方(後始末/リソース解放)
- 関連記事:with構文とは?ファイルやソケットを安全に閉じる理由
- 公式ドキュメント: with文 / コンテキスト管理
メモリ/速度の簡易計測方法
time.perf_counter()
とtracemalloc
で簡易比較が可能です。
リスト内包表記とジェネレータ式の差を測ってみます。
import time
import tracemalloc
N = 2_000_00 # 20万 (デモ用に控えめ)
def calc_list():
# 全要素を先に作る
return sum([i * i for i in range(N)])
def calc_gen():
# 都度生成して合計
return sum(i * i for i in range(N))
def timed(func):
tracemalloc.start()
t0 = time.perf_counter()
result = func()
t1 = time.perf_counter()
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return {
"result": result,
"time_sec": t1 - t0,
"peak_kb": peak / 1024.0,
}
print("list:", timed(calc_list))
print("gen :", timed(calc_gen))
例としての出力(環境により変動します):
list: {'result': 2666666666700000, 'time_sec': 0.18, 'peak_kb': 8200.5}
gen : {'result': 2666666666700000, 'time_sec': 0.14, 'peak_kb': 0.7}
このようにピークメモリ使用量はジェネレータ式が圧倒的に小さいことが分かります。
速度はケースにより変わりますが、I/Oや大規模データではメモリ節約が全体の効率を改善しやすいです。
まとめ
ジェネレータは遅延評価により巨大データを省メモリで扱える実践的な仕組みです。
yield
とジェネレータ式で簡潔に書け、パイプラインやチャンク処理、無限列など多彩なパターンに適用できます。
ポイントは次の通りです。
ジェネレータは使い切りであること、list()
による材料化は最後に限定すること、必要なら再生成やitertools.tee
を検討すること、そしてtry/finally
やclose()
でクリーンアップを確実にすることです。
これらを踏まえ、巨大なデータ列やI/O中心の処理をストリーミング思考へ切り替えると、コードはより効率的で堅牢になります。