【Python】ジェネレータとyieldの使い方|遅延評価・メモリ効率・一度きりの罠

【Python】ジェネレータとyieldの使い方|遅延評価・メモリ効率・一度きりの罠 Python

ジェネレータ(generator)は、値を1つずつ、必要になったときに生成する仕組みです。関数の中でreturnの代わりにyieldを使うと、その関数はジェネレータになります。リストのように全部の値を一度にメモリへ用意するのではなく、使うぶんだけ順に作るため、巨大なデータや無限に続く数列でもメモリを圧迫しません。

つまずきやすいのは、ジェネレータは一度回すと使い切ってしまい、2回目は空になること、そして呼び出しただけでは中の処理が実行されない(遅延評価)ことです。この2つの性質を知らないと、「データが消えた」「処理が動かない」と戸惑います。この記事では、実機のPythonで確認しながら、ジェネレータとyieldを整理します。

先に結論

  • 関数の中でyieldを使うとジェネレータになります。値を1つずつ返します。
  • 値はnext()で1つずつ、またはforでまとめて取り出します。
  • 呼び出しただけでは本体は動きません(遅延評価)。取り出すときに初めて実行されます。
  • ジェネレータ式(x for x in ...)はリスト内包[...]よりメモリを使いません
  • 一度回すと使い切ります。2回目のループは空になります。
  • 巨大データ・無限数列・1行ずつの処理など、メモリを節約したい場面で活躍します。

一気に作るリスト内包表記との違い、forでの反復に関わるenumerate/zip、土台となる関数(def)もあわせて参考になります。

スポンサーリンク

ジェネレータとは(yieldで作る)

関数の中でreturnではなくyieldを使うと、その関数はジェネレータ関数になります。呼び出すと、値を順に生み出す「ジェネレータオブジェクト」が返ります。

yield でジェネレータを作る
def count_up(n):
    for i in range(1, n + 1):
        yield i          # return ではなく yield

g = count_up(3)
print(type(g))           # <class 'generator'>

# yield は「ここで値を1つ返して、いったん止まる」
# 次に呼ばれると、続きから再開する

実機でも、count_up(3)の戻り値の型はgeneratorでした。yield iは「ここで値iを1つ返して、いったん処理を止める」という意味です。普通の関数のreturnは1回で終わりますが、yieldは値を返したあとも状態を保ったまま止まり、次に呼ばれると続きから再開します。これが「1つずつ生成する」動きの正体です。

next と for で取り出す

ジェネレータから値を取り出すには、next()で1つずつ取るか、forでまとめて取り出します。最後まで取り切ったあとにnext()を呼ぶとStopIterationになります。

next と for で値を得る
def count_up(n):
    for i in range(1, n + 1):
        yield i

# next() で1つずつ
g = count_up(3)
print(next(g))   # 1
print(next(g))   # 2
print(next(g))   # 3
# print(next(g)) # StopIteration(もう値が無い)

# for でまとめて(StopIteration は自動で処理される)
for x in count_up(3):
    print(x)     # 1, 2, 3

実機でも、next(g)を呼ぶたびに123と順に返り、4回目でStopIterationになりました。普段はforでループするのが基本で、forStopIterationを自動的に処理して終了してくれます。next()を直接使うのは、1つずつ手動で進めたい特別な場面です。

遅延評価:呼んだだけでは動かない

ジェネレータの大きな特徴が遅延評価(lazy evaluation)です。ジェネレータ関数を呼び出しても、その時点では中の処理は実行されません。値を取り出そうとした瞬間に、初めて必要なぶんだけ実行されます。

呼んだだけでは本体が動かない
def gen():
    print("本体が実行された")
    yield 1

g = gen()
print("呼び出し済み(まだ何も表示されない)")

# next() で初めて本体が動く
next(g)
# → ここで「本体が実行された」が表示される
取り出すまで何も起きない

実機で確認したところ、gen()を呼び出した時点では中のprint("本体が実行された")実行されずnext(g)で値を取り出そうとした瞬間に初めて実行されました。これが遅延評価です。ジェネレータは「値の作り方(レシピ)」を用意するだけで、実際に作るのは取り出すときなのです。この性質のおかげで、無限に続く数列や、まだ存在しない(これから計算する)値も扱えます。逆に、「ジェネレータを作ったのに処理が動かない」と感じたら、それは取り出していないだけ、ということがよくあります。list()で囲むかforで回すと、全部の値が取り出されて処理が実行されます。

メモリ効率:ジェネレータ式 vs リスト内包

ジェネレータは()で囲むジェネレータ式でも作れます。リスト内包表記[]()に変えるだけです。この2つはメモリの使い方が大きく違います

ジェネレータ式とメモリ
import sys

# リスト内包: 全要素をメモリに作る
lst = [x * x for x in range(10000)]

# ジェネレータ式: () で囲む。値は取り出すときに作る
gen = (x * x for x in range(10000))

print(sys.getsizeof(lst))   # 約 85000 バイト(全部を保持)
print(sys.getsizeof(gen))   # 約 200 バイト(中身は持たない)
大きなデータはジェネレータ式でメモリ節約

実機で確認したところ、1万個の二乗をリスト内包[...]で作ると約85,000バイトジェネレータ式(...)では約200バイトと、メモリ使用量に圧倒的な差が出ました。リスト内包は全要素をメモリに展開しますが、ジェネレータ式は「作り方」だけを持ち、値は取り出すたびに1つずつ計算するためです。合計を求める・1件ずつ処理するなど、全要素を同時に保持する必要がない場面では、(...)のジェネレータ式を使うとメモリを大幅に節約できます。たとえばsum(x * x for x in range(10000))のように、関数に直接渡すときはかっこも省略でき、巨大なデータでもメモリを圧迫しません。逆に、要素に何度もアクセスする・インデックスで取り出す場合はリストが向いています。

