🌟

Python の変数スコープを code object と inspect で見てみる

2021/09/16に公開

Overview

(おすすめできない方法だが) eval() でラムダ式を生成しようとして、スコープについてやや複雑であることがわかったので知見を共有する。スコープについては色々なところで紹介されているため、あまり新しい内容はないかもしれない。

注意: このあたりの仕様にあまり詳しくないため、誤りの可能性にはご注意いただきたい。

なお、スコープや変数束縛について、基本的な知識があることを仮定する。

動作環境

Python 3.9.6 で動作を確認した。

参考文献

意外なところに書かれていたりする。

Summary

  • 関数定義時に解決されていない変数は、関数実行時のスコープから上にたどってグローバルスコープまでを見に行くことにより解決しようとする。
    • 解決が成功すれば正常実行できる。
    • 解決が失敗すれば NameError が発生する。
  • スコープ情報は以下の方法で確認できる
    • inspect.getclosurevars()
    • <関数>.__globals__
    • <関数>.__closure__
    • <関数>.__code__.co_varnames などのコードオブジェクト内の情報

Motivating example

まず、私にとっては驚きだったが、次のコードは動く。
エディタはソース上にエラーを表示するかもしれないが、動く。

scope1.py
from pprint import pprint

v = 1


def define_g():
    z = 5

    def g(x, y):
        o = v + w + x + y + z
        return o
    return g


f = define_g()

f に代入されるのは define_g() の中で定義されている関数 g である。
g において、

  • xy はこの関数の引数である。(= g の local scope にある)
  • oはこの関数内で定義されている。(= これも g の local scope にある)
  • v はこのファイル (= モジュール) のトップレベルで定義されている。(= global scope にある)
  • z はこの関数の外側の関数内で定義されている。(g の enclosing scope にある)

いっぽう、w に対する参照が解決していない。でも関数 g定義することはできる。

ただし、もちろん、f を実行すること (呼び出して関数内をすべて実行すること) はできない。

scope2.py
# 略: scope1.py の内容
f(1, 2)

を付け足すと出力はこうなる:

Traceback (most recent call last):
  File ".../scope2.py", line 16, in <module>
    f(1, 2)
  File ".../scope2.py", line 10, in g
    o = v + w + x + y + z
NameError: name 'w' is not defined

正しく動かすには f (つまり g) の呼び出し前に w を (f の呼び出しを含むどこかの) スコープで定義してやればいい:

scope3.py
# 略: scope1.py の内容
w = 3
f(1, 2)

つまり、スコープは変数名をキーとする辞書だと思っておけば大体よくて、

  • 実行時にどのスコープに変数を探しに行けばよいかは関数の定義時に決定している。
  • 実行時にはそれらのスコープのその時点での値を使って変数を解決する。

ことがわかる。なお、def の代わりに lambda を使っても同様の振る舞いをする。

ではこれらのスコープ情報をプログラム的に取得できるか、というのが興味の的である。

inspect モジュールを使う

おあつらえ向きに、inspect モジュールには getclosurevars() 関数がある。

scope4.py
from inspect import getclosurevars

v = 1


def define_g():
    z = 5

    def g(x, y):
        o = v + w + x + y + z
        return o
    return g


f = define_g()
print(getclosurevars(f))

実行結果はこう:

ClosureVars(nonlocals={'z': 5}, globals={'v': 1}, builtins={}, unbound={'w'})

z を enclosing scope から探しに行くこと、w が解決されていないこと、などがわかる。
g が組み込み関数などを呼んでいる場合は、それらが builtins の中に含まれる。
一方で、関数名が示すように、ローカル変数 o, x, y はこれらに含まれない。私が知る限り、これらを取得する inspect のメソッドはないので、次節のようにコードオブジェクトを取得するのがよいと思う。

コードオブジェクトから変数スコープを取得する

ユーザが定義した関数 f はコードオブジェクト f.__code__ を持っており、変数のスコープに関する情報を持っている。

scope5.py
v = 1


def define_g():
    z = 5

    def g(x, y):
        o = v + w + x + y + z
        return o
    return g


f = define_g()
print(f.__code__)
print(f.__code__.co_varnames)
print(f.__code__.co_freevars)
print(f.__code__.co_names)

実行結果はこちら:

<code object g at 0x1085dd5b0, file ".../scope5.py", line 7>
('x', 'y', 'o')
('z',)
('v', 'w')

属性名と取得できる内容があまり一致していない気がするが、私の解釈はこうである:

  • co_varnames: f (g) の local scope でアクセスできる変数
  • co_freevars: enclosing scope で定義されている変数
  • co_names: 外の scope で解決する変数 (global scope で定義されているか、すべての scope で未定義である変数)

f.__closure__ について

enclosing scope で定義されている変数については、その値をセルオブジェクト経由で取得できる。

spec6.py
# 略: spec5.py と同じ
f = define_g()
print(f.__closure__)
print(f.__closure__[0].cell_contents)

実行結果はこう:

(<cell at 0x10ee3dfd0: int object at 0x10ecef9b0>,)
5

たしかに z の値が入っている。f.__closure__ でセルオブジェクトの tuple が取得できて、それらオブジェクトは cell_contents 属性を持ち、そこに変数の値が入っている。
試していないが、これは書き換えが可能で、それによりクロージャ変数の値を変更できるらしい。

f.__globals__ について

コードオブジェクトのその他の属性については inspect モジュールに説明があるので見てほしい。
たとえば、関数が実行されるときの「グローバルスコープ」が格納されている変数がある:

spec7.py
from pprint import pprint

# 略

f = define_g()
pprint(f.__globals__)

結果はこう

{'__annotations__': {},
 '__builtins__': <module 'builtins' (built-in)>,
 '__cached__': None,
 '__doc__': None,
 '__file__': '/Users/shinsa/git/zenn-docs/articles/scope7.py',
 '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x10823fca0>,
 '__name__': '__main__',
 '__package__': None,
 '__spec__': None,
 'define_g': <function define_g at 0x108289f70>,
 'f': <function define_g.<locals>.g at 0x108325c10>,
 'pprint': <function pprint at 0x108325af0>,
 'v': 1}

これはこのファイルに相当するモジュールの global scope そのものであり、それが g が定義されたときの global scope でもある。

実は、関数が実行されるときに変数を探しに行く「グローバルスコープ」とは、この f.__globals__ である。これは普通、関数呼び出しが入っているモジュールの、ではなくて、関数定義が入っているモジュールの global scope である。

メタプログラミングを使わなければ、この内容を変更することはできないが、eval() で関数を作るときに第2引数として「グローバルスコープ」を与えることができる。これにより関数実行時の scope を tweak することができる。

もしくは関数の実行を exec() で行う。繰り返しになるが、知らないコードを実行しちゃったりすると危険なので、あまり推奨されない方法であることに注意。

本来の動機であった、eval() で関数を作るときのポイントについては別記事で。

Discussion