データを一度に全てメモリに載せず、必要になった時だけ順次取り出して処理したい場面は多くあります。
Pythonのジェネレータは、遅延評価により省メモリで効率的な反復処理を実現する仕組みです。
本記事では、ジェネレータの基本から実用的な活用法まで、初心者の方にも分かりやすく段階的に解説します。
ジェネレータとは?遅延評価で省メモリ
ジェネレータとイテレータの基本
ジェネレータは、Pythonの「イテレータ」を作るための仕組みです。
yield
を使った関数や、ジェネレータ式で作られます。
必要な値を1つずつ生成して返すため、全体をメモリに展開しないのが特徴です。
以下の用語の関係を整理します。
用語 | 説明 | 代表例 |
---|---|---|
イテラブル(iterable) | for で回せるオブジェクト。__iter__() を実装 | list , str , dict , range |
イテレータ(iterator) | 次の要素を返す__next__() を持つ | iter(list) の結果、ジェネレータ |
ジェネレータ(generator) | yield で値を順次返すイテレータ | ジェネレータ関数、ジェネレータ式 |
簡単なジェネレータ関数の例です。
# 0, 1, 4, 9, ... と平方数を必要な分だけ順次生成するジェネレータ
def squares(limit):
# limit個ぶんの平方数を遅延生成
for n in range(limit):
# 値を1つ返して関数の状態を一時停止
yield n * n
# 使い方例: forループで消費
for v in squares(5):
print(v)
0
1
4
9
16
遅延評価の仕組みとメリット
ジェネレータ関数を呼ぶと、すぐには実行されず「ジェネレータオブジェクト」を返します。
next()
で初めて実行され、yield
に到達した時点で値を1つ返し停止します。
次のnext()
で続きを再開する、という動きを繰り返します。
遅延評価の主なメリットは次の通りです。
全て文章で説明します。
まず、省メモリであるため巨大なデータも扱いやすく、メモリ不足を避けられます。
また、途中で処理を打ち切れるためムダな計算を省けます。
さらに処理のパイプライン化(段階的な加工)が容易で、ストリーミング処理に適します。
大量データ処理に向くケースと注意点
ジェネレータが特に力を発揮するのは、巨大ファイルの逐次処理、ネットワークやデータベースからのストリーム処理、無限列(際限ないシーケンス)の計算などです。
一方で、ジェネレータは一度消費すると再利用できず、ランダムアクセスやlen()
ができません。
デバッグ時にうっかり消費してしまうと後続の処理が空振りする点にも注意が必要です。
yieldの使い方とジェネレータ関数の書き方
yieldとreturnの違い
return
は関数を即座に終了して「最終結果」を返すのに対し、yield
は「途中結果」を1つ返して関数の実行を一時停止します。
yield
を含む関数は「ジェネレータ関数」として扱われます。
キーワード | 役割 | 関数の性質 |
---|---|---|
return | その場で関数終了、値を1回だけ返す | 通常の関数 |
yield | 値を1つ返して停止、次の呼び出しで再開 | ジェネレータ関数 |
比較コードを示します。
# return版: すべての結果を一括で返す(メモリ消費が大きい)
def build_squares_list(limit):
# 全要素をリストに格納してから返す
return [n * n for n in range(limit)]
# yield版: 必要になったときに1つずつ返す(省メモリ)
def gen_squares(limit):
for n in range(limit):
# 値を1つずつ遅延生成
yield n * n
# 使い方比較
print("return版:", build_squares_list(5)) # すぐにリストができる
print("yield版:", list(gen_squares(5))) # 消費するときに生成される
return版: [0, 1, 4, 9, 16]
yield版: [0, 1, 4, 9, 16]
なお、ジェネレータ関数内でreturn 値
を書くとStopIteration
例外にその値が格納されて終了します。
通常は明示的に使わず、yield
で値を返す設計にします。
nextで取り出す反復の流れ
next()
はジェネレータから次の値を1つ取り出す関数です。
yield
で停止・再開する流れを確認します。
def count_up():
print("start") # 最初のnext()でここから実行
yield 1 # 値1を返して停止
print("resume A") # 次のnext()でここから再開
yield 2
print("resume B")
yield 3
print("end") # 次のnext()でStopIteration
g = count_up()
print(next(g)) # 最初の値: 1
print(next(g)) # 次の値: 2
print(next(g)) # 次の値: 3
# 次を取り出そうとすると要素が尽きてStopIteration
try:
print(next(g))
except StopIteration:
print("StopIteration発生")
start
1
resume A
2
resume B
3
end
StopIteration発生
StopIterationとforループの関係
for
ループは内部でnext()
を呼び、尽きたらStopIteration
を捕まえて自動的にループを終了します。
つまり、通常はtry/except
を書かずにfor
を使うだけで安全に消費できます。
def gen123():
yield 1
yield 2
yield 3
# forは内部でStopIterationを処理する
for x in gen123():
print(x)
print("forループ終了") # 例外処理は不要
1
2
3
forループ終了
ジェネレータ式の書き方と使い分け
ジェネレータ式の基本構文
ジェネレータ式は、内包表記に似た書き方でイテレータを作る構文です。
丸括弧で囲みます。
# 0〜4の平方を遅延生成するジェネレータ式
g = (x * x for x in range(5))
# 1つずつ取り出す
print(next(g))
print(next(g))
# 残りを合計(消費しながら計算)
print(sum(g))
0
1
29
リスト内包表記との違いとメモリ比較
リスト内包表記は結果を即時にリスト化するため、要素数に比例してメモリを消費します。
一方、ジェネレータ式は要素を1つずつ計算して返すので一定のメモリで済みます。
sys.getsizeof()
は概算であり、リスト要素分のメモリを完全には反映しませんが、相対的な違いを体感できます。
import sys
N = 100_000
# リスト内包表記: すぐに10万要素のリストを作る
lst = [i * 2 for i in range(N)]
# ジェネレータ式: 必要時に1つずつ取り出す
gen = (i * 2 for i in range(N))
print("listの概算サイズ(bytes):", sys.getsizeof(lst))
print("generatorの概算サイズ(bytes):", sys.getsizeof(gen))
# 両者の結果は同じだが、作り方が異なる
print("listの最初の3要素:", lst[:3])
# generatorはスライスできないため、nextで取り出す
g_copy = (i * 2 for i in range(3))
print("generatorの最初の3要素:", [next(g_copy) for _ in range(3)])
listの概算サイズ(bytes): 800984
generatorの概算サイズ(bytes): 200
listの最初の3要素: [0, 2, 4]
generatorの最初の3要素: [0, 2, 4
上の数値は環境で異なりますが、ジェネレータ式が極めて小さいメモリで表現されることが分かります。
フィルタやマップとの組み合わせ
ジェネレータ式はif
節でフィルタしたり、入れ子にして段階的に加工するのが得意です。
map
やfilter
もイテレータを返すため、相性が良いです。
# 1〜20のうち偶数だけ2乗し、そのうち100以上のものを合計
nums = range(1, 21)
evens = (n for n in nums if n % 2 == 0) # フィルタ
squares = (n * n for n in evens) # マップ
large = (x for x in squares if x >= 100) # さらにフィルタ
print(sum(large)) # 全体を展開せず合計
# map/filterを使う場合(どちらも遅延)
from math import sqrt
roots = map(sqrt, filter(lambda x: x % 3 == 0, range(1, 50)))
# 最初の5個だけ確認
import itertools
print(list(itertools.islice(roots, 5)))
1420
[1.7320508075688772, 2.449489742783178, 3.0, 3.4641016151377544, 3.872983346207417]
実用例 大量データを省メモリで処理する
大きなファイルを行ごとに読み込む
巨大ファイルでも、1行ずつ遅延処理すればメモリ使用量は一定です。
ここではio.StringIO
でファイル相当のオブジェクトを作り、動作を確認します。
実ファイルならopen("path")
に置き換えてください。
import io
def read_lines(stream):
# 行末の改行を取り除きながら1行ずつ返す
for line in stream:
yield line.rstrip("\n")
# デモ用の「大きな」テキスト(実際は小さい)
data = "\n".join(f"line {i}" for i in range(1, 8))
fake_file = io.StringIO(data)
# 最初の3行だけ処理して途中で打ち切る例
for i, line in enumerate(read_lines(fake_file), start=1):
print(f"{i}: {line}")
if i == 3:
break
1: line 1
2: line 2
3: line 3
ジェネレータで処理をパイプライン化する
複数のジェネレータをつなげると、読み取り→パース→フィルタ→集計のような流れを省メモリで実現できます。
import io
def read_lines(stream):
# 入力を1行ずつ読み出す
for line in stream:
yield line.rstrip("\n")
def parse_csv(lines, sep=","):
# "a,b,c" -> ["a","b","c"] のように分割
for line in lines:
yield [part.strip() for part in line.split(sep)]
def filter_status(rows, status="ERROR"):
# 2列目(インデックス1)がERRORの行のみ通す
for row in rows:
if len(row) >= 2 and row[1] == status:
yield row
def extract_message(rows):
# 3列目(インデックス2)のメッセージだけ取り出す
for row in rows:
if len(row) >= 3:
yield row[2]
# デモ用CSV風データ: "timestamp,level,message"
raw = "\n".join([
"2024-01-01,INFO,Started",
"2024-01-01,ERROR,Dropped connection",
"2024-01-01,INFO,Heartbeat",
"2024-01-01,ERROR,Timeout",
])
fake_csv = io.StringIO(raw)
# パイプライン: read -> parse -> filter(ERROR) -> extract message
messages = extract_message(filter_status(parse_csv(read_lines(fake_csv))))
for m in messages:
print(m)
Dropped connection
Timeout
すべてが遅延でつながっているため、ファイル全体を展開せずに必要な行だけ処理されます。
無限シーケンスと遅延計算の注意点
ジェネレータは無限列の生成にも使えますが、そのままlist()
やsum()
に渡すと処理が終わりません。
itertools.islice()
などで上限を決めて扱います。
import itertools
def naturals(start=1):
# 無限に自然数を生成
n = start
while True:
yield n
n += 1
# 最初の10個だけ取り出す
first_ten = list(itertools.islice(naturals(), 10))
print(first_ten)
# フィボナッチの無限列を例示
def fib():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
print(list(itertools.islice(fib(), 8)))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[0, 1, 1, 2, 3, 5, 8, 13]
closeと例外処理で安全に終了する
長いパイプラインやリソースを持つ処理では、早期終了時に後始末が必要です。
ジェネレータはg.close()
で終了させるとGeneratorExit
が投げられ、finally
でクリーンアップが実行されます。
def resourceful():
print("open resource")
try:
for i in range(5):
yield i
finally:
# g.close()やガベージコレクション時に呼ばれる
print("cleanup resource")
g = resourceful()
print(next(g)) # 0を取得
print(next(g)) # 1を取得
g.close() # ここで後始末が走る
# close後にnextするとStopIteration
try:
print(next(g))
except StopIteration:
print("closed")
open resource
0
1
cleanup resource
closed
ファイルやソケットのような外部リソースは、原則としてwith open(...) as f:
のようにコンテキストマネージャで管理してください。
ジェネレータ内でもwith
を用いておけば、例外が発生しても確実にクローズされます。
# コンテキストマネージャと併用する安全なパターン
def read_numbers(path):
# ファイルを安全に開いて1行ずつ整数化して返す
with open(path, "rt", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
# 整数に変換できない場合はスキップ
try:
yield int(line)
except ValueError:
# ログ出力などに切り替え可
continue
# デモ環境ではファイルを用意できないため、この関数の使い方だけ示します。
print("read_numbersは遅延で整数を返し、ファイルは自動クローズされます。")
read_numbersは遅延で整数を返し、ファイルは自動クローズされます。
まとめ
ジェネレータは、yield
を用いて値を遅延生成するイテレータです。
メモリに全展開しないため、大量データやストリームの処理で特に有効です。
for
ループは内部でStopIteration
を扱うので、通常はfor
で自然に消費できます。
ジェネレータ式は簡潔に書け、フィルタやマップを重ねてパイプライン化しやすいのが強みです。
一方で、一度消費すると再利用できない、ランダムアクセスができないといった制約があるため、使い所を見極めることが重要です。
無限列はislice
などで上限を設け、リソースを扱う場合はwith
やclose()
、try/finally
で安全に終了させてください。
これらのポイントを押さえれば、ジェネレータはPythonの省メモリ・高効率なデータ処理を支える強力な道具になります。