当查找一个 Python 变量时,虚拟机会进行哪些动作?

文摘   2024-10-14 11:03   北京  

楔子


上一篇文章我们介绍了名字空间,并且知道了全局变量都存在 global 名字空间中,往 global 空间添加一个键值对相当于定义一个全局变量。那么问题来了,如果往函数的 local 空间里面添加一个键值对,是不是也等价于创建了一个局部变量呢?

def f1():
    locals()["name "] = "古明地觉"
    try:
        print(name)
    except Exception as e:
        print(e)

f1()  # name 'name' is not defined

全局变量的创建是通过向字典添加键值对实现的,因为全局变量会一直变,需要使用字典来动态维护。

但对于函数来讲,内部的变量是通过静态方式存储和访问的,因为局部作用域中存在哪些变量在编译的时候就已经确定了,我们通过 PyCodeObject 的 co_varnames 即可获取内部都有哪些变量。

所以,虽然我们说变量查找遵循 LGB 规则,但函数内部的变量其实是静态访问的,不过完全可以按照 LGB 的方式理解。关于这方面的细节,后续还会细说。

因此名字空间是 Python 的灵魂,它规定了变量的作用域,使得 Python 对变量的查找变得非常清晰。



LEGB 规则


LGB 是针对 Python2.2 之前的,而从 Python2.2 开始,由于引入了嵌套函数,所以内层函数在找不到某个变量时应该先去外层函数找,而不是直接就跑到 global 空间里面找,那么此时的规则就是 LEGB。

x = 1

def foo():
    x = 2
    def bar():
        print(x)
    return bar

foo()()
"""
2
"""

调用了内层函数 bar,如果按照 LGB 的规则来查找的话,由于函数 bar 的作用域没有 a,那么应该到全局里面找,打印的结果是 1 才对。

但我们之前说了,作用域仅仅是由文本决定的,函数 bar 位于函数 foo 之内,所以函数 bar 定义的作用域内嵌于函数 foo 的作用域之内。换句话说,函数 foo 的作用域是函数 bar 的作用域的直接外围作用域。

所以应该先从 foo 的作用域里面找,如果没有那么再去全局里面找,而作用域和名字空间是对应的,所以最终打印了 2。

另外在调用 foo() 的时候,会执行函数 foo 中的 def bar(): 语句,这个时候解释器会将 a = 2 函数 bar 捆绑在一起,然后返回,这个捆绑起来的整体就叫做闭包

所以:闭包 = 内层函数 + 引用的外层作用域。

这里显示的规则就是 LEGB,其中 E 表示 Enclosing,代表直接外围作用域。



global 表达式


在初学 Python 时,估计很多人都会对下面的问题感到困惑。

x = 1

def foo():
    print(x)

foo()
"""
1
"""

首先这段代码打印 1,这显然是没有问题的,不过下面问题来了。

x = 1

def foo():
    print(x)
    x = 2

foo()

这段代码在执行 print(x) 的时候是会报错的,会抛出一个 UnboundLocalError。

意思就是说,无法访问局部变量 x,因为它还没有和某个值(对象)进行绑定。当然,如果是以前的 Python 版本,比如 3.8,同样会抛出这个错误,只是信息不同。

意思是局部变量 x 在赋值之前就被使用了,所以尽管报错信息不同,但表达的含义是一样的。

那么问题来了,在 print(x) 的下面加一个 x = 2,整体效果不应该是先打印全局变量 x,然后再创建一个局部变量 x 吗?为啥就报错了呢,相信肯定有人为此困惑。如果想弄明白这个错误的原因,需要深刻理解两点:

  • 函数中的变量是静态存储、静态访问的, 内部有哪些变量在编译的时候就已经确定;

  • 局部变量在整个作用域内都是可见的;


在编译的时候,因为 x = 2 这条语句,所以知道函数中存在一个局部变量 x,那么查找的时候就会在当前局部作用域中查找。但还没来得及赋值,就 print(x) 了,换句话说,在打印 x 的时候,它还没有和某个具体的值进行绑定,所以报错:局部变量 x 在赋值之前就被使用了。

但如果没有 x = 2 这条语句则不会报错,因为知道局部作用域中不存在 x 这个变量,所以会找全局变量 x,从而打印 1。

更有趣的东西隐藏在字节码当中,我们可以通过反汇编来查看一下:

import dis

x = 1

