Pythonのyieldは、大量のデータを扱う際にメモリ効率を高めてくれる便利な機能です。
しかし、その仕組みを正しく理解しないまま実装してしまうと、かえってメモリ効率を悪化させてしまうケースが少なくありません。
ジェネレータは本来、値を一つずつ遅延評価しながら生成することで、リスト全体をメモリ上に保持せずに済むという利点を持っています。
ところが、実装の途中でリストへの変換処理を挟んでしまったり、ジェネレータ内部で不要にデータを蓄積してしまったりすると、せっかくの遅延評価の恩恵が失われてしまいます。
このような実装は、コードの見た目上はyieldを使っているにもかかわらず、実質的には通常の関数と同じようにすべてのデータをメモリ上に展開してしまうため、ジェネレータを使う意味そのものがなくなってしまうという問題を抱えています。
特に、扱うデータ量が増えるほどこの問題は顕在化しやすく、本番環境でのメモリ枯渇やパフォーマンス低下といった深刻な事態を引き起こす原因にもなりかねません。
本記事では、yieldを使う際に陥りがちなアンチパターンを具体的なコード例とともに紹介します。
なぜそれが問題なのか、そしてどのように改善すればメモリ効率の良いジェネレータを実装できるのかを、論理的な観点から順を追って解説していきます。
ジェネレータの内部動作を正しく理解し、実務で誤った実装を避けるための一助となれば幸いです。
Pythonのyieldとジェネレータの基本的な仕組みとは

Pythonのyieldは、関数の中に記述するだけで、その関数を「ジェネレータ関数」に変換する特別なキーワードです。
通常の関数がreturnで一度だけ値を返して処理を終了するのに対し、yieldを含む関数は呼び出されても即座には実行されず、next()が呼ばれるたびに処理を一時停止・再開しながら値を一つずつ返します。
この挙動を正しく理解することが、メモリ効率の良いコードを書くための第一歩になります。
まずはyieldとイテレータの関係、そしてジェネレータとリストの構造的な違いについて整理していきます。
yield文がイテレータを生成する仕組み
yieldを含む関数を呼び出すと、関数の中身はすぐには実行されません。
代わりに、ジェネレータオブジェクトと呼ばれるイテレータが生成されます。
def count_up(n):
for i in range(n):
yield i
gen = count_up(3)
print(type(gen)) # <class 'generator'>
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 2
このコードでは、count_up(3)を呼び出した時点では関数内の処理は一切実行されません。
next()が呼び出されるたびにyieldの位置まで処理が進み、値を返した後は状態を保持したまま一時停止します。
つまりジェネレータは、Pythonのイテレータプロトコル(__iter__と__next__)を自動的に実装したオブジェクトであり、必要になった時点で初めて次の値を計算するという特徴を持っています。
この「必要になるまで計算しない」という性質こそが、遅延評価と呼ばれる考え方の中核です。
ジェネレータとリストの違い
ジェネレータとリストは、どちらも複数の値を順に扱えるという点で似ていますが、内部的な性質には明確な違いがあります。
| 観点 | リスト | ジェネレータ |
|---|---|---|
| データの保持方法 | 全要素をメモリ上に保持 | 現在の状態のみを保持 |
| 生成タイミング | 生成時にすべて計算 | 呼び出し時に一つずつ計算 |
| 再利用性 | 何度でも走査可能 | 一度しか走査できない |
| メモリ使用量 | 要素数に比例して増加 | 要素数に依存しにくい |
リストはすべての要素をメモリ上に確保するため、要素数が多くなるほどメモリ使用量が線形に増加します。
一方でジェネレータは、直前に返した値とその時点での処理位置のみを保持するため、要素数がどれだけ多くても、必要なメモリ量はほぼ一定に保たれます。
この違いを理解しないままコードを書いてしまうと、次章以降で紹介するような、意図せずリストと同じメモリ消費をしてしまうアンチパターンに陥りやすくなります。
ジェネレータの真価は、あくまで「一つずつ計算して一つずつ渡す」という設計思想を最後まで貫くことにある点を、まず押さえておく必要があります。
なぜyieldを使うとメモリ効率が向上するのか

