Python の throw() と close() でジェネレータを制御する
ジェネレータは yield で値を返すだけの存在ではない。外部からジェネレータに例外を投げ込んだり、明示的に終了させたりする仕組みが用意されている。throw() と close() がそれにあたる。
throw() の基本
throw() は、ジェネレータが yield で一時停止している箇所に、任意の例外を送り込むメソッドだ。ジェネレータの内部で try/except を使えば、その例外をキャッチして処理を続行できる。
def accumulator():
total = 0
while True:
try:
value = yield total
if value is not None:
total += value
except ValueError:
print("不正な値が送られたためリセットします")
total = 0
gen = accumulator()
next(gen) # ジェネレータを起動
gen.send(10) # total = 10
gen.send(20) # total = 30
gen.throw(ValueError) # リセットされ total = 0
print(gen.send(5)) # 5
throw() の呼び出しによって、yield の位置で ValueError が発生する。ジェネレータ内部の except 節がこれを捕捉し、total を 0 にリセットしたうえで次の yield に進む。throw() の戻り値は、次の yield が返す値になる。
throw() の挙動を正確に理解する
throw() が呼ばれたとき、ジェネレータの内部では以下のことが起きている。
throw(例外) を呼び出す
yield の位置で指定した例外が発生する
ジェネレータ内の except で捕捉できれば次の yield まで進む
捕捉されなければ呼び出し元に例外が伝播する
捕捉されなかった場合の挙動も確認しておこう。
def simple_gen():
yield 1
yield 2
yield 3
gen = simple_gen()
next(gen) # 1
try:
gen.throw(RuntimeError, "強制エラー")
except RuntimeError as e:
print(f"捕捉: {e}") # 捕捉: 強制エラー
simple_gen の内部には try/except がないため、throw() で投げた RuntimeError はそのまま呼び出し元に伝播する。ジェネレータはこの時点で終了し、以降 next() を呼ぶと StopIteration が発生する。
throw() の引数形式
throw() には 2 種類の呼び出し方がある。
gen.throw(ValueError(“メッセージ”)) のように、インスタンスを直接渡す形式。Python 3 ではこちらが推奨される。
gen.throw(ValueError, “メッセージ”) のように、クラスとメッセージを別々に渡す古い形式。Python 2 との互換性のために残っているが、新しいコードでは使わないほうがよい。
close() の基本
close() はジェネレータを明示的に終了させるメソッドだ。内部的には yield の位置に GeneratorExit 例外を送り込む。
def resource_gen():
print("開始")
try:
while True:
yield "データ"
except GeneratorExit:
print("クリーンアップ処理を実行")
print("終了")
gen = resource_gen()
next(gen) # "開始" と表示
gen.close() # "クリーンアップ処理を実行" と "終了" が表示
close() を呼ぶと GeneratorExit がジェネレータ内で発生し、except GeneratorExit でリソースの後処理を行える。ジェネレータが無限ループであっても、close() で安全に停止できるわけだ。
GeneratorExit の特殊なルール
GeneratorExit には重要な制約がある。except GeneratorExit の中で yield を使ってはならない。
def bad_gen():
try:
yield 1
except GeneratorExit:
yield 2 # これは RuntimeError を引き起こす
gen = bad_gen()
next(gen)
try:
gen.close()
except RuntimeError as e:
print(f"エラー: {e}")
close() は「このジェネレータを終わらせる」という意図の操作であり、GeneratorExit を捕捉した後にさらに値を yield するのは矛盾している。Python はこの矛盾を RuntimeError として通知する。
GeneratorExit を捕捉する場合、後処理として許されるのはログ出力やファイルのクローズなどの副作用のない終了処理に限られる。
yield や send() で再び値をやり取りしようとする操作は禁止されている。
try/finally との組み合わせ
GeneratorExit をわざわざ except で捕捉しなくても、finally 節でクリーンアップするのがより一般的なパターンだ。
def file_reader(path):
f = open(path)
try:
for line in f:
yield line.strip()
finally:
f.close()
print(f"{path} をクローズしました")
gen = file_reader("example.txt")
# 数行だけ読んで途中で終了
for i, line in enumerate(gen):
if i >= 3:
gen.close()
break
finally は GeneratorExit の有無にかかわらず実行されるため、リソース解放の記述場所として適切だ。ジェネレータがガベージコレクションで回収される際にも finally 節は実行される。
throw() と close() の使い分け
ジェネレータに異常状態を伝え、回復可能な処理を促す。入力バリデーションの失敗や再試行可能なエラーの通知に使う。ジェネレータは例外を捕捉すれば処理を続行できる。
ジェネレータを終了させる。不要になったジェネレータのリソースを解放する用途で使う。GeneratorExit を受け取ったジェネレータは新しい値を yield できない。
実用例: タイムアウト付きデータ処理
throw() と close() を組み合わせた実践的な例を示す。一定時間で処理を打ち切り、クリーンアップを行うパターンだ。
import time
class TimeoutError(Exception):
pass
def data_processor():
processed = 0
try:
while True:
try:
data = yield processed
# データ処理のシミュレーション
time.sleep(0.1)
processed += 1
except TimeoutError:
print(f"タイムアウト: {processed} 件処理済み")
yield processed
return
finally:
print(f"終了: 合計 {processed} 件を処理")
gen = data_processor()
next(gen)
start = time.time()
for i in range(100):
if time.time() - start > 0.5:
result = gen.throw(TimeoutError)
print(f"結果: {result} 件")
break
gen.send(f"item_{i}")
else:
gen.close()
TimeoutError を throw() で送り込むと、ジェネレータは処理済み件数を返してから自ら return で終了する。時間内にすべて完了した場合は close() で正常終了させる。どちらのケースでも finally 節のクリーンアップは確実に実行される。
throw() でジェネレータに例外を送り、ジェネレータ内部で捕捉されなかった場合、何が起こるか?
- ジェネレータが無視して次の yield に進む
- StopIteration が発生する
- 例外が呼び出し元に伝播し、ジェネレータは終了する
- GeneratorExit が自動的に発生する
close() を呼んだとき、except GeneratorExit の中で yield を使うとどうなるか?
- 正常に次の値が返される
- GeneratorExit が再送される
- RuntimeError が発生する
- StopIteration が発生する
close() の目的はジェネレータの終了であり、GeneratorExit を捕捉した後に yield するのは矛盾する操作として Python が RuntimeError を送出する。
throw() で送った例外がジェネレータ内で捕捉されない場合、その例外はそのまま throw() の呼び出し元に伝播する。ジェネレータはその時点で終了状態になる。