def foo():
    print(x)

dis.dis(foo)
"""
  5           0 RESUME                   0

  6           2 LOAD_GLOBAL              1 (NULL + print)
             12 LOAD_GLOBAL              2 (x)
             22 CALL                     1
             30 POP_TOP
             32 RETURN_CONST             0 (None)
"""


def bar():
    print(x)
    x = 2

dis.dis(bar)
"""
 10           0 RESUME                   0

 11           2 LOAD_GLOBAL              1 (NULL + print)
             12 LOAD_FAST_CHECK          0 (x)
             14 CALL                     1
             22 POP_TOP

 12          24 LOAD_CONST               1 (2)
             26 STORE_FAST               0 (x)
             28 RETURN_CONST             0 (None)
"""

第二列的序号代表字节码指令的偏移量,我们看偏移量为 12 的指令,函数 foo 对应的指令是 LOAD_GLOBAL,意思是在 global 空间中查找 x。而函数 bar 的指令是 LOAD_FAST_CHECK,表示在数组中静态查找 x,但遗憾的是,此时 x 还没有和某个值进行绑定。

因此结果说明 Python 采用了静态作用域策略,在编译的时候就已经知道变量藏身于何处。而且这个例子也表明,一旦函数内有了对某个变量的赋值操作,它会在整个作用域内可见,因为编译时就已经确定。换句话说,会遮蔽外层作用域中相同的名字。

我们看一下函数 foo 和函数 bar 的符号表。

x = 1

def foo():
    print(x)


def bar():
    print(x)
    x = 2

print(foo.__code__.co_varnames)  # ()
print(bar.__code__.co_varnames)  # ('x',)

在编译的时候,就知道函数 bar 里面存在局部变量 x。

如果想修复这个错误,可以用之前说的 global 关键字,将变量 x 声明为全局的。

x = 1

def bar():
    global x  # 表示变量 x 是全局变量
    print(x)
    x = 2

bar()  # 1
print(x)  # 2

但这样的话,会导致外部的全局变量被修改,如果不想出现这种情况,那么可以考虑直接获取全局名字空间。

x = 1

def bar():
    print(globals()["x"])
    x = 2

bar()  # 1
print(x)  # 1

这样结果就没问题了,同样的,类似的问题也会出现在嵌套函数中。

def foo():
    x = 1
    def bar():
        print(x)
        x = 2
    return bar

foo()()

执行内层函数 bar 的时候,print(x) 也会出现 UnboundLocalError,如果想让它不报错,而是打印外层函数中的 x,该怎么做呢?Python 同样为我们准备了一个关键字: nonlocal。

def foo():
    x = 1
    def bar():
        # 使用 nonlocal 的时候,必须是在内层函数里面
        nonlocal x
        print(x)
        x = 2
    return bar

foo()()  # 1

如果 bar 里面是 global x,那么表示 x 是全局变量,当 foo()() 执行完毕之后,会创建一个全局变量 x = 2。但这里不是 global,而是 nonlocal,表示 x 是外部作用域中的变量,因此会打印 foo 里面的变量 x。

当然啦,既然声明为 nonlocal,那么 foo 里面的 x 肯定会受到影响。

from types import FrameType
import inspect

frame: FrameType | None = None  

def foo():
    globals()["frame"] = inspect.currentframe()
    x = 1
    def bar():
        nonlocal x
        # print(x)
        x = 2
    return bar

bar = foo()
# 打印 foo 的局部变量,此时变量 x 的值为 1
print(frame.f_locals)
"""
{'bar': <function foo.<locals>.bar at 0x0000021EC32AB9A0>, 'x': 1}
"""

# 调用内层函数 bar
bar()
# 此时 foo 的局部变量 x 的值变成了 2
print(frame.f_locals)
"""
{'bar': <function foo.<locals>.bar at 0x0000021EC32AB9A0>, 'x': 2}
"""

不过由于 foo 是一个函数,调用内层函数 bar 的时候,外层函数 foo 已经结束了,所以不管怎么修改它里面的变量,都无所谓了。

另外上面的函数只嵌套了两层,即使嵌套很多层也是可以的。

from types import FrameType
import inspect

frame: FrameType | None = None

def a():
    def b():
        globals()["frame"] = inspect.currentframe()
        x = 123
        def c():
            def d():
                def e():
                    def f():
                        nonlocal x
                        print(x)
                        x = 456
                    return f
                return e
            return d
        return c
    return b