yieldを使うことでメモリ効率が向上する理由は、処理の主導権が「呼び出す側」に移るという点にあります。
すべての値を先に用意しておくのではなく、必要になったタイミングで一つずつ値を計算して渡すため、大量のデータを扱う場面ほどその効果が顕著に表れます。
ここでは、その根底にある遅延評価という考え方と、実際にどの程度メモリ使用量に差が出るのかを具体的に見ていきます。
遅延評価(Lazy Evaluation)の考え方
遅延評価とは、値が実際に必要とされるまで計算を先延ばしにする評価戦略のことです。
Pythonのyieldは、この遅延評価をシンプルな文法で実現するための仕組みだと言えます。
対照的な例として、リスト内包表記とジェネレータ式を比較してみます。
# リスト内包表記:即座にすべての要素を計算しメモリに確保
squares_list = [x ** 2 for x in range(1000000)]
# ジェネレータ式:呼び出されるまで計算しない
squares_gen = (x ** 2 for x in range(1000000))
squares_listは定義した瞬間に100万個の要素をすべて計算し、リストとしてメモリ上に確保します。
一方squares_genは、この時点では計算を一切行っておらず、for文などで実際に値を取り出そうとした瞬間に初めて一つずつ計算が行われます。
この違いは、単なる実装スタイルの違いではなく、次のような実務上のメリットに直結します。
- 巨大なデータセットを扱う際にメモリ不足を回避できる
- 無限に続くシーケンスを表現できる
- 処理の途中で不要になった場合に、残りの計算を省略できる
特に無限シーケンスを扱えるという点は、リストでは決して実現できない、ジェネレータならではの利点です。
メモリ使用量の比較で見る効果
遅延評価の効果を定量的に把握するために、要素数ごとのメモリ使用傾向を整理すると次のようになります。
| 要素数 | リストのメモリ使用量の傾向 | ジェネレータのメモリ使用量の傾向 |
|---|---|---|
| 1,000 | 数十KB程度で増加 | ほぼ一定 |
| 1,000,000 | 数十MB規模まで増加 | ほぼ一定 |
| 100,000,000 | 数GB規模に達し得る | ほぼ一定 |
このように、リストは要素数に比例してメモリ使用量が増加するのに対し、ジェネレータは要素数がどれだけ増えても、内部で保持する状態量はほとんど変化しません。
この特性こそが、大規模データを扱うプログラムにおいてジェネレータが選ばれる本質的な理由です。
ただし、この恩恵は正しく実装されて初めて得られるものです。
次章では、せっかくyieldを使っているにもかかわらず、この遅延評価の利点を台無しにしてしまう典型的なアンチパターンについて見ていきます。
ジェネレータのメモリ効率を悪化させるアンチパターン一覧

