【Python】ジェネレータとyieldの使い方

Python

こんにちは、かつコーチです。

大きなファイルを読み込む処理を書いていて、メモリを大量に消費してしまった経験はないでしょうか。

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]

defyieldを使うほどではない、簡単な処理であれば、ジェネレータ式で手軽に書けます。

よくあるつまずきポイント・エラー対処

ジェネレータを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, 中級者向け, 基本文法

タイトルとURLをコピーしました