Pythonでは、変数のスコープ解決は実行時ではなく、コンパイルフェーズ中に静的に行われます。CPythonコンパイラが関数定義に遭遇すると、抽象構文木をたどって、すべての名前をローカル、グローバル、またはセル変数として分類するシンボルテーブルを構築します。関数本体内で名前に対するバインディング操作(代入、拡張代入、またはインポートなど)が検出されると、コンパイラはその名前をスコープ全体でローカル変数としてマークします。この設計により、仮想マシンは固定サイズの配列で動作する最適化されたLOAD_FASTオペコードを使用でき、遅いハッシュテーブルのルックアップを行う必要がなくなります。この最適化はPythonの関数呼び出し性能にとって重要ですが、厳格なバインディング要件を導入します。
名前がローカルとして分類されると、コンパイラはその名前のすべての読み取り操作に対してLOAD_FASTバイトコード命令を発行します。実行時にLOAD_FASTはフレームのローカル変数配列内の対応するインデックスからオブジェクト参照を取得しようとします。スロットに値がまだ割り当てられていないことを示すnullポインタが含まれている場合、実行時にUnboundLocalErrorが発生します。同名のグローバル変数が存在していても、コンパイラは意図的にLOAD_GLOBALの発行を回避するためです。このエラーは、この静的スコープ決定を明示的に示し、NameErrorとは区別されます。
これを解決するには、global <variable_name>を宣言して、名前がグローバル名前空間を指すことをコンパイラに明示的に知らせる必要があります。この宣言により、コンパイラはLOAD_GLOBALおよびSTORE_GLOBALオペコードに切り替わり、モジュールのグローバル辞書内で名前を動的に検索します。あるいは、すべてのローカル変数が関数のトップで初期化され、条件ロジックによる読み取りが行われる前に構造を再編成することができます。ネストされたスコープの場合、nonlocalキーワードはコンパイラにクローズセルにアクセスするためにLOAD_DEREFを使用させます。これらの宣言は、コンパイル時のコンパイラのバインディング決定を変更し、アンバウンドローカルシナリオを防ぎます。
threshold = 100 def analyze(data): # コンパイラは以下の'threshold = ...'を見て、ローカルとしてマークします if data > threshold: # UnboundLocalErrorを発生させます return "high" threshold = 50 # 代入によりローカルになります # 'global'を使用した解決策 def analyze_fixed(data): global threshold if data > threshold: # LOAD_GLOBALが成功します return "high" threshold = 50 # グローバル変数を更新します
データエンジニアリングチームは、Apache Airflowを使用してETLパイプラインを構築していました。彼らは、処理パラメータを容易に調整できるように、モジュールレベルでデフォルト設定辞書CONFIG = {"batch_size": 1000}を定義しました。メイン変換関数process_batch()は、初めにif len(records) > CONFIG["batch_size"]:をチェックして分割が必要かを判断しました。その後の特定の条件下で、コードはCONFIG = {"batch_size": 500}を使ってバッチサイズを減少させてメモリを最適化しようとしました。このパターンは偶然にスコープの競合を引き起こしました。
パイプラインを実行すると、関数の最初の行でUnboundLocalErrorが発生しました:local variable 'CONFIG' referenced before assignment。関数の終わりでの代入文がPythonコンパイラにCONFIGを関数本体全体のローカル変数として扱わせました。その結果、開始時の比較操作は初期化されていないローカル変数スロットにアクセスするためにLOAD_FASTを使用しました。この失敗により、関数は実行を開始できず、データパイプラインが重要なプロダクション実行中に停止しました。
チームは最初に、ローカルでの再代入をlocal_configに名前変更し、減少したバッチ処理のための新しい辞書を作成することを検討しました。これにより、シャドウイングの問題を完全に回避し、グローバル設定を不変に保つことができます。しかし、このアプローチでは、現在の制限を反映する名前CONFIGを期待する下流コードをリファクタリングする必要がありました。これは、開発者が後続のロジックで新しい変数名を使用するのを忘れた場合に潜在的な不整合を導入します。同じ概念に対して2つの変数名を追跡する認知的オーバーヘッドは、この解決策をあまり魅力的にしませんでした。
もう一つの選択肢は、関数の最初にglobal CONFIGを追加して、すべての参照をグローバル参照として扱うように強制することでした。この方法ではエラーを防ぐことができますが、グローバル状態をバッチプロセス中に変更することは危険なアンチパターンであるため、チームはそれを拒否しました。これにより関数の再入性が妨げられ、単体テストが大幅に複雑化します。また、コードがスレッド間で並行処理される場合にはレースコンディションを引き起こします。モジュールレベルの状態への副作用は、製品データパイプラインにとって受け入れられないと見なされました。
3つ目の解決策は、変数名そのものを再代入するのではなく、CONFIG["batch_size"] = 500を使用して既存の辞書をその場で変更することを含みました。この操作はCONFIGの名前に対して新しいバインディングを作成しないため、コンパイラはそれを引き続きグローバル参照として扱い続けます。これにより、UnboundLocalErrorを回避し、構成の更新を次の呼び出しに保持できます。これは最善の即時修正と見なされましたが、チームは後で構成をクラスインスタンスにリファクタリングする予定でした。この変更アプローチは既存のAPIを保ちながら、即時のクラッシュを解決しました。
彼らは3つ目の解決策を実装し、再代入を変更してCONFIG["batch_size"] = 500にしました。パイプラインはエラーなく実行を再開し、構成変更が次のバッチに正しく適用されました。後に彼らはコードをリファクタリングして、関数に注入されたPydantic設定オブジェクトを使用しました。これにより、モジュールレベルのグローバル変数への依存関係が完全に排除され、関数は純粋でテスト可能なものになりました。この事件は、同様のシャドウイングパターンを排除するためにすべてのAirflowオペレータのコードレビューを促しました。
なぜ関数内で変数をdelした後にそれを読み取ろうとすると、グローバルスコープにフォールバックせずにUnboundLocalErrorが発生するのでしょうか?
ローカル変数に対してdel xを実行すると、フレームのf_localsから参照が削除されますが、xの静的な分類がローカルとして変更されることはありません。コンパイラはその後の読み取りに対してLOAD_FASTを生成し続けます。インタプリタがLOAD_FASTを実行すると、スロットが空であることがわかり、UnboundLocalErrorが発生します。これは、グローバルにフォールバックするのではなく、スコープの決定が実行時に不変であることを確認します。削除後にグローバルなxにアクセスするには、コンパイル時にglobal xを宣言する必要があります。
デフォルトの引数式はどのようにしてUnboundLocalErrorの罠を避け、これが彼らの評価タイミングに何を示していますか?
デフォルト引数は、関数定義が囲むスコープで実行される時に一度評価され、関数のローカルスコープ内ではありません。def f(val=CONFIG["key"]):と書くと、Pythonは定義時にCONFIGを解決するためにLOAD_GLOBALを使用します。関数本体が後でCONFIGに代入してローカルにしても、デフォルトはすでに安全にキャプチャされます。これは、デフォルト値が定義時にグローバルスコープを使用し、関数本体のローカル実行とは分離されていることを示しています。したがって、デフォルトは、代入前に関数本体内で同じアクセスが発生した場合に発生するUnboundLocalErrorを回避します。
なぜクラス本体ではUnboundLocalErrorが発生しないのか、そしてこのことを可能にするバイトコードの違いは何ですか?
クラス本体では、変数のアクセスにLOAD_NAMEが使用されます。LOAD_NAMEはクラスの辞書内で動的な検索を行い、次にグローバル辞書、最後に組込みを検索します。事前に割り当てられた固定スロットを使用しないため、"アンバウンドローカル"状態に遭遇することはありません。クラス本体内で代入前に名前が参照されると、LOAD_NAMEは単にグローバルスコープで見つけます。この辞書ベースのアプローチは、関数のローカルの速さをトレードオフして、クラス構築中に必要な柔軟性を提供します。