楔子
前面我们剖析了字节码的执行流程,本来应该接着介绍一些常见指令的,但因为有几个指令涉及到了局部变量,所以我们单独拿出来说。与此同时,我们还要再度考察一下 local 名字空间,它的背后还隐藏了很多内容。
我们知道函数的参数和函数内部定义的变量都属于局部变量,均是通过静态方式访问的。
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 名字空间(局部名字空间)呢?我们往下看。
如何访问(创建)一个局部变量
之前我们说过 Python 变量的访问是有规则的,会按照本地、闭包、全局、内置的顺序去查找,也就是 LEGB 规则,所以在查找变量时,local 名字空间应该是第一选择。
但不幸的是,虚拟机在为调用的函数创建栈帧对象时,这个至关重要的 local 名字空间并没有被创建。因为栈帧的 f_locals 字段和 f_globals 字段分别指向了局部名字空间和全局名字空间,而创建栈帧时 f_locals 被初始化成了 NULL,所以并没有创建局部名字空间。
我们通过源码来进行验证,不过要先补充一个知识点,就是当调用一个 Python 函数时,底层会调用哪些 C 函数呢?
我们看一下源码:
// Objects/call.c
/*
* Python 函数也是一个对象,当调用 Python 函数时
* 底层会将 Python 函数对象作为参数,调用 _PyFunction_Vectorcall
* 关于函数,我们后续会剖析
*/
PyObject *
_PyFunction_Vectorcall(PyObject *func, PyObject* const* stack,
size_t nargsf, PyObject *kwnames)
{
// ...
// 参数 func 指向函数对象,它内部的 func_code 指向 PyCodeObject 对象
// 如果 co_flags & CO_OPTIMIZED 为真,表示 PyCodeObject 是被优化过的
// 那么对应的函数在调用时,会静态查找本地局部变量
if (((PyCodeObject *)f->func_code)->co_flags & CO_OPTIMIZED) {
// 在这种情况下,会给 _PyEval_Vector 的第三个参数传递 NULL
return _PyEval_Vector(tstate, f, NULL, stack, nargs, kwnames);
}
// ...
}
// Python/ceval.c
/*
* 创建栈帧,调用 _PyEval_EvalFrame,最终执行帧评估函数
* 注意该函数的第三个参数,显然它表示局部名字空间
* 而 _PyFunction_Vectorcall 在调用时传递的是 NULL
*/
PyObject *
_PyEval_Vector(PyThreadState *tstate, PyFunctionObject *func,
PyObject *locals,
PyObject* const* args, size_t argcount,
PyObject *kwnames)
{
// ...
// 执行帧评估函数之前,要先创建栈帧
// 这个过程由 _PyEvalFramePushAndInit 负责
_PyInterpreterFrame *frame = _PyEvalFramePushAndInit(
tstate, func, locals, args, argcount, kwnames);
// ...
return _PyEval_EvalFrame(tstate, frame, 0);
}
/*
* 在当前栈帧之上创建新的栈帧,并推入虚拟机为其准备的 C Stack 中
*/
static _PyInterpreterFrame *
_PyEvalFramePushAndInit(PyThreadState *tstate, PyFunctionObject *func,
PyObject *locals, PyObject* const* args,
size_t argcount, PyObject *kwnames)
{
// ...
// 栈帧创建之后,调用 _PyFrame_Initialize,进行初始化
_PyFrame_Initialize(frame, func, locals, code, 0);
// ...
}
// Include/internal/pycore_frame.h
/*
* 对 frame 进行初始化
*/
staticinlinevoid
_PyFrame_Initialize(
_PyInterpreterFrame *frame, PyFunctionObject *func,
PyObject *locals, PyCodeObject *code, int null_locals_from)
{
frame->f_funcobj = (PyObject *)func;
frame->f_code = (PyCodeObject *)Py_NewRef(code);
frame->f_builtins = func->func_builtins;
frame->f_globals = func->func_globals;
// 将 f_locals 字段初始化为参数 locals
// 而参数 locals 是从 _PyFunction_Vectorcall 一层层传过来的
// 由于 _PyFunction_Vectorcall 传的是 NULL
// 所以栈帧的 f_locals 字段最终会被初始化为 NULL
frame->f_locals = locals;
// ...
}
所以我们验证了在调用函数时,栈帧的局部名字空间确实被初始化为 NULL,当然也明白了 C 函数的调用链路。
我们用 Python 代码演示一下:
import inspect
# 模块的栈帧
frame = inspect.currentframe()
# 对于模块而言,局部名字空间和全局名字空间是同一个字典
print(frame.f_locals is frame.f_globals) # True
# 当然啦,局部名字空间和全局名字空间也可以通过内置函数获取
print(
frame.f_locals is locals() is frame.f_globals is globals()
) # True
# 但对于函数而言就不一样了
def foo():
name = "古明地觉"
return inspect.currentframe()
frame = foo()
# global 名字空间全局唯一
# 无论是获取栈帧的 f_globals,还是调用 globals()
# 得到的都是同一份字典
print(frame.f_globals is globals()) # True
# 但每个函数都有自己独立的局部名字空间
print(frame.f_locals) # {'name': '古明地觉'}
# 咦,不是说局部名字空间被初始化为 NULL 吗?
# 那么在 Python 里面获取的话,结果应该是个 None 才对啊
# 关于这一点,我们稍后会解释
总之对于函数而言,在创建栈帧时,它的 f_locals 被初始化为 NULL。那么问题来了,局部变量到底存储在什么地方呢?当然,由于变量只是一个名字(符号),而局部变量的名字都存储在符号表中,所以更严谨的说法是,局部变量的值存储在什么地方?
在介绍虚拟机执行字节码的时候我们说过,当函数被调用时,虚拟机会为其创建一个栈帧。栈帧是虚拟机的执行环境,包含了执行时所依赖的上下文,而栈帧内部有一个字段叫 f_localsplus,它是一个数组。
这个数组虽然是一段连续内存,但在逻辑上被分成了 4 份,其中局部变量便存储在 f_localsplus 的第一份空间中。现在我们明白了,局部变量是静态存储在数组中的。
我们举个例子。
def foo(a, b):
c = a + b
print(c)
它的字节码如下:
注意里面的 LOAD_FAST 和 STORE_FAST,这两个指令对应的逻辑如下。
TARGET(LOAD_FAST) {
PyObject *value;
#line 192 "Python/bytecodes.c"
// 通过宏 GETLOCAL 获取局部变量的值
value = GETLOCAL(oparg);
assert(value != NULL);
Py_INCREF(value);
#line 90 "Python/generated_cases.c.h"
// 将值压入运行时栈,等价于 PUSH(value)
STACK_GROW(1);
stack_pointer[-1] = value;
DISPATCH();
}
TARGET(STORE_FAST) {
// 获取栈顶元素
PyObject *value = stack_pointer[-1];
#line 209 "Python/bytecodes.c"
// 通过宏 SETLOCAL 创建局部变量
SETLOCAL(oparg, value);
#line 124 "Python/generated_cases.c.h"
// 将 stack_pointer 向栈底移动一个位置,即弹出栈顶元素
// 如果和第一行组合起来的话,等价于 TOP()
STACK_SHRINK(1);
DISPATCH();
}
所以 LOAD_FAST 和 STORE_FAST 分别负责加载和创建局部变量,而核心就是里面的两个宏:GETLOCAL、SETLOCAL。
// Python/ceval_macros.h
#define GETLOCAL(i) (frame->localsplus[i])
#define SETLOCAL(i, value) do { PyObject *tmp = GETLOCAL(i); \
GETLOCAL(i) = value; \
Py_XDECREF(tmp); } while (0)
/* 这里额外再补充一个关于 C 语言的知识点
* 我们看到宏 SETLOCAL 展开之后的结果是 do {...} while (0)
* do while 循环会先执行 do 里面的循环体,然后再判断条件是否满足
* 因此从效果上来说,执行 do {...} while (0) 和直接执行 ... 是等价的
* 那么问题来了,既然效果等价,为啥还要再套一层 do while 呢
* 其实原因很简单,如果宏在展开之后会生成多条语句,那么这些语句要成为一个整体
* 另外由于 C 程序的语句要以分号结尾,所以在调用宏时,我们也会习惯性地在结尾加上分号
* 因此我们希望有这样一种结构,能同时满足以下要求:
* 1)可以将多条语句包裹起来,作为一个整体;
* 2)程序的语义不能发生改变;
* 3)在语法上,要以分号结尾;
* 显然 do while 完美满足以上三个要求,只需将 while 里的条件设置为 0 即可
* 并且当编译器看到 while (0) 时,也会进行优化,去掉不必要的循环控制结构
* 因此以后看到 do {...} while (0) 时,不要觉得奇怪,这是宏的一个常用技巧
*/
我们看到操作局部变量,就是在基于索引操作数组 f_localsplus,显然这个过程比操作字典要快。尽管字典是经过高度优化的,但显然再怎么优化,也不可能快过数组的静态操作。
所以此时我们对局部变量的藏身之处已经了然于心,它们就存放在栈帧的 f_localsplus 字段中,而之所以没有使用 local 名字空间的原因也很简单。因为函数内部的局部变量在编译时就已经确定了,个数是不会变的,因此编译时也能确定局部变量占用的内存大小,以及访问局部变量的字节码指令应该如何访问内存。
def foo(a, b):
c = a + b
print(c)
print(
foo.__code__.co_varnames
) # ('a', 'b', 'c')
比如变量 c 位于符号表中索引为 2 的位置,这在编译时就已确定。
当创建变量 c 时,只需修改数组 f_localsplus 中索引为 2 的元素即可。
当访问变量 c 时,只需获取数组 f_localsplus 中索引为 2 的元素即可。
这个过程是基于数组索引实现的静态查找,所以操作局部变量和操作全局变量有着异曲同工之妙。操作全局变量本质上是基于 key 操作字典的 value,其中 key 是变量的名称,value 是变量的值;而操作局部变量本质上是基于索引操作数组 f_localsplus 的元素,这个索引就是变量名在符号表中的索引,对应的数组元素就是变量的值。
所以我们说 Python 的变量其实就是个名字,或者说符号,到这里是不是更加深刻地感受到了呢?
但对于局部变量来说,如果想实现静态查找,显然要满足一个前提:变量名在符号表中的索引和与之绑定的值在 f_localsplus 中的索引必须是一致的。毫无疑问,两者肯定是一致的,并且索引是多少在编译阶段便已经确定,会作为指令参数保存在字节码指令序列中。
好,到此可以得出结论,虽然虚拟机为函数实现了 local 名字空间(初始为 NULL),但在操作局部变量时却没有使用它,原因就是为了更高的效率。当然还有所谓的 LEGB,都说变量查找会遵循这个规则,但我们心里清楚,局部变量其实是静态访问的,不过完全可以按照 LEGB 的方式来理解。
解密 local 名字空间
先来看一下全局名字空间:
x = 1
def foo():
globals()["x"] = 2
foo()
print(x) # 2
global 空间全局唯一,在 Python 层面上就是一个字典,在任何地方操作该字典,都相当于操作全局变量,即使是在函数内部。
因此在执行完 foo() 之后,全局变量 x 就被修改了。但 local 名字空间也是如此吗?我们尝试一下。
def foo():
x = 1
locals()["x"] = 2
print(x)
foo() # 1
我们按照相同的套路,却并没有成功,这是为什么?原因就是上面解释的那样,函数内部有哪些局部变量在编译时就已经确定了,查询的时候是从数组 f_localsplus 中静态查找的,而不是从 local 名字空间中查找。
然后我们打印一下 local 名字空间,看看里面都有哪些内容。
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 位于符号表中索引为 1 的位置,那么执行 age = 17 时,就会将 17 放在 f_localsplus 中索引为 1 的位置。
由于符号 gender 位于符号表中索引为 2 的位置,那么执行 gender = "female" 时,就会将 "female" 放在 f_localsplus 中索引为 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 名字空间是怎么创建的了,也熟悉了它的特性,下面通过源码来看一下它的构建过程。
// Python/bltinmodule.c
static PyObject *
builtin_locals_impl(PyObject *module)
{
// Python 内置函数的源码实现位于 bltinmodule.c 中
// 这里又调用了 _PyEval_GetFrameLocals
return _PyEval_GetFrameLocals();
}
// Python/ceval.c
PyObject *
_PyEval_GetFrameLocals(void)
{
PyThreadState *tstate = _PyThreadState_GET();
_PyInterpreterFrame *current_frame = _PyThreadState_GetFrame(tstate);
if (current_frame == NULL) {
_PyErr_SetString(tstate, PyExc_SystemError, "frame does not exist");
return NULL;
}
// 调用了 _PyFrame_GetLocals
return _PyFrame_GetLocals(current_frame, 1);
}
所以核心逻辑位于 _PyFrame_GetLocals 函数中,来看一下它的逻辑。
// Object/frameobject.c
PyObject *
_PyFrame_GetLocals(_PyInterpreterFrame *frame, int include_hidden)
{
// 获取局部名字空间
PyObject *locals = frame->f_locals;
// 如果为 NULL,那么创建一个新字典,作为名字空间
// 所以局部名字空间只会创建一次,后续不会再创建
if (locals == NULL) {
locals = frame->f_locals = PyDict_New();
if (locals == NULL) {
return NULL;
}
}
PyObject *hidden = NULL;
// 在 Include/internal/pycore_code.h 里面有 4 个宏
/* #define CO_FAST_HIDDEN 0x10
* #define CO_FAST_LOCAL 0x20
* #define CO_FAST_CELL 0x40
* #define CO_FAST_FREE 0x80
*/
// 它们分别对应隐藏变量、局部变量、cell 变量、free 变量
// 所谓隐藏变量,指的就是解析式里的临时变量,比如列表解析式
// 解析式具有独立的作用域,里面的临时变量不会污染外部的作用域
// 所以一般我们也不会关注这些隐藏变量,locals() 也不会返回它
// 但如果你真的关注,那么可以将 include_hidden 指定为真
// 那么调用 locals() 时,这些隐藏变量也会一块儿返回
if (include_hidden) {
// 单独创建一个字典,负责保存隐藏变量
hidden = PyDict_New();
if (hidden == NULL) {
return NULL;
}
}
// 初始化 free 变量,这个和闭包有关
// 关于闭包,等剖析完函数之后会说,这里暂时先不关注
frame_init_get_vars(frame);
PyCodeObject *co = frame->f_code;
// co_nlocalsplus 等于局部变量、cell 变量、free 变量的个数之和
// 这些变量都要拷贝到 local 名字空间中
for (int i = 0; i < co->co_nlocalsplus; i++) {
PyObject *value;
// 获取 f_localsplus[i],在函数内部会对 value 进行修改
if (!frame_get_var(frame, co, i, &value)) {
continue;
}
// f_localsplus[i] 对应局部名字空间的 value
// 那么 co_localsplusnames[i] 显然对应局部名字空间的 key
// 估计有人已经忘记 co_localsplusnames 字段的含义了,我们再解释一下
/* co_localsplusnames:包含所有局部变量、cell 变量、free 变量的名称
* co_nlocalsplus:co_localsplusnames 的长度,或者说这些变量的个数之和
* co_varnames:包含所有局部变量的名称,co_nlocals:局部变量的个数
* co_cellvars:包含所有 cell 变量的名称,co_ncellvars:cell 变量的个数
* co_freevars:包含所有 free 变量的名称,co_nfreevars:free 变量的个数
* 因此不难得出它们之间的关系:
* co_localsplusnames = co_varnames + co_cellvars + co_freevars
* co_nlocalsplus = co_nlocals + co_ncellvars + co_nfreevars
*/
// 所以 co_localsplusnames 也是符号表,并且是 co_varnames 的超集
PyObject *name = PyTuple_GET_ITEM(co->co_localsplusnames, i);
// 到此局部名字空间的 key 和 value 便有了,但还要做一个判断
_PyLocals_Kind kind = _PyLocals_GetKind(co->co_localspluskinds, i);
// 如果变量的类型是隐藏变量,那么添加到 hidden 中
// 所以 co_localsplusnames 其实还包含了隐藏变量的名称
// 但我们基本不会遇到这种情况,因此关于隐藏变量直接忽略掉即可
if (kind & CO_FAST_HIDDEN) {
if (include_hidden && value != NULL) {
if (PyObject_SetItem(hidden, name, value) != 0) {
goto error;
}
}
continue;
}
// 如果不是隐藏变量,那么拷贝到 locals 中,但这里有一个判断很重要
// 当 value 为 NULL 时,如果 key 已存在,那么会将它删掉
// 关于这里的玄机,稍后会解释
if (value == NULL) {
if (PyObject_DelItem(locals, name) != 0) {
if (PyErr_ExceptionMatches(PyExc_KeyError)) {
PyErr_Clear();
}
else {
goto error;
}
}
}
// 到这里说明 value 指向了一块合法的内存
// 也就是变量名和变量值已经完成了绑定,那么将它们添加到 locals 中
else {
if (PyObject_SetItem(locals, name, value) != 0) {
goto error;
}
}
// 继续遍历下一个符号
}
// 隐藏变量保存在 hidden 中,它不会污染 f_locals
if (include_hidden && PyDict_Size(hidden)) {
// 创建一个新字典
PyObject *innerlocals = PyDict_New();
if (innerlocals == NULL) {
goto error;
}
// 合并 locals
if (PyDict_Merge(innerlocals, locals, 1) != 0) {
Py_DECREF(innerlocals);
goto error;
}
// 合并 hidden
if (PyDict_Merge(innerlocals, hidden, 1) != 0) {
Py_DECREF(innerlocals);
goto error;
}
// 重新赋值给 locals,所以返回的结果会包含 hidden 里的键值对
// 但 f_locals 里面是没有隐藏变量的
locals = innerlocals;
}
else {
Py_INCREF(locals);
}
Py_CLEAR(hidden);
// 返回 locals
return locals;
error:
Py_XDECREF(hidden);
return NULL;
}
所以逻辑非常简单,如果不考虑隐藏变量(也不需要考虑),那么整个过程就是我们刚才说的:遍历符号表和 f_localsplus,将变量名和变量值组成键值对拷贝到字典中。
但里面有一处细节非常关键。
当变量值为 NULL 时,说明在获取名字空间时,该变量还没有被赋值。要是此时变量已经在局部名字空间中,那么会将它从名字空间中删掉。这一处非常关键,在介绍 exec 的时候你就会明白。
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 也是全局变量。
以上几个例子都比较简单,接下来我们开始上强度了。
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 对象的符号表中,这是由语法规则所决定的;
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 中混进一只薛定谔的猫……,这是《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 这个键值对,所以 locals()["y"] 会返回 2。
但例 1 则不同,因为 Python 在语法解析的时候发现了 y = ... 这样的赋值语句,那么它在编译的时候就知道函数里面存在 y 这个局部变量,并写入符号表中。既然符号表中存在,那么调用 locals 的时候就会写入到名字空间中。但问题是变量 y 的值是多少呢?由于对 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 名字空间的关系。
局部变量在编译时就已经确定,所以会采用数组静态存储,并且在整个作用域内都是可见的。
f_localsplus 的内存被分成了四份,局部变量的值便存储在第一份空间中。
局部名字空间是对真实存在的局部变量的拷贝,调用 locals() 时,会遍历得到每一个符号和与之绑定的值,然后拷贝到局部名字空间。
如果遍历时发现变量值为 NULL,这就说明获取名字空间时,该变量尚未赋值,那么要将它从名字空间中删掉。