こんにちは、かつコーチです。
大きなファイルを読み込む処理を書いていて、メモリを大量に消費してしまった経験はないでしょうか。
「yieldってreturnと何が違うの」
「ジェネレータを使うと何が嬉しいのか分からない」
そう感じている方も多いと思います。
この記事では、ジェネレータとは何か、yieldの使い方、そして通常のリストとの違いを、メモリ効率の観点から解説します。
読み終える頃には、大量データを扱う処理を、メモリに優しい形で書けるようになります。
ジェネレータとは何か
値を1つずつ生成するオブジエクト
ジェネレータとは、値を1つずつ順番に生成する、特殊なイテレータ(値を1つずつ取り出せるオブジェクト)です。
リストのように全ての値を一度にメモリへ展開するのではなく、必要になったタイミングで、その都度1つの値を計算して返します。
この性質のおかげで、大量のデータを扱うときでも、メモリ消費を抑えられます。
なぜメモリ効率が良いのか
リストは、要素をすべてメモリ上に保持します。
100万件のデータをリストで持つと、100万件分のメモリを一度に確保することになります。
一方でジェネレータは、「次の値を求めるための計算方法」だけを覚えており、実際に値が必要になった瞬間に、1つずつ計算します。
そのため、一度にメモリ上へ保持する値は常に1つだけで済みます。
import sys
numbers_list = [x for x in range(1_000_000)]
numbers_generator = (x for x in range(1_000_000))
print(f"リストのサイズ: {sys.getsizeof(numbers_list)}バイト")
print(f"ジェネレータのサイズ: {sys.getsizeof(numbers_generator)}バイト")
実行結果は次の通りです(サイズは環境によって多少変わります)。
リストのサイズ: 8448728バイト
ジェネレータのサイズ: 200バイト
同じ100万件のデータを扱っていても、ジェネレータのサイズはほぼ変わらないことが分かります。
yieldの使い方
yieldはreturnの代わりに値を返す
関数の中でreturnの代わりにyieldを使うと、その関数はジェネレータ関数になります。
def count_up_to(n):
count = 1
while count <= n:
yield count
count += 1
for num in count_up_to(5):
print(num)
実行結果は次の通りです。
1
2
3
4
5
yield countが実行されると、その時点の値(count)を呼び出し元に返しつつ、関数の実行状態はそのまま保持されます。
次に値が要求されると、yieldの次の行から処理が再開される点が、returnとの大きな違いです。
returnとの違い
returnは関数の実行を終了し、値を1つだけ返します。
一方yieldは、関数の実行を一時停止するだけで、終了はしません。
def with_return():
return [1, 2, 3]
def with_yield():
yield 1
yield 2
yield 3
print(with_return())
print(list(with_yield()))
実行結果は次の通りです。
[1, 2, 3]
[1, 2, 3]
見た目の結果は同じでも、with_returnは一度にリストを作ってメモリに載せているのに対し、with_yieldは呼び出されるたびに1つずつ値を生成しています。
ジェネレータ関数を呼び出すとジェネレータオブジェクトが返る
yieldを含む関数を呼び出しても、中身はすぐには実行されません。
def count_up_to(n):
print("開始")
count = 1
while count <= n:
yield count
count += 1
gen = count_up_to(3)
print("呼び出し直後")
print(next(gen))
print(next(gen))
実行結果は次の通りです。
呼び出し直後
開始
1
2
count_up_to(3)を呼び出した時点では、print("開始")はまだ実行されていません。
next(gen)で最初の値を要求されたタイミングで初めて、関数の中身が動き始めます。
リストとの違い・使い分け
用途に応じた使い分け
| 場面 | おすすめ |
|---|---|
| データを何度も繰り返し使う | リスト |
| データを1回だけ順番に処理する | ジェネレータ |
| 全件を一度にメモリへ載せると重い | ジェネレータ |
| インデックスで要素にアクセスしたい | リスト |
| ファイルやAPIから大量データを逐次処理する | ジェネレータ |
リストは、インデックス指定でのアクセスや、何度も同じデータを繰り返し使う場面に向いています。
ジェネレータは、一度きりの逐次処理で、メモリ効率を優先したい場面に向いています。
ジェネレータ式という書き方
内包表記の[]を()に変えるだけで、ジェネレータ式という簡潔な書き方もできます。
squares_list = [x ** 2 for x in range(5)]
squares_generator = (x ** 2 for x in range(5))
print(squares_list)
print(list(squares_generator))
実行結果は次の通りです。
[0, 1, 4, 9, 16]
[0, 1, 4, 9, 16]
defとyieldを使うほどではない、簡単な処理であれば、ジェネレータ式で手軽に書けます。
よくあるつまずきポイント・エラー対処
ジェネレータを2回目に使うと空になる
筆者が大量データの集計処理を書いていたとき、一度使ったジェネレータをもう一度使い回そうとして、結果が空になる現象にハマった経験があります。
❌ Before(2回使おうとして空になる書き方)
def count_up_to(n):
count = 1
while count <= n:
yield count
count += 1
numbers = count_up_to(3)
total = sum(numbers)
print(f"合計: {total}")
# もう一度使おうとする
max_value = max(numbers)
print(f"最大値: {max_value}")
実行すると、次のエラーメッセージが表示されます。
ValueError: max() arg is an empty sequence
sum(numbers)で一度ジェネレータの値をすべて消費してしまうと、そのジェネレータの中身は空になり、max(numbers)では何も残っていない状態になるのが原因です。
✅ After(必要な回数だけリスト化する書き方)
def count_up_to(n):
count = 1
while count <= n:
yield count
count += 1
numbers = list(count_up_to(3))
total = sum(numbers)
print(f"合計: {total}")
max_value = max(numbers)
print(f"最大値: {max_value}")
実行結果は次の通りです。
合計: 6
最大値: 3
同じデータを複数回使い回したい場合は、list()でリスト化してから使うか、使うたびに新しいジェネレータを生成し直す必要があります。
「ジェネレータは一度きりしか使えない」という性質を、意識しておくことが重要です。
まとめ
この記事のポイント
- ジェネレータは値を1つずつ生成するオブジェクトであり、大量データでもメモリ消費を抑えられる
- 関数内で
returnの代わりにyieldを使うと、その関数はジェネレータ関数になる yieldは関数を終了せず一時停止するだけで、次回呼び出し時に続きから再開する- インデックスアクセスや繰り返し利用にはリスト、逐次処理やメモリ効率重視にはジェネレータが向く
- ジェネレータは一度消費すると空になるため、複数回使い回す場合はリスト化しておく
次に読むべき記事
- リスト内包表記の書き方
- ラムダ式とmap・filter・reduceの使い方
- コンテキストマネージャ(with文)の仕組みと自作方法
タグ: Python, 中級者向け, 基本文法