b = a()
c = b()
d = c()
e = d()
f = e()
print(frame.f_locals)
"""
{'c': <function a.<locals>.b.<locals>.c at 0x00000255A0C10F70>, 'x': 123}
"""

# 调用函数 f 的时候,打印的是函数 b 里面的变量 x
# 当然,最后也会修改它
f()
"""
123
"""

print(frame.f_locals)
"""
{'c': <function a.<locals>.b.<locals>.c at 0x00000255A0C10F70>, 'x': 456}
"""

不难发现,在嵌套多层的情况下,会采用就近原则。如果函数 d 里面也定义了变量 x,那么函数 f 里面的 nonlocal x 表示的就是函数 d 里面的局部变量 x。 



属性查找


当我们访问某个变量时,会按照 LEGB 的规则进行查找,而属性查找也是类似的,本质上都是到名字空间中查找一个名字所引用的对象。但由于属性查找限定了范围,所以要更简单,比如 a.xxx,就是到 a 里面去找属性 xxx,这个规则是不受 LEGB 作用域限制的,就是到 a 里面查找,有就是有,没有就是没有。

import numpy as np

# 在 np 指向的对象(模块)中查找 array 属性
print(np.array([123]))
"""
[1 2 3]
"""

# 本质上就是去 np 的属性字典中查找 key = "array"
print(np.__dict__["array"]([112233]))
"""
[11 22 33]
"""



class Girl:

    name = "古明地觉"
    age = 16

print(Girl.name, Girl.age)
"""
古明地觉 16
"""

print(Girl.__dict__["name"], Girl.__dict__["age"])
"""
古明地觉 16
"""

需要补充一点,我们说属性查找会按照 LEGB 规则,但这必须限制在自身所在的模块内,如果是多个模块就不行了。举个例子,假设有两个 py 文件,内容如下:

# girl.py
print(name)

# main.py
name = "古明地觉"
from girl import name

关于模块的导入我们后续会详细说,总之执行 main.py 的时候报错了,提示变量 name 没有被定义,但问题是 main.py 里面定义了变量 name,为啥报错呢?

很明显,因为 girl.py 里面没有定义变量 name,所以导入 girl 的时候报错了。因此结论很清晰了,变量查找虽然是 LEGB 规则,但不会越过自身所在的模块。print(name) 在 girl.py 里面,而变量 name 定义在 main.py 里面,在导入时不可能跨过 girl.py 的作用域去访问 main.py 里的 name,因此在执行 from girl import name 的时候会抛出  NameError。

虽然每个模块内部的作用域规则有点复杂,因为要遵循 LEGB;但模块与模块的作用域之间则划分得很清晰,就是相互独立。

关于模块,我们后续会详细说。总之通过属性操作符 . 的方式,本质上都是去指定的名字空间中查找对应的属性。



属性空间


我们知道,自定义的类里面如果没有 __slots__,那么这个类的实例对象会有一个属性字典,和名字空间的概念是等价的。

class Girl:
    def __init__(self):
        self.name = "古明地觉"
        self.age = 16

g = Girl()
print(g.__dict__)  # {'name': '古明地觉', 'age': 16}

# 对于查找属性而言, 也是去属性字典中查找
print(g.name, g.__dict__["name"])  # 古明地觉 古明地觉

# 同理设置属性, 也是更改对应的属性字典
g.__dict__["gender"] = "female"
print(g.gender)  # female

当然模块也有属性字典,本质上和类的实例对象是一致的,因为模块本身就是一个实例对象。

print(__builtins__.str)  # <class 'str'>
print(__builtins__.__dict__["str"])  # <class 'str'>

另外这个 __builtins__ 位于 global 名字空间里面,然后获取 global 名字空间的 globals 又是一个内置函数,于是一个神奇的事情就出现了。

print(globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"]
      )  # <module 'builtins' (built-in)>

print(globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"].list("abc")
      )  # ['a', 'b', 'c']

global 名字空间和 builtin 名字空间,都保存了指向彼此的指针,所以不管套娃多少次,都是可以的。



小结


整个内容很好理解,关键的地方就在于局部变量,它是静态存储的,编译期间就已经确定。而在访问局部变量时,也是基于数组实现的静态查找,而不是使用字典。

关于 local 空间,以及如何使用数组静态查找,我们后面还会详细说。

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章