楔子
上一篇文章我们探究了 Python 函数的实现原理,本次来聊一聊它的局部作用域与名字空间。
我们知道函数的参数和函数内部定义的变量都属于局部变量,均是通过静态的方式访问的。
x = 123 def foo1(): global x a = 1 b = 2 # co_nlocals 会返回局部变量的个数 # a 和 b 是局部变量,x 是全局变量,因此是 2 print(foo1.__code__.co_nlocals) # 2 def foo2(a, b): pass print(foo2.__code__.co_nlocals) # 2 def foo3(a, b): a = 1 b = 2 c = 3 print(foo3.__code__.co_nlocals) # 3
无论是参数还是内部新创建的变量,本质上都是局部变量。
按照之前的理解,当访问一个全局变量时,会去访问 global 名字空间(也叫全局名字空间),而这也确实如此。
那么问题来了,当操作函数的局部变量时,是不是也等价于操作其内部的 local 名字空间(也叫局部名字空间)呢?
local 名字空间与局部变量
之前我们说过 Python 变量的访问是有规则的,按照本地、闭包、全局、内置的顺序去查找,也就是 LEGB 规则,所以当然会首当其冲去 local 名字空间里面查找啊。
但不幸的是,在调用函数期间,虚拟机创建栈帧对象时,这个至关重要的 local 名字空间并没有被创建。
//frameobject.c PyFrameObject* _Py_HOT_FUNCTION _PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code, PyObject *globals, PyObject *locals) { //... f->f_locals = NULL; f->f_trace = NULL; //... }
局部名字空间由栈帧的 f_locals 属性负责维护,全局名字空间由 f_globals 属性维护。对于模块而言,它的 f_locals 和 f_globals 指向的是同一个字典。
# 通过 locals() 可以拿到局部名字空间 # 通过 globals() 可以拿到全局名字空间 # 模块的局部名字空间和全局名字空间是同一个字典 print(locals() is globals()) # True
但对于函数而言,f_locals 却是 NULL,它都没有局部名字空间。那么问题来了,局部变量到底存储在什么地方呢?我们先来举个例子。
def foo(a, b): c = a + b print(c)
它的字节码如下:
在介绍虚拟机执行字节码的时候我们说过,当函数被调用时,虚拟机会为其创建一个栈帧。栈帧是函数的执行环境,它包含了函数执行时所依赖的全部的上下文,而栈帧内部有一个成员叫 f_localsplus,它是一个数组。
这个数组虽然是一段连续内存,但在逻辑上它被分成了 4 块。
其中局部变量便存储在 f_localsplus 的第一份空间中。
而我们看到字节码偏移量为 6 和 10 的两条指令分别是:STORE_FAST 和 LOAD_FAST,这两个指令干的事情如下。
case TARGET(LOAD_FAST): { // 基于 GETLOCAL 获取局部变量的值 PyObject *value = GETLOCAL(oparg); //... Py_INCREF(value); PUSH(value); FAST_DISPATCH(); } case TARGET(STORE_FAST): { PREDICTED(STORE_FAST); PyObject *value = POP(); // 基于 SETLOCAL 创建局部变量 SETLOCAL(oparg, value); FAST_DISPATCH(); }
- LOAD_FAST:基于宏 GETLOCAL 获取一个局部变量;
- STORE_FAST:基于宏 STORE_FAST 创建一个局部变量;
那么这两个宏具体是怎么做的呢?
fastlocals 就是栈帧的 f_localsplus,而函数在编译的时候就知道局部变量的值在 f_localsplus 中的索引,所以通过 GETLOCAL 获取即可。同理 SETLOCAL 则是创建一个局部变量。
所以此时我们对局部变量的藏身之处已经了然于心,它们就存放在栈帧的 f_localsplus 成员中,而之所以没有使用 local 名字空间的原因也很简单。因为函数内部的局部变量有多少,在编译的时候就已经确定了,个数是不会变的。因此编译时就能确定局部变量占用的内存大小,也能确定访问局部变量的字节码指令应该如何访问内存。
def foo(a, b): c = a + b print(c) print( foo.__code__.co_varnames ) # ('a', 'b', 'c')
比如符号 c 位于符号表中索引为 2 的位置(编译时就已确定),那么运行时通过 f_localsplus[2] 即可拿到变量 c 对应的值。
这个过程是基于数组索引实现的静态查找,它的效率非常高。而 local 空间是一个字典,虽然字典也是经过高度优化的,但肯定没有静态查找快。
因此,尽管虚拟机为函数实现了 local 空间(初始为 NULL),但是在操作局部变量时却没有使用它,原因就是为了更高的效率。
结论:虽然变量查找的时候是遵循 LEGB 规则,但其实局部变量是静态访问的,不过完全可以按照 LEGB 的方式来理解。
获取 local 名字空间
然后我们再来从 Python 的层面演示一下:
x = 1 def foo(): globals()["x"] = 2 foo() print(x) # 2
global 空间全局唯一,在 Python 层面上就是一个字典,可以通过 globals() 拿到。在任何地方操作该字典,都相当于操作全局变量,即使是在函数内部。
因此在执行完 foo() 之后,全局变量 x 就被修改了。但 local 名字空间也是如此吗?我们来看看,通过 locals() 可以拿到 local 名字空间。
def foo(): x = 1 locals()["x"] = 2 print(x) foo() # 1
我们按照相同的套路,却并没有成功,这是为什么?原因就是我们刚才解释的那样,函数内部有哪些局部变量在编译时就已经确定好了,查询的时候是从 f_localsplus 中静态查找的,而不是从 local 名字空间中查找。
那么 locals() 会打印出来什么东西呢?
def foo(): name = "satori" print(locals()) age = 17 print(locals()) gender = "female" print(locals()) foo() """ {'name': 'satori'} {'name': 'satori', 'age': 17} {'name': 'satori', 'age': 17, 'gender': 'female'} """
我们看到打印 locals() 居然也会显示内部的局部变量,相信聪明如你已经猜到 locals() 是怎么回事了。局部变量不是从局部名字空间里面查找的,所以它初始为空,但当我们执行 locals() 的时候,会动态构建一个字典出来。
首先符号表里面存储了局部变量的符号(或者说名字),f_localsplus 里面存储了局部变量的值,当执行 locals() 的时候,会基于符号表和 f_localsplus 创建一个字典出来。
def foo(): name = "satori" age = 17 gender = "female" print(locals()) # 符号表:保存了函数中创建的局部变量的名字 print(foo.__code__.co_varnames) """ ('name', 'age', 'gender') """ # 调用函数时会创建栈帧,局部变量的值都保存在 f_localsplus 里面 # 并且符号表中变量名的顺序和 f_localsplus 中变量值的顺序是一致的 f_localsplus = ["satori", 17, "female"] # 这里就用一个列表来模拟了
我们来看一下变量的创建,因为符号 name 位于符号表中索引为 0 的位置,那么执行 name = "satori" 时,就会将 "satori" 也放在 f_localsplus 中索引为 0 的位置。
至于变量 age 和 gender 也是同理,它们的值会放在 f_localsplus 中索引为 1 和 2 的位置。
后续在访问变量的时候,比如访问变量 age,由于它位于符号表中索引为 1 的位置,那么就会通过 f_localsplus[1] 获取它的值,这些符号对应的索引都是在编译阶段确定的。所以在运行时才能实现静态查找,指令 LOAD_FAST 和 STORE_FAST 都是基于索引来静态操作底层数组。
我们用一张图来描述这个过程:
符号表负责存储局部变量的名字,f_localsplus 负责存储局部变量的值(里面的元素初始的时候为 NULL),而在给局部变量赋值的时候,本质上就是将值写在了 f_localsplus 中。并且变量名在符号表中的索引,和变量值在 f_localsplus 中的索引是一致的,而操作局部变量本质上就是在操作 f_localsplus 数组。
至于 locals() 或者说局部名字空间,它是基于符号表和 f_localsplus 动态创建的。为了方便我们获取已存在的局部变量,因此在执行 locals() 的时候会临时创建一个字典。
所以我们通过 locals() 获取局部名字空间之后,访问里面的局部变量是可以的,只不过此时将静态操作变成了动态操作。
def foo(): name = "satori" # 会从 f_localsplus 中静态查找 print(name) # 先基于已有的变量和值创建一个字典 # 然后通过字典实现变量的动态查找 print(locals()["name"]) foo() """ satori satori """
两种方式都是可以的,但基于 locals() 来访问,在效率上明显会低一些。
另外基于 locals() 访问一个变量是可以的,但无法创建一个变量。
def foo(): name = "satori" locals()["age"] = 17 try: print(age) except NameError as e: print(e) foo() """ name 'age' is not defined """
局部变量是静态存储在数组里面的,locals() 只是做了一个拷贝而已。往局部名字空间里面添加一个键值对,不等于创建一个局部变量,因为局部变量不是从它这里查找的,因此代码中打印 age 报错了。但如果外部还有一个全局变量 age 的话,那么会打印全局变量 age。
然后再补充一点,我们说全局名字空间在任何地方都是唯一的,而对于函数而言,它内部的局部名字空间也是唯一的。不断调用 locals 多少次,拿到的都是同一个字典。
def foo(): name = "satori" # 执行 locals() 的时候,内部只有一个键值对 d = locals() print(d) # {'name': 'satori'} # 再次获取,此时有两个键值对 print( locals() ) # {'name': 'satori', 'd': {...}} # 但两者的 id 相同,因为一个函数的局部名字空间只有一个 # 不管调用多少次 locals(),拿到的都是同一个字典 print(id(d) == id(locals())) # True foo()
所以 locals() 和 globals() 都是唯一的,只不过 locals() 是在某个函数内唯一,而 globals() 在所有地方都唯一。
因此局部名字空间初始为 NULL,但在第一次执行 locals() 时,会以符号表中的符号作为 key,f_localsplus 中的值作为 value,创建一个字典作为函数的局部名字空间。而后续再执行 locals() 的时候,由于名字空间已存在,就不会再次创建了,直接基于当前的局部变量对字典进行更新即可。
def foo(): # 创建一个字典,由于当前还没有定义局部变量,因此是空字典 print(locals()) """ {} """ # 往局部名字空间添加一个键值对 locals()["a"] = "b" print(locals()) """ {'a': 'b'} """ # 定义一个局部变量 name = "satori" # 由于局部名字空间已存在,因此不会再次创建 # 将局部变量的名字作为 key、值作为 value,拷贝到字典中 print(locals()) """ {'a': 'b', 'name': 'satori'} """ foo()
注意:虽然局部名字空间里面存在 "a" 这个 key,但 a 这个局部变量是不存在的。
local 名字空间的创建过程
我们已经知道 local 名字空间是怎么创建的了,也熟悉了它的特性,下面通过源码来看一下它的构建过程。
int PyFrame_FastToLocalsWithError(PyFrameObject *f) { PyObject *locals, *map; PyObject **fast; PyCodeObject *co; Py_ssize_t j; Py_ssize_t ncells, nfreevars; // 获取局部名字空间 locals = f->f_locals; // 如果为 NULL,那么创建一个新字典,作为名字空间 // 所以局部名字空间只会创建一次,后续不会再创建 if (locals == NULL) { locals = f->f_locals = PyDict_New(); if (locals == NULL) return -1; } // 获取 PyCodeObject 对象,然后拿到内部的符号表 // 符号表(co_varnames)里面保存了函数局部变量的名字 co = f->f_code; map = co->co_varnames; // 获取 f_localsplus,它里面保存了局部变量的值 // 只不过除了局部变量的值之外,还保存了其它的 fast = f->f_localsplus; // 那么 f_localsplus 里面到底有多少个局部变量的值呢? // 显然这要基于符号表来判断,co_varnames 里面保存了多少符号 // f_localsplus 里面就保存了多少个局部变量的值 j = PyTuple_GET_SIZE(map); if (j > co->co_nlocals) j = co->co_nlocals; // 将 co_varnames 和 f_localsplus 的元素组成键值对,添加到局部名字空间中 // 相当于 locals.update(zip(co_varnames, f_localsplus)) if (co->co_nlocals) { if (map_to_dict(map, j, locals, fast, 0) < 0) return -1; } // 如果里面有闭包变量的话,也会添加到局部名字空间当中 ncells = PyTuple_GET_SIZE(co->co_cellvars); nfreevars = PyTuple_GET_SIZE(co->co_freevars); if (ncells || nfreevars) { if (map_to_dict(co->co_cellvars, ncells, locals, fast + co->co_nlocals, 1)) return -1; // ... } return 0; }
代码有略微的删减,可以看到源码的实现逻辑和我们之前分析的是一样的。
- 变量 map 是符号表 co_varnames,保存了局部变量的名字;
- 变量 fast 是 f_localsplus,保存了局部变量的值;
- 变量 j 是局部变量的个数;
- 变量 locals 是局部名字空间;
然后将它们作为参数,传递给 map_to_dict 函数进行调用。该函数内部会进行遍历,按照顺序将变量名和变量值依次添加到局部名字空间中。
但 map_to_dict 函数有一处细节非常之关键:
当变量值为 NULL 时,则说明在获取名字空间时,该变量还没有被赋值。要是此时变量已经在局部名字空间中,那么会将它从名字空间中删掉。这一处非常关键,在介绍 exec 的时候你就会明白。
以上就是 local 名字空间的获取过程在源码层面的体现。
local 名字空间与 exec 函数
我们再来搭配 exec 关键字,结果会更加明显。首先 exec 函数可以将一段字符串当成代码来执行,并将执行结果体现在当前的名字空间中。
def foo(): print(locals()) # {} exec("x = 1") print(locals()) # {'x': 1} try: print(x) except NameError as e: print(e) # name 'x' is not defined foo()
尽管 locals() 变了,但是依旧访问不到 x,因为虚拟机并不知道 exec("x = 1") 是创建一个局部变量,它只知道这是一个函数调用。
事实上 exec 会作为一个独立的编译单元来执行,并且有自己的作用域。
所以 exec("x = 1") 执行完之后,效果就是改变了局部名字空间,里面多了一个 "x": 1 键值对。但关键的是,局部变量 x 不是从局部名字空间中查找的,exec 终究还是错付了人。
由于函数 foo 对应的 PyCodeObject 对象的符号表中并没有 x 这个符号,所以报错了。
补充:exec 默认影响的是 local 名字空间,如果在执行时发现 local 名字空间为 NULL,那么会自动创建一个。所以调用 exec 也可以创建名字空间(当它为 NULL 时)。
exec("x = 1") print(x) # 1
如果放在模块里面是可以的,因为模块的 local 名字空间和 global 名字空间指向同一个字典,所以 global 名字空间会多一个 key 为 "x" 的键值对。而全局变量是从 global 名字空间中查找的,所以这里没有问题。
def foo(): # 此时 exec 影响的是全局名字空间 exec("x = 123", globals()) # 这里不会报错, 但此时的 x 不是局部变量, 而是全局变量 print(x) foo() print(x) """ 123 123 """
可以给 exec 指定要影响的名字空间,代码中 exec 影响的是全局名字空间,打印的 x 也是全局变量。
变量名冲突的问题
我们说 exec 的执行效果会体现在 local 名字空间中,但是需要考虑变量名冲突的问题。举个例子:
def foo(): exec("x = 1") print(locals()["x"]) foo() """ 1 """ def bar(): exec("x = 1") print(locals()["x"]) x = 123 bar() """ Traceback (most recent call last): File ..... bar() File ..... print(locals()["x"]) KeyError: 'x' """
这是什么情况?函数 bar 只是多了一行赋值语句,为啥就报错了呢?要想搞懂这个问题,首先要明确两点:
1)函数的局部变量在编译的时候已经确定,并存储在对应的 PyCodeObject 对象的符号表 (co_varnames) 中,这是由语法规则所决定的;
2)函数内的局部变量在其整个作用域范围内都是可见的;
对于 foo 函数来说,exec 执行完之后相当于往 local 名字空间中添加一个键值对,这没有问题。对于 bar 函数而言也是如此,在执行完 exec("x = 1") 之后,local 名字空间也会存在 "x": 1 这个键值对,但我们下面执行 locals() 的时候又把字典更新了。
因为局部变量可以在函数的任意位置创建,或者修改,所以每一次执行 locals() 的时候,都会遍历符号表和 f_localsplus,组成键值对将原来的字典更新一遍。
在 bar 函数里面有一行 x = 123,所以知道函数里面存在局部变量 x,符号表里面也会有 "x" 这个符号,这是在编译时就确定的。但我们是在 x = 123 之前调用的 locals,所以此时符号 x 在 f_localsplus 中对应的值还是一个 NULL,没有指向一个合法的 PyObject。换句话说就是,知道里面存在局部变量 x,但是还没有来得及赋值。
然后在更新名字空间的时候,如果发现值是个 NULL,那么就把名字空间中该变量对应的键值对给删掉。
所以 bar 函数执行 locals()["x"] 的时候,会先获取名字空间,原本里面是有 "x": 1 这个键值对的。但因为赋值语句 x = 123 的存在,导致符号表里面存在 "x" 这个符号,但执行 locals() 的时候又尚未完成赋值,所以值为 NULL,于是又把这个键值对给删掉了。所以执行 locals()["x"] 的时候,出现了 KeyError。
因为局部名字空间体现的是局部变量的值,而调用 locals 的时候,局部变量 x 还没有被创建。所以 locals() 里面不应该存在 key 为 "x" 的键值对,于是会将它删除。
我们将名字空间打印一下:
def foo(): # 创建局部名字空间,并写入键值对 "x": 1 # 此时名字空间为 {"x": 1} exec("x = 1") # 获取名字空间,会进行更新 # 但当前不存在局部变量,所以名字空间仍是 {"x": 1} print(locals()) def bar(): # 创建局部名字空间,并写入键值对 "x": 1 # 此时名字空间为 {"x": 1} exec("x = 1") # 获取名字空间,会进行更新 # 由于里面存在局部变量 x,但尚未赋值 # 于是将字典中 key 为 "x" 的键值对给删掉 # 所以名字空间变成了 {} print(locals()) x = 123 foo() # {'x': 1} bar() # {}
上面代码中,局部变量的创建发生在 exec 之后,如果发生在 exec 之前也是相同的结果。
def foo(): exec("x = 2") print(locals()) foo() # {'x': 2} def bar(): x = 1 exec("x = 2") print(locals()) bar() # {'x': 1}
在 exec("x = 2") 执行之后,名字空间也变成了 {"x": 2}。但每次调用 locals,都会对字典进行更新,所以在 bar 函数里面获取名字空间的时候,又把 "x" 对应的 value 给更新回来了。
当然这是在变量冲突的情况下,会保存真实存在的局部变量的值。但如果不冲突,比如 bar 函数里面是 exec("y = 2"),那么 locals() 里面就会存在两个键值对。但只有 x 才是真正的局部变量,而 y 则不是。
将 exec("x = 2") 换成 locals()["x"] = 2 也是一样的效果,它们都是往局部名字空间中添加一个键值对,但不会创建一个局部变量。
薛定谔的猫
当 Python 中混进一只薛定谔的猫……,这是猫哥在 19 年更新的一篇文章,里面探讨的内容我们本文的主题是重叠的。猫哥在文章中举了几个疑惑重重的例子,看看用上面学到的内容能不能合理地解释。
# 例 0 def foo(): exec('y = 1 + 1') z = locals()['y'] print(z) foo() # 输出:2 # 例 1 def foo(): exec('y = 1 + 1') y = locals()['y'] print(y) foo() # 报错:KeyError: 'y'
以上是猫哥文章中举的示例,首先例 0 很简单,因为 exec 影响了所在的局部名字空间,里面存在 "y": 2 这个键值对。至于里面的变量 z 则不影响,因为我们获取的是 "y" 这个 key 对应的 value。
但例 1 则不同,因为 Python 在语法解析的时候发现了 y = ... 这样的赋值语句,那么它在编译的时候就知道函数里面存在 y 这个局部变量,并写入符号表中。既然符号表中存在,那么调用 locals 的时候就会对它进行更新。
而对 y 赋值是发生在调用 locals 之后,所以在调用 locals 的时候,y 的值还是一个 NULL,也就是变量还没有赋值。所以会将名字空间中的 "y": 2 这个键值对给删掉,于是报出 KeyError 错误。
再来看看猫哥文章的例 2:
# 例 2 def foo(): y = 1 + 1 y = locals()['y'] print(y) foo() # 2
locals() 是对真实存在的局部变量的一个拷贝,在调用 locals 之前 y 就已经创建好了。符号表里面有 "y",f_localsplus 里面有一个数值 2,所以调用 locals() 的时候,会得到 {"y": 2},因此函数执行正常。
猫哥文章的例 3:
# 例3 def foo(): exec('y = 1 + 1') boc = locals() y = boc['y'] print(y) foo() # KeyError: 'y'
这个例3 和例1 是一样的,只不过用变量 boc 将局部名字空间保存起来了。执行 exec 的时候,会创建局部名字空间,写入键值对 "y": 2。
但调用 locals 的时候,发现函数内部存在局部变量 y 并且还尚未赋值,于是又会将 "y": 2 这个键值对给删掉,因此 boc 变成了一个空字典。
在执行 y = boc["y"] 的时候会出现 KeyError。
猫哥文章的例 4:
# 例4 def foo(): boc = locals() exec('y = 1 + 1') y = boc['y'] print(y) foo() # 2
显然在调用 locals 的时候,会返回一个空字典,因为此时的局部变量都还没有赋值。但需要注意的是:boc 已经指向了局部名字空间(字典),而局部名字空间在一个函数里面也是唯一的。
所以 exec("y = 1 + 1") 执行之后,会往局部名字空间里面写入一个键值对,而变量 boc 指向的字典也会发生改变,因为是同一个字典,所以程序正常执行。
猫哥文章的例 5:
# 例5 def foo(): boc = locals() exec('y = 1 + 1') print(locals()) y = boc['y'] print(y) foo() # {'boc': {...}} # KeyError: 'y'
首先在执行 boc = locals() 之后,boc 会指向一个空字典,然后 exec 函数执行之后会往字典里面写入一个键值对 "y": 2。如果在 exec 执行之后,直接执行 y = boc["y"],那么代码是没有问题的,但问题是执行之前插入了一个 print(locals())。
我们说过,当调用 locals 的时候,会对名字空间进行更新,然后返回更新之后的名字空间。由于函数内部存在 y = ... 这样的赋值语句,所以符号表中就存在 "y" 这个符号,于是会进行更新。但更新的时候,发现 y 还没有被赋值,于是又将字典中的键值对 "y": 2 给删掉了。
由于局部名字空间只有一份,所以 boc 指向的字典也会发生改变,换句话说在 print(locals()) 之后,boc 就指向了一个空字典,因此出现 KeyError。
小结
以上我们就探讨了 local 名字空间相关的内容,它是真实存在的局部变量的一个拷贝,每当我们调用一次 locals,都会拷贝一次。
然后函数的局部变量都是静态存储的,编译时就已经确定,无法在运行时动态添加。我们往局部名字空间里面添加键值对,并不等价于创建局部变量。