yieldを使っているという事実だけでは、メモリ効率の良いコードにはなりません。
実装の途中でリストやタプルへの変換を挟んでしまうと、遅延評価の恩恵は失われ、結果的に通常の関数と変わらないメモリ消費をしてしまいます。
ここでは、実務でよく見かける三つの典型的なアンチパターンを取り上げます。
リストへ変換してからyieldするパターン
最も分かりやすいアンチパターンが、いったんリストとしてすべてのデータを構築してから、それをyieldで返すという実装です。
def bad_read_lines(filepath):
with open(filepath) as f:
lines = f.readlines() # ここで全行をメモリに読み込む
for line in lines:
yield line.strip()
このコードは、yieldを使っているためジェネレータとして機能しているように見えます。
しかし、f.readlines()の時点でファイルの全行がリストとしてメモリ上に確保されてしまうため、ファイルサイズが大きくなるほどメモリ使用量も比例して増加します。
これでは、ジェネレータを使う意味がほとんどありません。
正しくは、ファイルオブジェクトを直接1行ずつイテレートすることで、必要な行だけを都度メモリに載せる実装にするべきです。
ジェネレータ内でデータを蓄積してしまうパターン
一見すると値を一つずつ返しているように見えても、関数内部でリストやセットにデータを蓄積し続けてしまうケースも典型的なアンチパターンです。
def bad_unique_values(items):
seen = []
for item in items:
if item not in seen:
seen.append(item) # 際限なく増加し続ける
yield item
このコードでは、重複判定のためにseenというリストを内部で保持し続けています。
yieldによって値は一つずつ返されるものの、seen自体は処理が進むにつれて増加し続けるため、結局はメモリ使用量が入力データ量に比例して増大してしまいます。
このパターンは、フィルタリングや集計処理を伴うジェネレータで特に発生しやすく、見た目上は遅延評価をしているように見える分、気づきにくいという厄介な性質を持っています。
不要にlist()やタプル化を挟むパターン
ジェネレータをそのまま使えば十分な場面で、不必要にlist()やtuple()で変換してしまうパターンも見逃せません。
def bad_process(data_gen):
data_list = list(data_gen) # ここで全要素を一括展開
return [x * 2 for x in data_list]
このように、引数として受け取ったジェネレータをlist()で一度に展開してしまうと、呼び出し元がどれだけ遅延評価を意識して設計していても、その努力は無駄になります。
これら三つのパターンに共通しているのは、いずれも「どこかの時点で全データを一括してメモリに載せてしまっている」という点です。
次章以降では、こうした問題を引き起こす典型的な誤実装をさらに具体的に取り上げ、正しい書き換え方について解説していきます。
return文でリストを返してしまう誤った実装例

yieldを活用したジェネレータ関数を書いているつもりが、途中でreturnによってリストを返してしまい、結果的にジェネレータとしての性質を失ってしまうケースも少なくありません。
関数の一部だけをyieldで書き、残りを通常のreturnで処理してしまうと、呼び出し側が期待する遅延評価の挙動と実際の挙動が食い違い、思わぬメモリ消費やバグの原因になります。
リスト内包表記との混同によるミス
このミスは、リスト内包表記とジェネレータ式の書き方が非常に似ていることに起因する場合が多く見られます。
def bad_filter_even(numbers):
result = [n for n in numbers if n % 2 == 0] # 内包表記で全件生成
return result
このコードはyieldを一切使っていないため、関数を呼び出した瞬間に条件に合う要素をすべて計算し、リストとしてメモリ上に確保してしまいます。
関数名や使われ方によっては、あたかもジェネレータであるかのように呼び出し側から扱われてしまうこともあり、そうなると意図せず全件展開が発生します。
さらに厄介なのは、次のようにyieldとreturnが混在してしまうパターンです。
- ループの一部だけを
yieldで処理し、残りをリストとしてreturnしてしまう - 例外処理の分岐で、片方は
yield、もう片方はreturnでリストを返してしまう - 関数の途中でジェネレータ式を
list()化してからreturnしてしまう
こうした実装は、コードレビューでも見落とされやすく、テストで小規模なデータしか扱わない場合には問題が顕在化しないため、本番環境で大量データを処理して初めて発覚することが多いという特徴があります。
正しいyield実装への書き換え方
このアンチパターンを解消する方法は明快で、関数全体を通じて一貫してyieldだけを使い、returnでリストを返す処理を排除することです。
def good_filter_even(numbers):
for n in numbers:
if n % 2 == 0:
yield n
このように書き換えることで、good_filter_evenは呼び出された時点では一切処理を行わず、呼び出し側が値を要求するたびに条件判定と値の生成を一つずつ行うようになります。
リスト内包表記を使いたい場合も、角括弧[]ではなく丸括弧()で囲むジェネレータ式に置き換えるだけで、同様の効果が得られます。
書き換えの際に意識すべきポイントを整理すると、次のようになります。
- 関数内に
return リストという記述がないかを確認する - リスト内包表記を使っている箇所がないかを見直す
- 途中で
list()やtuple()による変換が挟まっていないかを確認する
これらを徹底するだけで、関数の見た目上のわずかな違いにもかかわらず、メモリ使用量の挙動は劇的に改善されます。
「yieldを使う」ことと「関数全体を遅延評価として設計する」ことは同義であるという意識を持つことが、このアンチパターンを避ける最も確実な方法です。
メモリ効率を維持するための正しいジェネレータ設計パターン

