Skip to content

Commit 4862f58

Browse files
committed
新增「一般表达式与名字空间」章节
讲解取名字的编译期选指令与运行期查空间:LOAD_FAST/LOAD_GLOBAL/LOAD_NAME 与 LEGB,以及表达式在求值栈上的运算与优先级体现
1 parent 2a246bb commit 4862f58

7 files changed

Lines changed: 369 additions & 3 deletions

File tree

.vitepress/config.mts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export default defineConfig({
7171
text: '第 4 部分:虚拟机',
7272
items: [
7373
{ text: 'Python 虚拟机框架(帧对象与求值循环)', link: '/vm/frame-and-eval-loop/' },
74-
{ text: '一般表达式与名字空间(编写中…)', link: '/' }
74+
{ text: '一般表达式与名字空间', link: '/vm/expressions-and-names/' },
75+
{ text: '控制流:跳转、循环与迭代器(编写中…)', link: '/' }
7576
]
7677
},
7778
{

SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
## 第 4 部分:虚拟机
3131

3232
- [Python 虚拟机框架(帧对象与求值循环)](vm/frame-and-eval-loop/index.md)
33-
- 一般表达式与名字空间
33+
- [一般表达式与名字空间](vm/expressions-and-names/index.md)
3434
- 控制流:跳转、循环与迭代器
3535
- 异常机制:block 栈与栈展开
3636
- 函数机制:调用、参数与闭包

index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
- [x] 编译的产物:code object 与 pyc
3232
- [ ] 第 4 部分:虚拟机
3333
- [x] Python 虚拟机框架(帧对象与求值循环)
34-
- [ ] 一般表达式与名字空间
34+
- [x] 一般表达式与名字空间
3535
- [ ] 控制流:跳转、循环与迭代器
3636
- [ ] 异常机制:block 栈与栈展开
3737
- [ ] 函数机制:调用、参数与闭包
Lines changed: 60 additions & 0 deletions
Loading

vm/expressions-and-names/index.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# 一般表达式与名字空间
2+
3+
上一章我们立起了虚拟机的骨架——求值循环照着字节码一条条执行,操作的是帧里的求值栈。这一章就钻进最常见的那批指令:**取名字****算表达式**。一段直线代码(没有分支、没有调用)的执行,基本就是这两件事来回交织。
4+
5+
而其中真正有讲头的是「取名字」。`x` 这个名字,运行时到底去哪儿找它的值?答案牵出 Python 著名的 **LEGB** 规则,以及一个容易被忽略的事实:**「去哪找」这件事,一半在编译期就定好了。**
6+
7+
## 取名字:编译期定指令,运行期做查找
8+
9+
回想第三部分讲的符号表——编译时它已经分析出每个名字属于哪类作用域。这个分析结果不会浪费:**编译器据此为每个名字选定一条专门的取值指令**。于是取名字的工作被劈成两半:
10+
11+
- **编译期**:符号表定作用域 → 选指令。局部变量用 `LOAD_FAST`,函数里引用的全局名用 `LOAD_GLOBAL`,模块顶层和类体里的名字用 `LOAD_NAME`
12+
- **运行期**:选定的指令各查各的名字空间。
13+
14+
![取名字的编译期与运行期分工](name-resolution.svg)
15+
16+
亲眼看一下编译器的选择。同一个表达式 `a + g` 里,`a` 是参数(局部)、`g` 是全局变量,编译器给它们配了不同的指令:
17+
18+
```python
19+
>>> import dis
20+
>>> g = 10
21+
>>> def f(a):
22+
... return a + g
23+
...
24+
>>> for ins in dis.get_instructions(f):
25+
... if ins.opname.startswith("LOAD"):
26+
... print(f"{ins.opname:14} {ins.argrepr}")
27+
...
28+
LOAD_FAST a
29+
LOAD_GLOBAL g
30+
```
31+
32+
`a` 配了 `LOAD_FAST``g` 配了 `LOAD_GLOBAL`——**这个区分在编译期就完成了**,运行期不再去猜「`a` 是局部还是全局」。下面逐条看这三种指令在运行期怎么干活。
33+
34+
## LOAD_FAST:局部变量按下标取,最快
35+
36+
函数里的局部变量,存在帧的 `f_localsplus` 那块数组里(上一章提过,局部变量和求值栈共用它)。`LOAD_FAST` 的参数就是变量在这块数组里的**下标**——直接按下标取,连字典都不用查:
37+
38+
`源文件:`[Python/ceval.c](https://github.com/python/cpython/blob/v3.7.0/Python/ceval.c#L1067)
39+
40+
```c
41+
// Python/ceval.c —— TARGET(LOAD_FAST)
42+
PyObject *value = GETLOCAL(oparg); // 按下标直接取
43+
if (value == NULL) { // 槽位还没被赋值
44+
format_exc_check_arg(PyExc_UnboundLocalError, ...);
45+
goto error;
46+
}
47+
Py_INCREF(value);
48+
PUSH(value); // 压入求值栈
49+
```
50+
51+
这是 Python 里取值最快的路径——一次数组索引而已。这也解释了一个常见报错的由来:如果这个槽位还没被赋值就被读取,`GETLOCAL` 取到 `NULL`,于是抛 **`UnboundLocalError`**:
52+
53+
```python
54+
>>> def bad():
55+
... print(x) # x 在下面被赋值,所以整个函数里 x 是局部 → LOAD_FAST
56+
... x = 1 # 但执行到 print 时这个槽位还是空的
57+
...
58+
>>> bad()
59+
Traceback (most recent call last):
60+
...
61+
UnboundLocalError: local variable 'x' referenced before assignment
62+
```
63+
64+
> 上面是 3.7 的报错文字;新版本措辞略有调整(如「cannot access local variable ...」),含义一致。关键是:**只要函数里某处给 `x` 赋了值,`x` 在整个函数里就是局部的**,读取它走的就是 `LOAD_FAST`——哪怕赋值写在读取之后。
65+
66+
## LOAD_GLOBAL:全局 → 内建
67+
68+
函数里**只读不写**的外部名(比如调用 `len`、引用模块级变量 `g`),编译器选 `LOAD_GLOBAL`。它先查全局名字空间 `f_globals`,没有再查内建 `f_builtins`
69+
70+
`源文件:`[Python/ceval.c](https://github.com/python/cpython/blob/v3.7.0/Python/ceval.c#L2101)
71+
72+
```c
73+
// Python/ceval.c —— TARGET(LOAD_GLOBAL)(快路径)
74+
v = _PyDict_LoadGlobal((PyDictObject *)f->f_globals, // 先全局
75+
(PyDictObject *)f->f_builtins, // 再内建
76+
name);
77+
if (v == NULL) { ... // 两处都没有
78+
format_exc_check_arg(PyExc_NameError, NAME_ERROR_MSG, name);
79+
goto error;
80+
}
81+
```
82+
83+
`_PyDict_LoadGlobal` 把「先全局后内建」两步合成一次调用。所以我们能直接用 `len``print` 这些**内建函数**而无需导入——它们不在全局里,但 `LOAD_GLOBAL` 会自动回退到内建名字空间找到它们:
84+
85+
```python
86+
>>> def show():
87+
... return len # 全局里没有 len,回退到内建找到
88+
...
89+
>>> show()
90+
<built-in function len>
91+
```
92+
93+
要是全局和内建都没有这个名字,就抛 **`NameError`**
94+
95+
```python
96+
>>> undefined_name
97+
Traceback (most recent call last):
98+
...
99+
NameError: name 'undefined_name' is not defined
100+
```
101+
102+
注意 `LOAD_GLOBAL``LOAD_FAST` 的报错不同:前者是 `NameError`(名字根本不存在),后者是 `UnboundLocalError`(是局部、但还没赋值)。报错类型直接反映了编译器当初选了哪条指令。
103+
104+
## LOAD_NAME:模块层与类体的运行期查找
105+
106+
还有第三条:`LOAD_NAME`。它用在**模块顶层代码****`class`**里——这些地方的名字,编译期没法像函数那样把局部变量固定成下标(class 体要支持动态、模块层的局部就是全局),于是只能运行期动态查。它依次查**局部 → 全局 → 内建**三个名字空间:
107+
108+
`源文件:`[Python/ceval.c](https://github.com/python/cpython/blob/v3.7.0/Python/ceval.c#L2050)
109+
110+
```c
111+
// Python/ceval.c —— TARGET(LOAD_NAME)(精简)
112+
v = PyObject_GetItem(f->f_locals, name); // ① 局部
113+
if (v == NULL) {
114+
v = PyDict_GetItem(f->f_globals, name); // ② 全局
115+
if (v == NULL) {
116+
v = PyDict_GetItem(f->f_builtins, name); // ③ 内建
117+
if (v == NULL) { ...NameError... }
118+
}
119+
}
120+
```
121+
122+
可以看到 `LOAD_NAME``LOAD_GLOBAL` 多查一层局部、比 `LOAD_FAST` 多了字典查找——它最灵活,但也最慢。这正是为什么**函数内部要尽量用局部变量**:函数体里的名字能走 `LOAD_FAST` 的快路径,而模块顶层只能用 `LOAD_NAME`
123+
124+
## LEGB:四类作用域与四条指令
125+
126+
把三条指令和大家熟悉的 **LEGB** 规则对起来,整幅图就完整了。LEGB 是取名字的查找顺序——**L**ocal(局部)→ **E**nclosing(外层函数)→ **G**lobal(全局)→ **B**uiltin(内建):
127+
128+
![LEGB 与对应指令](legb.svg)
129+
130+
| 作用域 | 指令 | 运行期行为 |
131+
|---|---|---|
132+
| **L** 局部 | `LOAD_FAST` | 按下标取,不查字典 |
133+
| **E** 外层(闭包) | `LOAD_DEREF` | 从 cell 取外层函数的变量 |
134+
| **G** 全局 / **B** 内建 | `LOAD_GLOBAL` | 先全局、再内建 |
135+
| 模块层 / 类体 | `LOAD_NAME` | 局部 → 全局 → 内建 |
136+
137+
关键在于:**LEGB 这条「链」并不是运行期一节节去试出来的,而是编译期就按符号表把每个名字归好类、配好指令**。运行期各指令只查自己该查的那一两处,查不到才报 `NameError`。其中 `LOAD_DEREF`(E 层,闭包)牵涉 cell 变量,留到「函数机制」一章再展开;这里只要知道它在 LEGB 里占了「外层」这一格。
138+
139+
## 表达式求值:运算符在栈上接力
140+
141+
名字取到栈上之后,剩下的就是****。运算符指令的套路高度一致——上一章的 `BINARY_ADD` 已经示范过:弹出操作数、计算、把结果压回栈顶。乘法 `BINARY_MULTIPLY` 一模一样,只是换成 `PyNumber_Multiply`
142+
143+
`源文件:`[Python/ceval.c](https://github.com/python/cpython/blob/v3.7.0/Python/ceval.c#L1197)
144+
145+
```c
146+
// Python/ceval.c —— TARGET(BINARY_MULTIPLY)
147+
PyObject *right = POP();
148+
PyObject *left = TOP();
149+
PyObject *res = PyNumber_Multiply(left, right);
150+
......
151+
SET_TOP(res); // 结果写回栈顶
152+
```
153+
154+
有意思的是**运算优先级是怎么体现的**。看 `a + b * c`(设 `a=1, b=2, c=3`)——上一章讲过,编译器按语法树生成字节码,而 `*` 在树里比 `+` 更深,于是它的指令排得更靠前:
155+
156+
```
157+
0 LOAD_FAST a
158+
2 LOAD_FAST b
159+
4 LOAD_FAST c
160+
6 BINARY_MULTIPLY # 先算 b * c
161+
8 BINARY_ADD # 再算 a + (b*c)
162+
```
163+
164+
在栈上跑一遍就一目了然:
165+
166+
![a + b * c 在栈上求值](expr-stack.svg)
167+
168+
三个值依次压栈后,`BINARY_MULTIPLY` 先弹出 `b``c` 相乘得 `6` 压回,`BINARY_ADD` 再把 `a``6` 相加得 `7`**乘法先于加法发生,纯粹是因为它的指令排在前面**——优先级在编译期就固化进了字节码顺序,运行期的栈只是忠实地按顺序执行,根本不需要懂什么叫优先级。
169+
170+
构建容器也是同一个套路。比如 `[a, b, c]` 编译成「依次压入 a、b、c,再用 `BUILD_LIST 3` 把栈顶三个值打包成列表」:
171+
172+
`源文件:`[Python/ceval.c](https://github.com/python/cpython/blob/v3.7.0/Python/ceval.c#L2263)
173+
174+
```c
175+
// Python/ceval.c —— TARGET(BUILD_LIST)
176+
PyObject *list = PyList_New(oparg); // oparg = 元素个数
177+
while (--oparg >= 0) {
178+
PyObject *item = POP(); // 从栈顶逐个弹出
179+
PyList_SET_ITEM(list, oparg, item); // 填进列表(倒着填,顺序正好对)
180+
}
181+
PUSH(list); // 列表压回栈顶
182+
```
183+
184+
`BUILD_TUPLE``BUILD_MAP`(字典)、`COMPARE_OP`(比较)……全是这个模式:操作数已经在栈上备好,指令弹出它们、算出结果、压回栈顶。理解了「**压操作数 → 指令计算 → 压回结果**」这一条,绝大多数表达式字节码都能照着读下来。
185+
186+
---
187+
188+
小结一下:
189+
190+
- 取名字分两半:**编译期**由符号表定作用域、选指令;**运行期**指令各查各的名字空间;
191+
- 三条取值指令:**`LOAD_FAST`**(局部,按下标,最快,未赋值则 `UnboundLocalError`)、**`LOAD_GLOBAL`**(全局→内建,缺失则 `NameError`)、**`LOAD_NAME`**(模块层/类体,局部→全局→内建,最灵活也最慢);
192+
- 它们对应 **LEGB** 规则的各层(外层 E 由 `LOAD_DEREF` 负责,留待函数机制章);LEGB 的归类在编译期完成,不是运行期逐层试;
193+
- **表达式求值**统一是栈式接力:压操作数 → 运算符指令弹出计算 → 压回结果;**运算优先级**靠编译期排好的字节码顺序体现,`BUILD_LIST` 等构建指令也是同一套路。
194+
195+
直线代码到此清楚了。但真实程序还有 `if``while``for`——执行不再是一条道走到底。下一章看**控制流**:虚拟机如何靠「跳转」指令改变执行顺序。

0 commit comments

Comments
 (0)