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() の使い分け

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 が自動的に発生する
__RESULT__

throw() で送った例外がジェネレータ内で捕捉されない場合、その例外はそのまま throw() の呼び出し元に伝播する。ジェネレータはその時点で終了状態になる。

close() を呼んだとき、except GeneratorExit の中で yield を使うとどうなるか?

  • 正常に次の値が返される
  • GeneratorExit が再送される
  • RuntimeError が発生する
  • StopIteration が発生する
__RESULT__

close() の目的はジェネレータの終了であり、GeneratorExit を捕捉した後に yield するのは矛盾する操作として Python が RuntimeError を送出する。