ここまで紹介してきたアンチパターンを避けるためには、場当たり的な修正ではなく、設計段階から遅延評価を前提としたパターンを選択することが重要です。
Python標準の機能をうまく活用すれば、可読性を落とさずにメモリ効率の良いコードを実現できます。
ジェネレータ式を活用した実装
複数の処理を連続して行いたい場合、それぞれの段階でリストを生成せず、ジェネレータ式を連結していく設計が効果的です。
def load_records(filepath):
with open(filepath) as f:
for line in f:
yield line.strip()
def parse_records(lines):
for line in lines:
yield line.split(",")
def filter_valid(records):
for record in records:
if len(record) == 3:
yield record
この三つの関数は、load_recordsからfilter_validまで一つの値が順番に受け渡されていく構造になっています。
それぞれの段階でリストを生成することなく、1件ずつパイプラインのように処理が流れていくため、ファイルがどれほど大きくても、メモリ上に保持されるのは常に「現在処理中の1件」程度に抑えられます。
このような複数のジェネレータを連結する設計は、次のような利点をもたらします。
- 各処理の責務が明確に分離され、テストや保守がしやすくなる
- 処理の途中で条件を満たさなくなった場合、以降の計算を省略できる
- パイプライン全体を通してメモリ使用量が一定に保たれる
itertoolsを使った効率的な処理
Python標準ライブラリのitertoolsモジュールは、ジェネレータを扱う上で非常に強力な選択肢です。
自前でループを書かずとも、メモリ効率を保ったまま複雑な処理を組み立てることができます。
代表的な関数を整理すると、次のようになります。
| 関数名 | 主な用途 | メモリ効率の特徴 |
|---|---|---|
islice |
先頭や範囲を限定して取得 | 全件展開せず必要分のみ計算 |
chain |
複数のイテラブルを連結 | 連結後もリスト化しない |
takewhile |
条件を満たす間だけ取得 | 条件不成立以降は計算しない |
groupby |
連続する値をグループ化 | 直前のグループのみ保持 |
たとえばitertools.isliceを使えば、無限に値を生成し続けるジェネレータから必要な件数だけを安全に取り出すことができ、itertools.chainを使えば、複数のジェネレータを一度リストに変換して結合するといった無駄な処理を避けられます。
これらの関数はいずれもC言語レベルで最適化されているため、自前でループを書くよりも高速に動作する場合が多く、可読性とパフォーマンスの両方を同時に満たせる点も大きな利点です。
独自にループを書く前に、まずitertoolsに該当する処理がないかを確認する習慣をつけておくと、無駄なメモリ消費を避けやすくなります。
パフォーマンスを検証するためのメモリ計測方法

