質問の歴史
Python 2.5がPEP 343を介してwithステートメントを導入する前は、リソース管理はコードベース全体に散在した明示的なtry/finallyブロックを必要としていました。これは機能的でしたが、このパターンは冗長で、シンプルなリソースの取得と解放のシナリオにおいてエラーが発生しやすいものでした。contextlibモジュールは、このボイラープレートを削減するために導入され、開発者がジェネレータ関数としてコンテキストマネージャを記述できるようにし、@contextmanagerデコレーターを使用して、直線的に見えるジェネレータをコンテキスト管理プロトコルを満たすオブジェクトに変換します。
問題
ジェネレータ関数はネイティブにイテレータプロトコル(__iter__、__next__)を実装していますが、コンテキストマネージャプロトコル(__enter__、__exit__)は実装していません。この異なるプロトコルを橋渡しすることが根本的な課題です:withブロックに入るとき、yieldの前のセットアップコードが実行される必要があります。終了する際には、例外に関係なくyieldの後のクリーンアップコードが実行されなければなりません。さらに、withブロック内で発生した例外は、ジェネレータの正確なyieldのサスペンションポイントに戻されなければならず、ジェネレータ自身の例外処理ロジックがクリーンアップ操作を実行できるようにします。
解決策
このデコレーターは、ジェネレータ関数をGeneratorContextManagerクラス(現代のCPythonでCで実装されています)でラップします。各呼び出しは新しいジェネレータイテレータを作成します。__enter__メソッドはこのイテレータでnext()を呼び出し、yield文まで関数を実行し、as変数に束縛される値を返します。__exit__メソッドは例外の詳細を受け取り、例外が発生しなかった場合は再びnext()を呼び出してジェネレータを再開し、枯渇させます。例外が発生した場合は、ジェネレータのthrow()メソッドを呼び出し、サスペンションポイントで例外を注入します。これにより、ジェネレータのexceptまたはfinallyブロックがクリーンアップを処理できます。throw()が通常通り戻る場合(例外が捕まえられた場合)、__exit__はTrueを返して例外を抑制します。それ以外の場合は、例外が伝搬されます。
from contextlib import contextmanager @contextmanager def managed_connection(): conn = create_connection() try: print("接続が確立されました") yield conn except NetworkError: conn.rollback() raise finally: conn.close() print("接続が閉じられました") with managed_connection() as c: c.query("SELECT * FROM data")
問題の説明: 高スループットのデータ処理サービスは、メモリ内バッファが制限を超えた場合に一時的なスピルファイルを処理する必要がありました。レガシー実装では、12の異なる処理モジュールでファイルの作成と削除ロジックが重複し、エッジケースのエラー条件でファイルディスクリプタリークが発生し、メンテナンスが複雑になりました。
検討された解決策:
手動のtry/finallyブロックが最初のアプローチでした。すべての使用サイトはファイル操作を明示的なtry/finallyでラップし、os.unlink()が呼び出されることを保証しました。これにより、明示的な制御フローが提供され、抽象化オーバーヘッドはゼロでしたが、各使用サイトで8行を要する冗長なものでした。開発者はしばしばクリーンアップロジックを誤ったfinallyブロックに配置し、ログ要件が追加された場合にはすべてのモジュールで一貫して挙動を変更することが困難でした。
クラスベースのコンテキストマネージャは再利用可能な代替策として考慮されました。TempSpillFileクラスは、ファイルを作成するために__enter__を実装し、削除するために__exit__を実装します。再利用可能で標準プロトコルに従っているものの、クラス定義はセットアップとクリーンアップを視覚的に多くの行で分離し、可読性を損ないました。また、概念的にはシンプルなリソースライフサイクルであるために15行のボイラープレートを必要とし、実際のロジックを隠しました。
@contextmanagerアプローチによるジェネレータが最終的な選択肢となりました。temp_spill_file()というジェネレータ関数はファイルを作成し、それをyieldし、削除のためにtry/finallyを使用しました。これにより、コードの重複が最小限に抑えられ、セットアップとクリーンアップがソースコード内で隣接しました。慣れ親しんだ例外処理構文を活用し、単一使用制限を課し、yieldのサスペンションポイントは、同期待機を期待している開発者に混乱を引き起こす可能性があります。
選ばれた解決策と結果: @contextmanagerアプローチが選択され、コードの重複が最小限に抑えられ、コードレビュー中の明瞭さが最大化されました。取得と解放のロジックの隣接性は、リソースライフサイクルが即座に明らかになります。このリファクタリングにより、リソース管理コードが96行から12行に削減されました。静的分析により、その後の四半期の運用中にファイルディスクリプタリークがゼロであることが確認されました。
ジェネレータのセットアップフェーズ(yieldの前)とクリーンアップフェーズ(yieldの後)で発生する例外をGeneratorContextManagerはどのように処理しますか?
ジェネレータ内のyieldの前に例外が発生した場合、ジェネレータは決してサスペンドされず、__enter__はこの例外を即座に伝播させ、__exit__は決して呼び出されません。withブロック内(yieldの後)で例外が発生した場合、ジェネレータはサスペンドされます。__exit__はその後generator.throw(exc_type, exc_val, exc_tb)を呼び出し、例外がアクティブな状態でyield行でジェネレータを再開します。これにより、ジェネレータ自身のexceptまたはfinallyブロックが実行されることができます。候補者はしばしばthrow()が実際に実行を再開し、例外がジェネレータの観点からはyield式で発生したと見なされることを見落とします。
contextmanagerでデコレートされたジェネレータが単一のyieldポイントを強制するのはなぜで、もしこの制約が違反されるとどのような特定のエラーが発生しますか?
コンテキストマネージャプロトコルは、単一のエントリと出口を想定します。ジェネレータが二回目のyieldを出す場合(__exit__がnext()を呼び出した場合(例外なし)でジェネレータが再度yieldするか、throw()が呼び出され、ジェネレータが例外を処理して再度yieldする場合)、GeneratorContextManagerは"generator didn't stop"というメッセージでRuntimeErrorを発生させます。これは、状態マシンがクリーンアップ後にジェネレータが枯渇していると期待するからです。候補者はしばしば、標準のイテレーションでは複数のyieldが有効であるのと混同し、yieldがコンテキストのサスペンド/再開境界として機能することを理解していません。
GeneratorContextManagerの__exit__メソッドがwithブロック内で発生した例外を抑制するのはどのような状況下で、どのようにジェネレータの例外処理と相互作用しますか?
__exit__は、throw()を介して注入された例外がジェネレータ内で捕まえられ、ジェネレータが終了に達(StopIterationを発生)して例外を再発生させないか、新しい例外を発生させない場合にのみ、例外を抑制します。ジェネレータが例外を捕まえ、throw()呼び出しが正常に戻るように許可した場合、__exit__はこれを成功した処理とみなし、Trueを返します。ジェネレータが例外を捕まえない場合、throw()はそれを外に伝播させ、__exit__はNone(falsy)を返し、例外が伝播します。候補者はしばしば、単にジェネレータ内にtry/exceptを持つことが十分ではないと見落とします。例外はthrow()呼び出しから具体的に捕まえられなければならず、再発生させてはならず、抑制のためには明示的なreturnまたは捕まえた後の終了に落ちることが必要です。