質問への回答
Pythonは、ネストされた関数とその外側のスコープとの間の仲介者として機能するcellオブジェクトを含むメカニズムを通じて、字句スコープを実装しています。ネストされた関数が外側のスコープから変数を参照すると、コンパイラはそれを自由変数(co_freevarsに格納)としてマークし、外側の関数はその変数の値を標準のローカル変数スロットではなく、cellオブジェクトの中に保存します。nonlocalキーワードは、インタープリタに対して、名前の解決を新しいローカルバインディングを作成するのではなく、既存のcellオブジェクトに解決するように指示します。これにより、内側のスコープは外側のスコープと同じメモリ位置を読み書きできるようになります。
実生活の状況
データ処理パイプラインのために軽量の監査ロガーを実装する必要があり、グローバル名前空間を汚染したり、完全なクラス階層を作成したりせずに、複数のコールバック呼び出し間でサニタイズされたレコードのカウントを維持する必要がありました。課題は、カウンタの状態が内側のロギング関数への呼び出し間で持続しつつ、作成するファクトリー関数の内部にカプセル化され続けることを保証することでした。
検討された1つの解決策は、ロガーIDによってキー付けされたカウンタを保存するためにグローバル辞書を使用することでした。このアプローチはシンプルで、状態の外部からの検査を可能にしましたが、グローバル名前空間の汚染を引き起こし、アプリケーション全体でスレッドセーフを保証するためには複雑なロック機構が必要でした。さらに、他のモジュールに実装の詳細を露呈することでカプセル化を壊しました。
別のアプローチは、カウンタを保持するインスタンス属性を持つ専用クラスを作成することでした。これにより、適切なカプセル化と慣れ親しんだオブジェクト指向の意味論が提供されましたが、本質的には単一機能のユーティリティであるため不必要なボイラープレートが増え、インスタンス作成のオーバーヘッドは何千回もインスタンス化される高頻度のロギング操作には過剰と見なされました。
選択された解決策は、カウンタを外側のスコープのcellオブジェクトにバインドするためにnonlocal宣言を利用するクロージャを使用しました。このアプローチは、クラスのオーバーヘッドなしでクリーンな関数的カプセル化を維持し、状態がクロージャ内にプライベートであることを保証し、Pythonの最適化されたセルデリファレンス機構を活用しました。これにより、ローカル変数よりはわずかに遅いものの、I/O操作と比較して無視できる程度のオーバーヘッドが発生しました。結果として、クラスベースのアプローチに比べてメモリオーバーヘッドが40%削減され、グローバル状態の競合が排除されました。
候補者がよく見落とすこと
nonlocalキーワードなしで外側のスコープからの変数への代入が新しいローカル変数を作成するのはなぜですか?
Pythonでは、代入はデフォルトで現在のローカルスコープ内で名前を値に結びつける文です。コンパイラがネストされた関数内で代入に遭遇すると、その変数は他に明示的に宣言されていない限り、その関数に固有のものであると判断します。nonlocalなしで、内側の関数は自身のf_locals辞書に新しいエントリを作成し、外側の変数を完全に隠します。nonlocal宣言は、コンパイラに対してその変数を外側のスコープで作成されたcellオブジェクトへの参照として扱うことを強制し、共有メモリ位置への読み書きアクセスを許可します。
スコープ解決についてnonlocalとglobalの根本的な違いは何ですか?
両方のキーワードは代入が操作されるスコープを変更しますが、globalは名前の解決をモジュールレベルのグローバル名前空間に制限し、介在する外側の関数スコープをバイパスします。対照的に、nonlocalは現在のローカルスコープをスキップし、外側の関数定義を通じて最も近い名前に関連付けられたcellオブジェクトを見つけるために検索します(ただしモジュールのグローバル変数を除く)。これにより、nonlocalはモジュールレベルの変数を変更するためには使用できず、globalは外側の関数で明示的にグローバルと宣言されない限り、ネストされた関数内の変数を確認することができません。
複数のネストされた関数が同じ状態をどのようにcellオブジェクトを介して共有し、これらのセルは実際にはいつ割り当てられるのか?
外側の関数が外側のスコープから同じ変数を参照する複数の内側の関数を定義すると、Pythonのコンパイラは、その変数に対して外側の関数のフレーム内に単一のcellオブジェクトを作成します。すべての内側の関数は、その__closure__タプル内でこの同じcellオブジェクトへの参照を受け取ります。これらのセルは、外側の関数が実行されるときにランタイムで割り当てられ(コードがコンパイルされるときではなく)、内側の関数(またはそれらへの参照)が存在する限り持続します。この共有されたcellオブジェクトは、異なる内側の関数が閉じ込められた変数に対する相互の変更を観察できることを可能にし、インスタンス変数に似た共有状態メカニズムを作成しますが、クラスなしで行います。