ジェネレータがメモリ効率に優れているという事実は理論として理解していても、実際のコードで本当にその効果が得られているかどうかは、計測によって初めて確認できます。
感覚だけに頼らず、客観的な数値でメモリ使用量を検証する習慣を持つことが、アンチパターンの早期発見につながります。
memory_profilerを使った計測手順
memory_profilerは、関数単位でメモリ使用量の推移を可視化できる代表的なライブラリです。
導入から計測までの手順は、次のように整理できます。
pip install memory_profilerでライブラリをインストールする- 計測したい関数に
@profileデコレータを付与する python -m memory_profiler スクリプト名.pyのようにモジュールとして実行する
from memory_profiler import profile
@profile
def process_with_list(n):
data = [x ** 2 for x in range(n)]
return sum(data)
@profile
def process_with_generator(n):
data = (x ** 2 for x in range(n))
return sum(data)
このコードを実行すると、行ごとのメモリ使用量が出力され、process_with_listではリスト生成時に大きくメモリが増加するのに対し、process_with_generatorではほとんど増加が見られないことを、数値として確認できます。
memory_profilerの利点は、単に総メモリ量が分かるだけでなく、コードのどの行でメモリが増加しているかまで特定できる点にあります。
これにより、前章までに紹介したアンチパターンが実際のコードに潜んでいないかを、行単位で検証することが可能になります。
sys.getsizeofで確認するオブジェクトサイズ
より手軽に確認したい場合は、Python標準ライブラリのsys.getsizeofを使う方法もあります。
これは特定のオブジェクトが直接占有しているメモリサイズをバイト単位で返す関数です。
import sys
list_obj = [x for x in range(10000)]
gen_obj = (x for x in range(10000))
print(sys.getsizeof(list_obj)) # 要素数に応じて大きな値
print(sys.getsizeof(gen_obj)) # 要素数によらずほぼ一定の小さな値
このコードを実行すると、list_objのサイズは要素数に応じて大きくなる一方、gen_objのサイズはほぼ一定の小さな値にとどまることが確認できます。
ただしsys.getsizeofには注意点もあります。
- ネストしたオブジェクトの内部要素までは合算されない
- ジェネレータが保持する内部状態の詳細までは分からない
- あくまでオブジェクト単体の表層的なサイズしか測れない
そのため、簡易的な確認にはsys.getsizeofを、処理全体の詳細な傾向を把握したい場合にはmemory_profilerを使うというように、目的に応じて両者を使い分けることをおすすめします。
計測を習慣化しておくことで、コードレビューの段階でアンチパターンに気づける可能性も高まります。
大規模データ処理におけるジェネレータ活用の実践例

ここまで解説してきた原則を踏まえた上で、実際の業務でよく直面する二つの場面を例に、ジェネレータの具体的な活用方法を見ていきます。
CSVファイルの読み込みとデータベースクエリの結果取得は、いずれもデータ量が予測しづらく、ジェネレータの効果が最も発揮されやすい典型的なユースケースです。
CSVファイルの逐次読み込み処理
CSVファイルを扱う際、初心者が陥りやすいのが、標準ライブラリのcsvモジュールを使いながらも、結果をリストにまとめてしまう実装です。
正しくは、行を読み込みながら逐次処理を行う設計にする必要があります。
import csv
def read_large_csv(filepath):
with open(filepath, newline="") as f:
reader = csv.reader(f)
header = next(reader)
for row in reader:
yield dict(zip(header, row))
このコードでは、ファイルを開いた後にヘッダー行だけを先に取得し、以降の各行はfor row in readerによって1行ずつ読み込まれます。
yieldによって辞書形式に変換した行を1件ずつ返しているため、ファイルの行数がどれだけ多くても、メモリ上に保持されるのは常に処理中の1行分にとどまります。
呼び出し側では、次のように通常のfor文で扱うだけで、意識せずともメモリ効率の良い処理を享受できます。
for record in read_large_csv("large_data.csv"):
if record.get("status") == "active":
pass # 必要な処理をここに記述
ファイルサイズが数GB規模になるケースでは、この設計の違いがそのままプログラムの実行可否を左右することも珍しくありません。
データベースクエリ結果のストリーミング処理
データベースから大量のレコードを取得する処理も、ジェネレータの効果が顕著に表れる場面です。
多くのORMやドライバでは、クエリ結果を全件取得してからリストとして返す実装がデフォルトになっていることがあるため、意識的にストリーミング処理へ切り替える必要があります。
def fetch_records_streaming(cursor, query, batch_size=1000):
cursor.execute(query)
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
yield row
このコードでは、fetchall()によって全件を一括取得するのではなく、fetchmany()を使って一定件数ずつ取得しながら、その中身をさらに1件ずつyieldで返しています。
取得件数をbatch_sizeで調整することで、メモリ使用量とデータベースへの問い合わせ回数のバランスを取ることができます。
大規模データ処理においては、次のような観点を意識しておくと安全です。
- クエリ結果を一括取得するAPIとストリーミング取得するAPIの違いを把握しておく
- バッチサイズを適切に設定し、問い合わせ回数とメモリ使用量のトレードオフを調整する
- 呼び出し側でも
list()化せず、ジェネレータのまま扱う設計を徹底する
これらを踏まえた実装にすることで、扱うデータ量に対してプログラムの安定性が左右されにくい、堅牢なシステムを構築できます。
まとめ:yieldを正しく使ってメモリ効率の良いコードを書こう

