CPython中的变量如何实现¶
考虑 Python 中的一个简单赋值语句:
a = b
这句话的含义可能看起来微不足道。我们在这里所做的是取名称b
的值并将其分配给名称a
,但我们真的这样做了吗?这是一个模棱两可的解释,
引发了很多问题:
- 名称与值相关联是什么意思?什么是值?
- CPython如何为名称赋值?获取值?
- 所有变量都以相同的方式实现吗?
今天我们将回答这些问题并了解变量是如何在CPython中实现的,变量是编程语言的重要方面。
注意:在这篇文章中,我指的是CPython 3.9。随着CPython的发展,一些实现细节肯定会发生变化。我将尝试跟踪重要更改并添加更新说明。
开始¶
我们应该从哪里开始调查?我们从前面的部分知道,要运行Python代码,CPython会将其编译为字节码,所以让我们从a = b
编译为的字节码开始:
$ echo 'a = b' | python -m dis
1 0 LOAD_NAME 0 (b)
2 STORE_NAME 1 (a)
...
上次我们了解到CPython VM使用值堆栈进行操作。典型的字节码指令从堆栈中弹出值,对它们进行处理并将计算结果推回到堆栈上。LOAD_NAME
和
STORE_NAME
指令在这方面是典型的。这是他们在我们的例子中所做的:
LOAD_NAME
获取名称b
的值并将其压入堆栈。STORE_NAME
从堆栈中弹出值并将名称a
与该值相关联。
上次我们还了解到所有操作码都是在Python/ceval.c
中的一个巨大的
switch
语句中实现的,因此我们可以通过研究该switch
的相应情况来了解LOAD_NAME
和STORE_NAME
操作码是如何工作的。让我们从
STORE_NAME
操作码开始,因为我们需要将名称与某个值相关联,然后才能获得该名称的值。这是执行STORE_NAME
操作码的case
块:
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
的元组中的代码对象中。names
变量只是co_names
的简写。STORE_NAME
指令的参数不是名称, 而是用于在co_names
中查找名称的索引。VM 做的第一件事是从co_names
获取名称,它将为其分配一个值。 - VM 从堆栈中弹出值。
- 变量的值存储在帧对象中。帧对象的
f_locals
字段是从局部变量的名称到它们的值的映射。 VM 通过设置f_locals[name] = v
将名称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;
}
// look up the value in `f->f_locals`
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);
}
}
// look up the value in `f->f_globals` and `f->f_builtins`
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
中查找值。frame
对象的f_builtins
字段指向builtins
模块的字典,其中包含内置类型、函数、异常和常量。如果名称不存在,VM放弃并设置NameError
异常。 - 如果VM找到该值,它会将值压入堆栈。
VM 搜索值的方式具有以下影响:
- 我们总是可以使用内置字典中的名称,例如
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
的值以将它们相加并返回结果。请注意编译器生成哪些操作码来执行此操作:
$ python -m dis global_fast_deref.py
...
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区分了三种类型的代码块:
- 模块
- 函数(推导式和 lambdas 也是函数)
- 类定义。
编译器为程序中的每个代码块创建一个代码对象。代码对象是一种描述代码块功能的结构。特别是,它包含一个块的字节码。为了执行代码对象,
CPython为其创建了一个称为帧对象的执行状态。除此之外,帧对象还包含名称-值映射,例如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()
$ python namespaces.py
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
另一个重要的概念是范围的概念。以下是Python文档对此的说明:
作用域是Python程序的文本区域,可以直接访问命名空间。这里的“可直接访问”意味着对名称的非限定引用尝试在名称空间中查找名称。
我们可以将作用域视为名称的一个属性,它告诉我们该名称的值存储在哪里。范围的示例是本地范围。名称的范围是相对于代码块的。下面的例子说明了这一点:
a = 1
def f():
b = 3
return a + b
这里,名称a
在两种情况下都指代相同的变量。从函数的角度来看,它是一个全局变量,但从模块的角度来看,它既是全局的又是局部的。变量b
是函数f
的局部变量,但它根本不存在于模块级别。
如果该变量绑定在该代码块中,则该变量被认为是该代码块的局部变量。像a = 1
这样的赋值语句将名称a
绑定到1。但是,赋值语句并不是绑定名称的唯一方法。
Python文档列出了更多内容:
The following constructs bind names: formal parameters to functions, import statements, class and function definitions (these bind the class or function name in the defining block), and targets that are identifiers if occurring in an assignment, for loop header, or after as in a with statement or except clause. The import statement of the form from ... import * binds all names defined in the imported module, except those beginning with an underscore. This form may only be used at the module level.
因为名称的任何绑定都会使编译器认为该名称是本地名称,所以以下代码会引发异常:
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
语句:
a = 1
def f():
global a
a += 1
print(a)
f()
$ python global_stmt.py
2
类似地,我们可以使用nonlocal
语句告诉编译器绑定在封闭(嵌套)函数中的名称引用封闭函数中的变量:
a = "I'm not used"
def f():
def g():
nonlocal a
a += 1
print(a)
a = 2
g()
f()
$ python nonlocal_stmt.py
3
这是编译器的工作,用于分析代码块中名称的使用情况,考虑全局和非局部语句,并生成正确的操作码来加载和存储值。通常, 编译器为名称生成的操作码取决于该名称的范围和当前正在编译的代码块的类型。VM以不同的方式执行不同的操作码。所有这些都是为了让Python 变量按照它们的方式工作。
CPython总共使用四对加载/存储操作码和另外一个加载操作码:
LOAD_FAST
和STORE_FAST
LOAD_DEREF
和STORE_DEREF
LOAD_GLOBAL
和STORE_GLOBAL
LOAD_NAME
和STORE_NAME
;和LOAD_CLASSDEREF
。
让我们弄清楚它们做了什么以及为什么CPython需要所有这些。
LOAD_FAST
和STORE_FAST
¶
编译器为函数的局部变量生成LOAD_FAST
和STORE_FAST
操作码。下面是一个例子:
def f(x):
y = x
return y
$ python -m dis fast_variables.py
...
2 0 LOAD_FAST 0 (x)
2 STORE_FAST 1 (y)
3 4 LOAD_FAST 1 (y)
6 RETURN_VALUE
y
变量是f
的局部变量,因为它通过赋值绑定在f
中。 x
变量是f
的局部变量,因为它被绑定在f
中 作为其参数。
让我们看看执行STORE_FAST
操作码的代码:
case TARGET(STORE_FAST): {
PREDICTED(STORE_FAST);
PyObject *value = POP();
SETLOCAL(oparg, value);
FAST_DISPATCH();
}
SETLOCAL()
是一个本质上扩展为fastlocals[oparg] = value
的宏。fastlocals
变量只是帧对象
f_localsplus
字段的简写。该字段是指向Python对象的指针数组。它存储局部变量、单元变量、
自由变量和值堆栈的值。上次我们了解到f_localsplus
数组是用来存储值栈的。在这篇文章的下一部分中,
我们将看到它如何用于存储单元格和自由变量的值。现在,我们对用于局部变量的数组的第一部分感兴趣。
我们已经看到,在STORE_NAME
操作码的情况下,VM首先从co_names
获取名称,然后将该名称映射到堆栈顶部的值。
它使用f_locals
作为名称-值映射,通常是一个字典。在STORE_FAST
操作码的情况下,VM
不需要获取名称。局部变量的数量可以由编译器静态计算,因此VM可以使用数组来存储它们的值。
每个局部变量都可以与该数组的索引相关联。要将名称映射到值,VM只需将值存储在相应的索引中。
VM不需要获取函数本地变量的名称来加载和存储它们的值。尽管如此,它将这些名称存储在co_varnames
元组中的函数代码对象中。
为什么?名称对于调试和错误消息是必需的。它们也被诸如dis
之类的工具使用,它读取co_varnames
以在括号中显示名称:
2 STORE_FAST 1 (y)
CPython提供了locals()
内置函数,该函数以字典的形式返回当前代码块的本地命名空间。VM没有保留这样的函数字典,但它可以通过将co_varnames
中的
键映射到f_localsplus
中的值来动态构建一个。
LOAD_FAST
操作码只是将f_localsplus[oparg]
压入堆栈:
case TARGET(LOAD_FAST): {
PyObject *value = GETLOCAL(oparg);
if (value == NULL) {
format_exc_check_arg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(co->co_varnames, oparg));
goto error;
}
Py_INCREF(value);
PUSH(value);
FAST_DISPATCH();
}
LOAD_FAST
和STORE_FAST
操作码仅出于性能原因而存在。它们被称为*_FAST
,因为VM使用数组进行映射,这比字典工作得更快。速度增益是多少?
让我们来衡量STORE_FAST
和STORE_NAME
之间的差异。以下代码将变量i
的值存储了1亿次:
for i in range(10**8):
pass
如果我们把它放在一个模块中,编译器会产生STORE_NAME
操作码。如果我们把它放在一个函数中,编译器会产生STORE_FAST
操作码。让我们同时进行并比较运行时间:
import time
# measure STORE_NAME
times = []
for _ in range(5):
start = time.time()
for i in range(10**8):
pass
times.append(time.time() - start)
print('STORE_NAME: ' + ' '.join(f'{elapsed:.3f}s' for elapsed in sorted(times)))
# measure STORE_FAST
def f():
times = []
for _ in range(5):
start = time.time()
for i in range(10**8):
pass
times.append(time.time() - start)
print('STORE_FAST: ' + ' '.join(f'{elapsed:.3f}s' for elapsed in sorted(times)))
f()
$ python fast_vs_name.py
STORE_NAME: 4.536s 4.572s 4.650s 4.742s 4.855s
STORE_FAST: 2.597s 2.608s 2.625s 2.628s 2.645s
STORE_NAME
和STORE_FAST
实现的另一个不同在理论上可能会影响这些结果。STORE_FAST
操作码的case
块以FAST_DISPATCH()
宏结束,
这意味着VM在执行STORE_FAST
指令后立即转到下一条指令。STORE_NAME
操作码的case
块以DISPATCH()
宏结束,这意味着VM
可能会进入评估循环的开始。
在评估循环开始时,VM检查它是否必须暂停字节码执行,例如,释放GIL或处理信号。我在STORE_NAME
的case
块中用FAST_DISPATCH()
替换了DISPATCH()
宏,重新编译了CPython并得到了类似的结果。所以,时间上的差异确实应该解释为:
- 获得名字的额外步骤;和
- 字典比数组慢的事实。
LOAD_DEREF
和STORE_DEREF
¶
在一种情况下,编译器不会为函数的局部变量生成LOAD_FAST
和STORE_FAST
操作码。当在嵌套函数中使用变量时会发生这种情况。
def f():
b = 1
def g():
return b
$ python -m dis nested.py
...
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_DEREF
和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]; // freevars = f->f_localsplus + co->co_nlocals
PyObject *oldobj = PyCell_GET(cell);
PyCell_SET(cell, v); // expands to ((PyCellObject *)(cell))->ob_ref = 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
我们真的需要细胞机制来实现这种行为吗?我们不能只使用封闭的命名空间来加载和存储自由变量的值吗?是的,我们可以,但请考虑以下示例:
def get_counter(start=0):
def count():
nonlocal c
c += 1
return c
c = start - 1
return count
count = get_counter()
print(count())
print(count())
$ python counter.py
0
1
回想一下,当我们调用一个函数时,CPython会创建一个帧对象来执行它。这个例子表明一个封闭的函数可以比一个封闭函数的框架对象寿命更长。 单元机制的好处是它允许避免将封闭函数的帧对象及其所有引用保存在内存中。
LOAD_GLOBAL
和STORE_GLOBAL
¶
编译器为函数中的全局变量生成LOAD_GLOBAL
和STORE_GLOBAL
操作码。如果该变量被声明为全局变量,或者它没有绑定在函数和任何封闭函数中(即它既不是局部的也不是自由的),则该变量在函数中被认为是全局的。下面是一个例子:
a = 1
d = 1
def f():
b = 1
def g():
global d
c = 1
d = 1
return a + b + c + d
c
变量不是g
的全局变量,因为它是g
的局部变量。b
变量对g
不是全局变量,因为它是自由的。a
变量对g
是全局的,因为它既不是本地的也不是自由的。
并且d
变量对于g
是全局的,因为它被显式声明为全局的。
这是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
字段是一个将全局名称映射到它们的值的字典。当CPython为模块创建框架对象时,它会将f_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_opcache_map
也会将额外的指令映射到0。
如果VM在执行LOAD_GLOBAL
时在缓存中找到一个值,它会确保f_global
和f_builtins
字典自上次查找该值以来没有被修改。这是通过将globals_ver
和builtins_ver
与字典的ma_version_tag
进行比较来完成的。每次修改字典时,字典的ma_version_tag
字段都会更改。有关更多详细信息,
请参阅PEP 509 。
如果 VM 在缓存中没有找到值,它会先在 f_globals 中进行正常查找,然后在 f_builtins 中进行查找。如果它最终找到一个值,它会记住两个字典的当前 ma_version_tag 并将该值压入堆栈。
LOAD_NAME
和STORE_NAME
(以及LOAD_CLASSDEREF
)¶
此时您可能想知道为什么CPython完全使用LOAD_NAME
和STORE_NAME
操作码。编译器在编译函数时确实不会产生这些操作码。然而,除了函数之外,
CPython还有另外两种类型的代码块:模块和类定义。我们根本没有讨论类定义,所以让我们修复它。
首先,理解当我们定义一个类时,VM 执行它的主体是至关重要的。这就是我的意思:
class A:
print('This code is executed')
$ python create_class.py
This code is executed
编译器为类定义创建代码对象,就像它为模块和函数创建代码对象一样。有趣的是,编译器几乎总是为类体内的变量生成LOAD_NAME
和STORE_NAME
操作码。
这个规则有两个罕见的例外:自由变量和显式声明为全局的变量。
VM以不同的方式执行*_NAME
操作码和*_FAST
操作码。因此,变量在类体中的工作方式与在函数中的工作方式不同:
x = 'global'
class C:
print(x)
x = 'local'
print(x)
$ python class_local.py
global
local
在第一次加载时,VM从f_globals
加载x
变量的值。然后,它将新值存储在f_locals
中,并在第二次加载时从那里加载它。如果C
是一个函数,
当我们调用它时,我们会得到UnboundLocalError: local variable 'x' referenced before assignment
,因为编译器会认为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
的作用:
Much like LOAD_DEREF but first checks the locals dictionary before consulting the cell. This is used for loading free variables in class bodies.
为什么要先查locals
字典?对于函数,编译器肯定知道变量是否是局部变量。对于类,编译器无法确定。这是因为CPython有元类,元类可以通过实现__prepare__
方法为一个类准备一个非空的locals
字典。
我们现在可以看到为什么编译器为类定义生成LOAD_NAME
和STORE_NAME
操作码,但我们也看到它为模块命名空间内的变量生成这些操作码,如a = b
示例中所示。它们按预期工作,因为模块的f_locals
和模块的f_globals
是一回事:
$ python -q
>>> locals() is globals()
True
您可能想知道为什么CPython在这种情况下不使用LOAD_GLOBAL
和STORE_GLOBAL
操作码。老实说,我不知道确切的原因,如果有的话,但我有一个猜测。
CPython提供了内置的compile()
、eval()
和exec()
函数,可用于动态编译和执行Python代码。这些函数使用顶级命名空间中的LOAD_NAME
和
STORE_NAME
操作码。这是完全合理的,因为它允许在类主体中动态执行代码并获得与在那里编写代码相同的效果:
a = 1
class A:
b = 2
exec('print(a + b)', globals(), locals())
$ python exec.py
3
CPython选择始终对模块使用LOAD_NAME
和STORE_NAME
操作码。这样,当我们以正常方式运行模块时,编译器产生的字节码与我们使用exec()
执行模块时是一样的。
编译器如何决定生成哪个操作码¶
我们在本系列的第2部分中了解到,在编译器为代码块创建代码对象之前,它会为该块构建一个符号表。符号表包含有关代码块中使用的符号(即名称)的信息, 包括它们的范围。编译器根据给定名称的范围和当前正在编译的代码块的类型决定为给定名称生成哪个加载/存储操作码。该算法可以总结如下:
- 确定变量的作用域:
- 如果变量声明为
global
,则它是一个显式的全局变量。 - 如果变量声明为非局部变量,则它是一个自由变量。
- 如果变量绑定在当前代码块中,则它是一个局部变量。
- 如果变量绑定在不是类定义的封闭代码块中,则它是一个自由变量。
- 否则,它是一个隐式全局变量。
- 如果变量声明为
- 更新范围:
- 如果变量是局部的并且在封闭的代码块中是空闲的,那么它就是一个单元变量。
- 决定生成哪个操作码:
- 如果变量是单元变量或自由变量,则产生
*_DEREF
操作码;如果当前代码块是类定义,则生成LOAD_CLASSDEREF
操作码以加载值。 - 如果变量是局部变量并且当前代码块是函数,则生成
*_FAST
操作码。 - 如果变量是显式全局变量或隐式全局变量并且当前代码块是函数,则生成
*_GLOBAL
操作码。 - 否则,产生
*_NAME
操作码。
- 如果变量是单元变量或自由变量,则产生
你不需要记住这些规则。您可以随时阅读源代码。查看 Python/symtable.c 以查看编译器如何确定变量的范围,查看Python/compile.c 以查看它如何决定生成哪个操作码。
总结¶
Python变量的主题比乍一看要复杂得多。Python文档的很大一部分与变量相关,包括关于命名和绑定的部分以及关于作用域和命名空间的部分。Python FAQ的 首要问题是关于变量的。我对 Stack Overflow上的问题只字未提。虽然官方资源提供了一些关于Python变量为何以它们的方式工作的想法, 但仍然难以理解和记住所有规则。幸运的是,通过研究Python实现的源代码,更容易理解Python变量的工作原理。这就是我们今天所做的。
我们研究了CPython用来加载和存储变量值的一组操作码。要了解VM如何执行实际计算某些内容的其他操作码,我们需要讨论Python的核心——Python 对象系统。这是我们下次的计划。