一度きりしか回せない

注意したいのが、ジェネレータは一度取り出すと使い切ってしまうことです。同じジェネレータを2回ループしようとすると、2回目は何も残っていません。

ジェネレータは使い切る
def count_up(n):
    for i in range(1, n + 1):
        yield i

g = count_up(3)

print(list(g))   # [1, 2, 3](ここで全部取り出した)
print(list(g))   # [](もう空。2回目は何も無い)

# もう一度使いたいなら、作り直す
g = count_up(3)
print(list(g))   # [1, 2, 3]
2回目のループは空になる

実機で確認したところ、list(g)で1回目は[1, 2, 3]が得られましたが、同じgに対する2回目のlist(g)[](空)になりました。ジェネレータは取り出した値を覚えておかず、進んだら戻れないためです。「ループしたあと、もう一度ループしたら何も出てこない」というのは、この使い切りが原因です。同じ値を複数回使いたいなら、ジェネレータを作り直すか、最初にdata = list(ジェネレータ)でリストに変換して保持しておきます。enumerate や zipmapなども同じく一度きりの性質を持つので、あわせて覚えておくとよいです。

yield from・無限ジェネレータ

yield fromを使うと、別のリストやジェネレータの値をまとめてyieldできます。また、while Trueと組み合わせると、無限に続くジェネレータも作れます(遅延評価なので、必要なぶんだけ取り出せます)。

yield from と無限ジェネレータ
# yield from: 別のリスト/ジェネレータの値を順に流す
def chain():
    yield from [1, 2]
    yield from [3, 4]

print(list(chain()))   # [1, 2, 3, 4]

# 無限ジェネレータ(必要な分だけ取り出す)
def infinite():
    i = 0
    while True:
        yield i
        i += 1

inf = infinite()
print([next(inf) for _ in range(5)])   # [0, 1, 2, 3, 4]

実機でも、yield fromで2つのリストをつなげて[1, 2, 3, 4]、無限ジェネレータから5個だけ取り出して[0, 1, 2, 3, 4]が得られました。無限ループでも、遅延評価のおかげで全部を作ろうとはせず、取り出したぶんだけ生成されます。だからこそ「終わりのない数列」も安全に扱えます。ただし無限ジェネレータをlist()forで無条件に回すと止まらなくなるため、next()で必要な回数だけ取るか、条件でbreakするようにします。

主なポイントまとめ

ジェネレータの要点をまとめます。

項目 ポイント
作り方 関数内でyield、または(式 for ...)
取り出し next()で1つ、forでまとめて
遅延評価 取り出すまで本体は実行されない
メモリ 全要素を保持しない(巨大データに強い)
使い切り 一度回すと空になる(再利用は作り直す)
連結 / 無限 yield from / while True

よくある失敗

同じジェネレータを2回ループする

2回目は空です。再利用するなら作り直すか、list()でリストにして保持します。

呼んだだけで処理が動くと思う

遅延評価です。forlist()で取り出して初めて実行されます。

巨大データをリスト内包で作る

メモリを大量に使います。全保持が不要ならジェネレータ式(...)を使います。

無限ジェネレータをlistで回す

止まらなくなります。next()で回数を決めるかbreakします。

nextを最後まで使い切ってStopIteration

値が無くなるとStopIterationです。通常はforで回せば自動処理されます。

よくある質問

Qジェネレータとは何ですか?
A値を1つずつ、必要になったときに生成する仕組みです。関数の中でreturnの代わりにyieldを使うとジェネレータになります。リストのように全要素を一度にメモリへ用意しないため、巨大なデータや無限の数列をメモリを節約しながら扱えます。
Qyieldとreturnの違いは?
Areturnは値を返して関数を終了しますが、yieldは値を1つ返したあと状態を保ったまま止まり、次に呼ばれると続きから再開します。これにより、値を1つずつ順に生成できます。yieldを含む関数は自動的にジェネレータになります。
Qジェネレータを2回ループしたら空になりました。
Aジェネレータは一度取り出すと使い切る性質があるためです。同じ値を複数回使いたいときは、data = list(ジェネレータ)でリストに変換して保持するか、ジェネレータを作り直してください。enumeratezipなども同じ一度きりの性質を持ちます。
Qリスト内包とジェネレータ式はどちらを使うべき?
A全要素を同時に保持する必要がなく、合計や1件ずつの処理をするだけなら、メモリを節約できるジェネレータ式(...)が向いています。要素に何度もアクセスしたり、インデックスで取り出したりするならリスト内包[...]を使います。巨大なデータではこの差が大きく効いてきます。
Qジェネレータを作ったのに処理が動きません。
Aジェネレータは遅延評価のため、作っただけでは中の処理が実行されません。forでループするか、list()で囲んで値を取り出すと、初めて処理が実行されます。「動かない」のではなく「まだ取り出していない」だけ、というケースがほとんどです。

まとめ

  • 関数内でyieldを使うとジェネレータ。値を1つずつ生成します。
  • 取り出しはnext()for取り出すまで本体は動きません(遅延評価)。
  • ジェネレータ式(...)はメモリを節約。巨大データに強いです。
  • 一度回すと使い切り。再利用は作り直すかlist()で保持します。
  • yield fromで連結、while Trueで無限ジェネレータが作れます。

ジェネレータは、メモリを節約しながら大量データや無限の数列を扱える、Pythonの強力な機能です。「取り出すまで動かない(遅延評価)」「一度きりで使い切る」という2つの性質さえ押さえれば、ログの1行ずつ処理や巨大ファイルの読み込みなど、実用的な場面で安全に活用できます。まずはリスト内包の[]()に変えるところから試してみてください。