# 初步了解
一条简单的赋值语句
>>> a = b |
我们所做的就是将 b 分配给 a,那么这会引出什么问题
- 名称与值关联意味着什么,什么是值
- CPython 如何为名称赋值,怎么获取值
- 所有变量都以相同的方式实现吗
为了运行 Python 代码,CPython 会将其编译为字节码 (byte code),所以我们首先来看看编译后的字节码
>>> dis.dis("a = b") | |
1 0 LOAD_NAME 0 (b) | |
2 STORE_NAME 1 (a) | |
4 LOAD_CONST 0 (None) | |
6 RETURN_VALUE |
CPython VM 使用值堆栈进行操作。从堆栈中弹出值,对它们执行某些操作,然后将计算结果推回堆栈。LOAD_NAME 和 STORE_NAME 最典型
- LOAD_NAME: 获取名称的值 b, 并将其压入堆栈
- STORE_NAME: 从堆栈中弹出值,并将名称 a 与该值关联起来
CPython 中所有操作码都是在一个巨大的 switch 语句中实现的,因此我们可以在其中了解 LOAD_NAME 或 STORE_NAME 是如何工作的
case TARGET(STORE_NAME): { | |
PyObject *name = GETITEM(names, oparg); | |
PyObject *v = POP(); | |
PyObject *ns = f->f_locals; | |
int err; | |
if (ns == NULL) { | |
_PyErr_Format(tstate, PyExc_SystemError, | |
"no locals found when storing %R", name); | |
Py_DECREF(v); | |
goto error; | |
} | |
if (PyDict_CheckExact(ns)) | |
err = PyDict_SetItem(ns, name, v); | |
else | |
err = PyObject_SetItem(ns, name, v); | |
Py_DECREF(v); | |
if (err != 0) | |
goto error; | |
DISPATCH(); | |
} |
- 名称是一个字符串,它们存储在名为 co_names 元组对象中,STORE_NAME 指令的参数不是名称,而是用于在 co_names 中查找名称的索引。VM 第一件事就是从中获取名称,并为其分配值
- VM 从堆栈中弹出该值
- 变量的值存储在 frame 对象中,frame 对象的 f_locals 就是局部变量,存储着它们的名称和值。VM 通过将名称与值关联起来。f_locals [name] = v
我们了解到
- Python 变量是映射到值的名称
- 名称的值是对 Python 对象的引用
LOAD_NAME 有点复杂,因为 VM 不仅在 f_locals 其中查找名称的值,还会在其它地方查找名称的值
case TARGET(LOAD_NAME): { | |
PyObject *name = GETITEM(names, oparg); | |
PyObject *locals = f->f_locals; | |
PyObject *v; | |
if (locals == NULL) { | |
_PyErr_Format(tstate, PyExc_SystemError, | |
"no locals when loading %R", name); | |
goto error; | |
} | |
if (PyDict_CheckExact(locals)) { | |
v = PyDict_GetItemWithError(locals, name); | |
if (v != NULL) { | |
Py_INCREF(v); | |
} | |
else if (_PyErr_Occurred(tstate)) { | |
goto error; | |
} | |
} | |
else { | |
v = PyObject_GetItem(locals, name); | |
if (v == NULL) { | |
if (!_PyErr_ExceptionMatches(tstate, PyExc_KeyError)) | |
goto error; | |
_PyErr_Clear(tstate); | |
} | |
} | |
if (v == NULL) { | |
v = PyDict_GetItemWithError(f->f_globals, name); | |
if (v != NULL) { | |
Py_INCREF(v); | |
} | |
else if (_PyErr_Occurred(tstate)) { | |
goto error; | |
} | |
else { | |
if (PyDict_CheckExact(f->f_builtins)) { | |
v = PyDict_GetItemWithError(f->f_builtins, name); | |
if (v == NULL) { | |
if (!_PyErr_Occurred(tstate)) { | |
format_exc_check_arg( | |
tstate, PyExc_NameError, | |
NAME_ERROR_MSG, name); | |
} | |
goto error; | |
} | |
Py_INCREF(v); | |
} | |
else { | |
v = PyObject_GetItem(f->f_builtins, name); | |
if (v == NULL) { | |
if (_PyErr_ExceptionMatches(tstate, PyExc_KeyError)) { | |
format_exc_check_arg( | |
tstate, PyExc_NameError, | |
NAME_ERROR_MSG, name); | |
} | |
goto error; | |
} | |
} | |
} | |
} | |
PUSH(v); | |
DISPATCH(); | |
} |
- 对于 STORE_NAME 操作码,VM 首先获取变量的名称
- VM 在局部变量的映射中查找名称的值: v = f_locals [name]
- 如果该名称不在 f_locals, VM 将在全局字典 f_globals 中查找该值,如果名称也不在 f_globals, 那么 VM 还会在 f_builtins 中查找,f_builtins 指向 builtins 模块的字典,其中包含内置类型、函数、异常和变量。如果该名称还不存在,则会放弃查找并抛出 NameError
- 如果 VM 找到该值,则将该值压入堆栈
VM 搜索值的方式具体有以下影响
- 我们经常使用到 builtin 字典中的 int、next、ValueError 和 None 等
- 如果我们对局部变量或全局变量使用内置名称,那么新变量将会覆盖内置变量
- 局部变量会隐藏同名的全局变量
由于我们需要对变量做的就是将它们与值关联并获取它们的值,因此,可以认为 STORE_NAME 和 LOAD_NAME 足以实现 Python 中的所有变量。
下面这个例子
x = 1 | |
def f(y, z): | |
def _(): | |
return z | |
return x + y + z |
该 f 函数必须加载变量 x、y, 并将与 z 相加返回结果
...... | |
7 12 LOAD_GLOBAL 0 (x) | |
14 LOAD_FAST 0 (y) | |
16 BINARY_ADD | |
18 LOAD_DEREF 0 (z) | |
20 BINARY_ADD | |
22 RETURN_VALUE |
可以看到,没有 LOAD_NAME 操作码,编译器生成 LOAD_GLOBAL 操作码加载 x, LOAD_FAST 加载 y, LOAD_DEREF 加载 z。为什么会产生不同的操作码,这里有两个重要的概念:命名空间和作用域
# 命名空间和范围
Python 程序由代码块组成,代码块是 VM 作为单元执行的一段代码,CPython 区分三种类型的代码块
- 模块
- 函数(推导式和 lambda 也是函数)
- 类定义
编译器为程序中的每个代码块创建一个 CodeObject, 它是描述代码块功能的结构,它包含块的字节码。为了执行代码对象,CPython 为其创建一个称为 FrameObject 的执行状态。除此之外,FrameObject 对象还包含 key-value 映射,如 f_locals、f_globals 和 f_builtins。这些映射称位名称空间。每个代码块都会引入一个名称空间,程序中相同名称可能引用不同命名空间中的不同变量
x = y = "I'm a variable in a global namespace" | |
def f(): | |
x = "I'm a local variable" | |
print(x) | |
print(y) | |
print(x) | |
print(y) | |
f() |
>>> I'm a variable in a global namespace | |
>>> I'm a variable in a global namespace | |
>>> I'm a local variable | |
>>> I'm a variable in a global namespace |
a = 1 | |
def f(): | |
b = 3 | |
return a + b |
这里名称 a 在两种情况下都指向相同的变量,从函数的角度看,它是一个全局变量,但从模块的角度看,它既是全局变量,又是局部变量。变量 b 是 f 函数的本地变量,它不存在于模块级别
如果该变量绑定在代码块中,则该变量视为代码块的本地变量,像这种赋值语句 a = 1 将名称 a 绑定到 1。然而,赋值语句并不是唯一绑定名称的唯一方法。Python 文档列出更多内容
由于名称的任何绑定都会让编译器认为该名称是本地名称,因此以下代码会抛异常
a = 1 | |
def f(): | |
a += 1 | |
return a | |
print(f()) |
$ python unbound_local.py | |
... | |
a += 1 | |
UnboundLocalError: local variable 'a' referenced before assignment |
该 a += 1 语句是一种赋值形式,因此编译器认为 a 是局部的,为了执行该操作,VM 尝试加载 a 值,结果失败了并设置异常,为了告诉编译器 a 是有赋值,但它是全局的,所以我们应该使用 global 语句,告诉编译器,你应该从 globals 开始找
a = 1 | |
def f(): | |
global a | |
a += 1 | |
return a |
同样,我们可以使用 nonlocal 语句告诉编译器,内嵌函数中绑定的名称引用上层函数中的变量
a = "I'm not used" | |
def f(): | |
def inner(): | |
nonlocal a | |
a += 1 | |
print(a) | |
a = 2 | |
inner() |
这是编译器的工作,用于分析代码块中名称的使用情况,考虑如 global 和 nonlocal 之类的语句,并生成正确的操作码来加载和存储值。编译器为名称生成哪个操作码取决于该名称的范围以及当前正在编译的代码块的类型。VM 以不同的方式执行不同的操作码,所有这些都是为了让 Python 变量按照它们的方式工作
CPython 总共使用四对加载 / 存储操作码和一对加载操作码
- LOAD_FAST 和 STORE_FAST
- LOAD_DEREF 和 STORE_DEREF
- LOAD_GLOBAL 和 STORE_GLOBAL
- LOAD_NAME 和 STORE_NAME
- LOAD_CLASSDEREF
# LOAD_FAST 和 STORE_FAST
编译器为函数的局部变量生成 LOAD_FAST 和 STORE_FAST
def f(x): | |
y = x | |
return y |
... | |
2 0 LOAD_FAST 0 (x) | |
2 STORE_FAST 1 (y) | |
3 4 LOAD_FAST 1 (y) | |
6 RETURN_VALUE |
该 y 是本地变量,因为它是由 f 函数内赋值绑定的,x 为本地变量,因为它作为其参数绑定
来看看 STORE_FAST 的代码
case TARGET(STORE_FAST): { | |
PREDICTED(STORE_FAST); | |
PyObject *value = POP(); | |
SETLOCAL(oparg, value); | |
FAST_DISPATCH(); | |
} |
SETLOCAL 是一个宏定义: fastlocals [oparg] = value, fastlocals 只是 FrameObject 中 f_localsplus 字段的简写。该字段是一个指向 Python 对象的指针数组。它存储局部变量、单元变量、自由变量和值堆栈的值。
在操作码为 STORE_NAME 时,VM 首先从 co_names 中获取名称,然后将该名称映射到堆栈顶部的值。它用作 f_locals 名称 - 值的映射,通常是字典。对于 STORE_FAST 操作码,VM 不需要获取名称。局部变量的数量可以由编译器静态计算,因此 VM 可以使用数组来存储它们的值。每个局部变量都可以与该数组的索引相关联。为了将名称映射到值,VM 只需将值存储在相应的索引中即可
# LOAD_DEREF 和 STORE_DEREF
在一种情况下,编译器不会为函数的本地变量生成 LOAD_FAST 和 STORE_FAST, 当在嵌套函数中使用变量时会发生这种情况
def f(): | |
b = 1 | |
def g(): | |
return b |
...... | |
Disassembly of <code object f at 0x1027c72f0, file "nested.py", line 1>: | |
2 0 LOAD_CONST 1 (1) | |
2 STORE_DEREF 0 (b) | |
3 4 LOAD_CLOSURE 0 (b) | |
6 BUILD_TUPLE 1 | |
8 LOAD_CONST 2 (<code object g at 0x1027c7240, file "nested.py", line 3>) | |
10 LOAD_CONST 3 ('f.<locals>.g') | |
12 MAKE_FUNCTION 8 (closure) | |
14 STORE_FAST 0 (g) | |
16 LOAD_CONST 0 (None) | |
18 RETURN_VALUE | |
Disassembly of <code object g at 0x1027c7240, file "nested.py", line 3>: | |
4 0 LOAD_DEREF 0 (b) | |
2 RETURN_VALUE |
编译器为单元变量和自由变量生成 LOAD_REDEF 和 STORE_DEREF 操作码。单元变量是嵌套函数中引用的变量。在上述代码中,b 是函数 f 的单元变量,因为它被 g 引用。从嵌套函数的角度来看,自由变量是单元变量。它是一个未绑定在嵌套函数中但在封闭函数。在代码中,b 是函数 g 的自由变量,因为它没有绑定在 g 中,而是绑定在 f 中
单元变量和自由变量的值存储在 f_localsplus 数组中,唯一的区别是,f_localsplus [index_of_cell_or_free_variable] 不是直接指向该值,而是指向包含该值的单元格对象
typedef struct { | |
PyObject_HEAD | |
PyObject *ob_ref; /* Content of the cell or NULL when empty */ | |
} PyCellObject; |
STORE_DEREF 操作码从堆栈中弹出值,获取 oparg 指定的变量的单元格,并将该单元格的 ob_ref 分配给弹出的值
case TARGET(STORE_DEREF): { | |
PyObject *v = POP(); | |
PyObject *cell = freevars[oparg]; | |
PyObject *oldobj = PyCell_GET(cell); | |
PyCell_SET(cell, v); | |
Py_XDECREF(oldobj); | |
DISPATCH(); | |
} |
LOAD_DEREF 操作码的工作原理是将单元格的内容推入堆栈
case TARGET(LOAD_DEREF): { | |
PyObject *cell = freevars[oparg]; | |
PyObject *value = PyCell_GET(cell); | |
if (value == NULL) { | |
format_exc_unbound(tstate, co, oparg); | |
goto error; | |
} | |
Py_INCREF(value); | |
PUSH(value); | |
DISPATCH(); | |
} |
在单元格中存储值是什么原因?这样做是为了将自由变量与相应单元变量连接起来。它们的值存储在不同框架对象的不同命名空间中,但存储在同一单元格中。VM 在创建封闭函数时将封闭函数的单元格传递给封闭函数。LOAD_CLOSURE 操作码将一个单元推入堆栈,MAKE_FUNCTION 操作码使用该单元为相应的自由变量创建一个函数对象。由于单元机制,当封闭函数重新分配单元变量时,封闭函数会看到重新分配
def f(): | |
def g(): | |
print(a) | |
a = 'assigned' | |
g() | |
a = 'reassigned' | |
g() | |
f() | |
$ python cell_reassign.py | |
>>> assigned | |
>>> reassigned |
反之亦然
def f(): | |
def g(): | |
nonlocal a | |
a = 'reassigned' | |
a = 'assigned' | |
print(a) | |
g() | |
print(a) | |
f() | |
$ python free_reassign.py | |
>>> assigned | |
>>> reassigned |
当我们调用函数时,CPython 会创建一个 FrameObject 来执行它,单元机制的好处是它允许避免将封闭函数的 FrameObject 及其所有引用保留在内存中。
# LOAD_GLOBAL 和 STORE_GLOBAL
编译器为函数中的全局变量生成 LOAD_GLOBAL 和 STORE_GLOBAL 操作码。如果变量被 global 声明或者未绑定在函数和任何封闭函数内(它既不是本地变量也不是自由变量),则该变量被认为是函数中的全局变量。
a = 1 | |
d = 1 | |
def f(): | |
b = 1 | |
def g(): | |
global d | |
c = 1 | |
d = 1 | |
return a + b + c + d |
变量 c 不是全局变量,因为它是 g 函数的本地变量。变量 b 不是全局变量,因为它是自由变量。变量 a 是全局变量,因为它既不是本地变量也不是自由变量。并且变量 d 是全局的,因为它显式的声明了 global
这时 STORE_GLOBAL 操作码的实现
case TARGET(STORE_GLOBAL): { | |
PyObject *name = GETITEM(names, oparg); | |
PyObject *v = POP(); | |
int err; | |
err = PyDict_SetItem(f->f_globals, name, v); | |
Py_DECREF(v); | |
if (err != 0) | |
goto error; | |
DISPATCH(); | |
} |
f_globals 字段是 FrameObject 的一個字典,它將全局名稱映射到它們的值。儅 CPython 為模塊創建 FrameObject 時,它會分配 g_globals 字典分配給模塊
$ python -q | |
>>> import sys | |
>>> globals() is sys.modules['__main__'].__dict__ | |
True |
当 VM 执行 MAKE_FUNCTION 操作码来创建新的函数对象时,它将该对象的 func_globals 字段分配给当前帧对象的 f_globals。当函数被调用时,VM 会为其创建一个新的框架对象,并将 f_globals 设置为 func_globals
LOAD_GLOBAL 的实现和 LOAD_NAME 的实现类似,但有两个例外:
- 它不会查找 f_locals 中的值
- 它使用缓存来减少查找时间
CPython 将结果缓存在 co_opcache 数组中的代码对象中。该数组存储指向_PyOpcache 结构的指针
typedef struct { | |
PyObject *ptr; /* Cached pointer (borrowed reference) */ | |
uint64_t globals_ver; /* ma_version of global dict */ | |
uint64_t builtins_ver; /* ma_version of builtin dict */ | |
} _PyOpcache_LoadGlobal; | |
struct _PyOpcache { | |
union { | |
_PyOpcache_LoadGlobal lg; | |
} u; | |
char optimized; | |
}; |
_PyOpcache_LoadGlobal 结构体的 ptr 字段指向 LOAD_GLOBAL 的实际结果。高速缓存按指令号维护。代码对象中的另一个数组称为 co_opcache_map,将字节码中的每条指令映射到其在 co_opcache 中的索引减一。如果指令不是 LOAD_GLOBAL,它会将该指令映射为 0,这意味着该指令永远不会被缓存。缓存的大小不会超过 254。如果字节码包含超过 254 条 LOAD_GLOBAL 指令,co_cocache_map 也会将多余的指令映射到 0
如果 VM 在执行 LOAD_GLOBAL 时在缓存中找到一个值,它会确保自上次查找该值以来 f_global 和 f_builtins 字典没有被修改。这是通过将 global_ver 和 builtins_ver 与字典的 ma_version_tag 进行比较来完成的。每次修改字典时,字典的 ma_version_tag 字典都会更改。更多详细信息,请参阅 PEP 509
如果 VM 在缓存中没有找到值,它会首先在 f_globals 中进行正常查找,然后在 f_builtins 中进行查找。如果它最终找到了一个值,它会记住两个字典的当前 ma_version_tag 并将该值推入堆栈。
# LOAD_NAME 和 STORE_NAME (and LOAD_CLASSDEREF)
编译器为类定义创建代码对象,就像为模块和函数创建代码对象一样。有趣的是,编译器几乎总是为类体内的变量生成 LOAD_NAME 和 STORE_NAME 操作码。此规则有两个罕见的例外:自由变量和显式声明为全局的变量
VM 以不同的方式执行 *_NAME 和 *_FAST 操作码。因此,变量在类主体中的工作方式与函数中的工作方式不同
class A: | |
print("This code is executed") | |
$ python class_local.py | |
>>> global | |
>>> local |
第一次加载时,VM 从 f_globals 加载 x 变量的值,然后它将新值存储在 f_locals 中,并在第二次加载时从那里加载它。如果它是一个函数,那么当我们调用它时,我们会得到 UnboundLocalError: local variable ‘x’ referenced before assignment when we call it,因为编译器认为 x 变量是 C 的局部变量
类和函数的命名空间如何相互作用?当我们将函数放入类中,该函数看不到绑定在类名称空间中的名称
class D: | |
x = 1 | |
def method(self): | |
print(x) | |
D().method() | |
$ python func_in_class.py | |
... | |
>>> NameError: name 'x' is not defined |
这是因为 VM 在执行类定义时使用 STORE_NAME 存储 x 的值,并在执行函数时尝试使用 LOAD_GLOBAL 加载它。然而,当我们将类定义中放入函数时,单元机制的工作方式就像将函数放入函数中一样
def f(): | |
x = "I'm a cell variable" | |
class B: | |
print(x) | |
f() | |
$ python class_in_func.py | |
>>> I'm a cell variable |
但还是有区别的。编译器生成 LOAD_CLASSDEREF 操作码而不是 LOAD_DEREF 来加载 x 的值,dis 模块有解释 LOAD_CLASSDEREF 的作用
与 LOAD_DEREF 非常类似,但在查询单元格之前首先检查本地字典。这用于在类中加载自由变量
那么,为什么它首先检查 locals 字典?对于函数,编译器可以确定变量是否为本地变量。对于类,编译器无法确定。这是因为 CPython 有元类,元类可以通过实现__prepare__方法为类准备一个非空的局部字典
# 总结
确定变量的范围
- 如果变量声明为全局变量,则它是显式全局变量
- 如果变量声明为局部变量,则它是自由变量
- 如果变量绑定在当前代码块内,则它是局部变量
- 如果变量绑定在不是类定义的封闭代码块中,则它是自由变量。否则,它是一个隐式全局变量
更新范围
- 如果变量是本地变量并且在封闭的代码块中是空闲的,则它是单元格变量
决定生成哪个操作码
- 如果变量是单元变量或自由变量,则产生 *_DEREF 操作码;如果当前代码块是类定义,则生成 LOAD_CLASSDEREF 操作码来加载值
- 如果变量是局部变量并且当前代码块是函数,则生成 *_FAST 操作码
- 如果变量是显式全局变量或者隐式全局变量并且当前代码块是函数,则生成 *_GLOBAL 操作码,否在,生成 *_NAME 操作码