楔子
上一篇文章我们介绍了名字空间,并且知道了全局变量都存在 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([1, 2, 3]))
"""
[1 2 3]
"""
# 本质上就是去 np 的属性字典中查找 key = "array"
print(np.__dict__["array"]([11, 22, 33]))
"""
[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 空间,以及如何使用数组静态查找,我们后面还会详细说。