ここまで、Pythonのyieldとジェネレータを取り巻くさまざまなアンチパターンと、その改善方法について解説してきました。
最後に、本記事全体の要点を整理しておきます。
yieldは、関数を呼び出した時点では処理を実行せず、値が要求されるたびに一つずつ計算して返すという、遅延評価の考え方に基づいた仕組みです。
この仕組みを正しく活かせば、要素数がどれだけ増えても、メモリ使用量をほぼ一定に保ったままデータを処理できます。
しかし、その恩恵は「関数のどこかにyieldを書いた」だけでは得られず、関数全体を通じて一貫して遅延評価の設計思想を貫くことによって初めて実現されるものです。
本記事で紹介したアンチパターンを振り返ると、いずれも共通した特徴を持っています。
| アンチパターン | 問題の本質 | 対策の方向性 |
|---|---|---|
| リストへ変換してからyieldする | 処理の途中で全件展開してしまう | ファイルやデータを直接イテレートする |
| ジェネレータ内でデータを蓄積する | 内部状態が入力量に比例して増加する | 蓄積せずに済む設計へ見直す |
| 不要にlist()やタプル化を挟む | ジェネレータの遅延性を自ら破棄してしまう | 呼び出し側までジェネレータのまま扱う |
| return文でリストを返してしまう | yieldとreturnの混在で挙動が不明瞭になる | 関数全体をyieldのみで統一する |
これらの表からも分かる通り、アンチパターンの根本原因は一貫して「どこかの時点で全データを一括してメモリに載せてしまっている」という点にあります。
裏を返せば、このポイントさえ意識できていれば、複雑な理論を持ち出さなくても、大半のアンチパターンは避けられるということです。
実務でジェネレータを設計する際には、次のような視点を持つことをおすすめします。
- 関数内に
list()やreturn リストといった、全件を一括で扱う記述がないかを確認する - 複数の処理を連結する場合は、各段階をジェネレータのまま受け渡す設計にする
itertoolsなど標準ライブラリに、車輪の再発明を避けられる関数がないかを確認する- 実装後は
memory_profilerやsys.getsizeofを用いて、実際にメモリ効率が改善しているかを計測する
特に4番目の計測という工程は軽視されがちですが、理論上は正しく見える実装であっても、実際に数値で確認しない限り、本当にメモリ効率が向上しているかは断言できません。
感覚や経験則だけに頼らず、客観的な指標をもとに検証する姿勢は、コンピューターサイエンスを学んだ者としても大切にしたい習慣です。
ジェネレータは、正しく設計すれば非常に強力な武器になりますが、誤った実装をしてしまうと、通常の関数と変わらない、あるいはそれ以上に分かりにくいコードになってしまう危険性もはらんでいます。
本記事で紹介した典型的なアンチパターンとその改善策を手がかりに、ぜひ一度、ご自身が普段書いているジェネレータ関連のコードを見直してみてください。
小さな書き換えの積み重ねが、大規模データを扱うシステム全体の安定性を大きく左右することになるはずです。


コメント