KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 作用域 — keel 龙骨
类型:主题概念 · 应用课对应:02 章 · 函数:参数规则、LEGB 与一等函数
类型:主题概念 · 应用课对应:02 章 · 函数:参数规则、LEGB 与一等函数
这一页只回答四件事:它为什么会出现、它是什么、它长什么样、它怎么工作。
一、它为什么会出现
一个动作,两个反应:
① 在函数里写 total = total + 1 —— 报错说「局部变量 total 没有值」
② 两个函数里各写一个 x,它们互不打架
同一个名字,在同一个文件里要同时做到两件相反的事:函数里面是私有的,外面又要有一些是共用的。
如果只有一套命名空间,①和②就都做不到。
问题在于:「这个名字从哪来」必须有一个确定的答案,而且答案不能取决于谁先跑过、谁后改过——
否则同一段函数在两次调用里会得到不同结果。
二、它是什么
一句话:作用域是一个名字可以被查到的范围;Python 在编译期就定好每个名字属于哪一层,运行时按固定的四层顺序去查。
由此有两个直接后果:
- 是「哪一层」,不是「哪一行」。 函数里的名字归属由整个函数体扫描的结果决定,
不由执行到哪一行决定——这就是「明明上面能用、下面却报错」的根。 - 读和写不是一回事。 往外层读是允许的;往外层写必须先声明
global或nonlocal,否则你创建了一个新名字。
它不是这两样东西——混淆会带来误判:
- 不是「就近生效」的直觉。 直觉会说「先看局部,没有就往外找」;真正的规则是
「函数体内凡是被赋值的名字,通篇都算局部」。所以函数里只要有一句total = ...,
在这个函数里total就不是外面那个了,读它只会得到「未绑定」。 - 不是「变量会跟着调用栈走」。 作用域是词法的:查外层查的是定义它的那个位置的外层,
不是「谁调用了它」。同一个函数被谁调用,不影响它能看到哪些名字。
三、它长什么样
以下是 CPython 3.13.14(Windows)上的输出。
第一种形态是报错原文——它把「这个操作数在函数里可见」记录下来,这是执行器唯一能捕捉的错误证据:
>>> def scope_demo():
... print(x)
... x = 1
>>> scope_demo()
Traceback (most recent call last):
...
UnboundLocalError: cannot access local variable 'x' where it is not associated with a value
第二种形态是四层里各放一个名字,看谁被查到:
>>> G = "global-G"
>>> def outer():
... E = "enclosing-E"
... def inner():
... return G, E, L
... L = "local-L"
... return inner()
>>> outer()
('global-G', 'enclosing-E', 'local-L')
两种形态合起来说明一件事:inner 里三个名字都不在它自己的函数体里被赋值,
所以它们顺着 L → E → G 一路找到了 L(外层函数)、E(外层函数的局部)、G(模块全局)。
四、它怎么工作
一次名字查找是四步,前两步在编译期完成:
① 扫描函数体 凡是「被赋值、被 import、被 for 绑定、被 with as 绑定、是参数」的名字,全部登记为本函数的局部名
② 划界 这一步定死了:这名字属于本层,运行时不再改判
③ 运行时查找 Local(本函数)→ Enclosing(外层函数)→ Global(模块)→ Builtins(内置)
④ 真正绑定 赋值语句**执行到**的那一刻,这个名字才有值;在此之前读它 → UnboundLocalError
第 ①② 步解释了三件经常让人困惑的事:
| 现象 | 原因 |
|---|---|
| 上面能读、下面报未绑定 | 扫描结果让整个函数都把它当局部名,而绑定发生在赋值执行到的那一刻 |
| 内层函数改了外层变量,外层没变 | 内层那句赋值创建了一个新的局部名,不是改外层那个 |
| 类体里的名字,类里别处读不到 | 类体不参与 LEGB 这条链,它不构成一层作用域 |
最后一条真的会报错:
>>> class C:
... tag = "class-tag"
... listing = [tag for _ in range(1)]
NameError: name 'tag' is not defined
原因是列表推导式有自己的作用域(它像一个隐式函数),而它只看得见 LEGB 那四层——
类体不在里面。同一段代码改成方法里读 self.tag 就没事。
五、它会带出哪些概念
- 闭包:内层函数能记住外层函数的名字,靠的就是 E 这一层还在。→ 应用课 03 章
global/nonlocal:它们不是「让变量可见」,而是改写 ① 的扫描结果:告诉编译器别登记成局部。→ 应用课 03 章- 推导式的作用域:推导式是隐式函数,所以看不到类体里的名字。→ 应用课 01 章
- 属性而不是名字:
self.tag走的是另一套规则(属性查找),和这一页的四层查找不是一回事。→ 02 页
六、回到应用课
| 应用课里的那句话 | 现在你知道它为什么成立 |
|---|---|
| "函数内赋值会让这个名字变成本地的" | 编译期扫描就定了归属,与执行顺序无关;所以「先读后赋值」必然报未绑定 |
"要改外层变量得用 nonlocal" |
不用声明的话,那句赋值是在本层登记一个新名字,外层那个从头到尾没被碰过 |
| "LEGB 是查找顺序" | 四层是词法的,查的是定义处的外层,不是调用栈上的上一层 |
这一页改变了你的哪个判断:遇到 UnboundLocalError,不要先去怀疑初始化顺序或全局变量,而是去函数体里找有没有一句对它赋值——找到了,问题就结束了:要么加 global/nonlocal,要么把名字改掉。
自测题
- 一个函数里写了
print(total),函数末尾有一句total = 1。调用它会怎样?把末尾那句删掉又会怎样? - 类体里
x = 1,紧接着的列表推导式里想读x,为什么会报NameError?改成方法里self.x为什么就行?