环境:名字到底在哪里被找到
把「函数调用时发生了什么」从一句口号变成一套可以在纸上一步步画出来的机械流程——环境图(environment diagram)。
0. 本讲导读
上一讲把函数抬成了「值」:函数可以当参数传进去,可以当返回值传出来,可以绑定到名字上。这一下子打开了表达能力,但也打开了一个新麻烦——写在一个地方的代码,可能在完全不同的地方被执行。
make_adder(2) 返回的那个 adder 函数,它的函数体里写着 n + k。这个函数被返回到全局,被赋值给 a,然后在全局被调用。可这时候 make_adder 早就执行完了,n 这个名字在全局根本不存在。那 a(1) 为什么还能算出 3?n 是从哪儿翻出来的?
再看一个更刺眼的:
j = 3
def h(i):
j = 4
return lambda z: j * i(z)
h(lambda z: j + z)(j)
这段代码里出现了四个 j。它们指的是同一个东西吗?哪个是 3,哪个是 4?光靠「从上往下读一遍」根本回答不了。你需要一套不依赖直觉、纯机械执行的记账方法——这就是环境图(environment diagram)。
环境图不是「教学辅助工具」,也不是「初学者的拐杖」。它就是 Python 解释器实际在做的事,只不过画在纸上。61A 从这一讲起,一直到期末考、到 Scheme 解释器项目,都在反复用它。本讲不引入任何新语法——def、lambda、return 你都见过了;本讲要做的是把它们的执行规则钉死,钉到你能在纸上一步不错地模拟出来。
之所以这一讲安排在这里,是因为下一讲就是递归。递归的本质是「同一个函数的多个调用同时活着,各自有各自的一套局部名字」——如果你脑子里没有「一次调用 = 一个新帧」这幅图,递归看起来就会像变魔术。
- 环境(environment)是一串帧(frame);一个 frame 是一堆名字到值的绑定(binding)。所有 Python 程序都从全局帧(global frame)开始,全局帧没有 parent。
- 函数的 parent 是它被定义时所在的那一帧,不是它被调用时所在的帧。 这一条是整讲的核心,也是所有「奇怪答案」的来源。
- 查一个名字:先在当前帧找 → 找不到就去 parent 帧找 → 一路向上;到全局帧还没有就抛
NameError。 - 新建一帧只有一个原因:调用一个用户定义的函数。
if、while、for都不新建帧。 - 执行
def/ 求值lambda时,函数体一行都不执行,只是造出一个函数对象并记下它的 parent。 - 调用表达式的三步:求值算子 → 从左到右求值算子数 → 把函数施加到实参上(新建帧、绑定形参、执行函数体)。
- 每个函数都会返回点什么:显式
return,或者走完函数体隐式返回None。碰到return立刻退出该帧。 - 在函数体里对某个名字做过赋值,那个名字在整个函数体范围内都是局部的——赋值之前就读它会得到
UnboundLocalError,而不是读到外层的同名变量。
1. 为什么必须画图:三个只靠读代码答不出的问题
先说服自己「有必要」,再学规则,学起来会快很多。下面三段代码,每一段都短到可以一眼看完,但每一段的答案都不是「一眼」能得到的。
问题一:函数执行完了,它的局部名字还活着吗
def make_adder(n):
def adder(k):
return n + k
return adder
a = make_adder(2)
b = make_adder(3)
make_adder(2) 这个调用已经结束了。按「函数调用结束,局部变量销毁」的朴素说法,n 应该没了。可是 a(1) 确实返回 3,b(1) 确实返回 4——不但 n 还在,而且两次调用留下的 n 互不干扰,一个是 2 一个是 3。
这说明「帧」不是一个简单的栈上临时空间。只要还有函数对象把某个帧记成自己的 parent,那个帧就得留着。环境图能把这件事画出来:a 和 b 各自指着一个 adder 函数对象,两个对象的 parent 指向两个不同的 make_adder 帧。
问题二:同名的名字,到底是哪一个
j = 3
def h(i):
j = 4
return lambda z: j * i(z)
h(lambda z: j + z)(j)
这里 j 出现了四次:全局的 j = 3、h 里的 j = 4、返回的那个 lambda 体里的 j、以及作为实参传进去的 lambda z: j + z 体里的 j。最后那两个 j 长得一模一样,但它们查到的值不同——一个是 4,一个是 3。原因完全取决于这两个 lambda 是在哪一帧里被定义的。
没有环境图,这题只能靠猜。有了环境图,它是一道纯机械题(答案是 24,第 8 节会一步不落地画完)。
问题三:错误到底发生在哪一行
x = 10
def bar():
print(x)
def foo():
print(x)
x += 1
bar() 正常打印 10。foo() 呢?大多数人的第一反应是「print(x) 打印 10,然后 x += 1 那行报错,因为不能改外层变量」。
错。报错发生在 print(x) 那一行,也就是「看起来完全没问题」的那一行。这不是 Python 在耍你,而是名字作用域的规则比你想的要早生效。第 3 节专门拆这个。
x += 1(真正的病根),而是它上面那句 print(x)。Python 文档给的解释是:只要你在某个作用域里对一个变量做了赋值,这个变量就成为该作用域的局部变量,并遮蔽(shadow)外层的同名变量;于是更早的那句 print(x) 试图读一个「还没被赋值的局部变量」,报错。这三个问题的共同点:代码文本的位置和执行时的名字查找路径是两回事。环境图就是把「查找路径」显式画出来的工具。你不需要「理解」它,你需要会算它——像做长除法一样,按步骤走,走完答案自然出现。
2. 规则 1 与 2:帧、绑定,以及「向上查找」
三个词:environment、frame、binding
先把词认清楚,后面才不会含混。
| 术语 | 是什么 | 在图上长什么样 |
|---|---|---|
| 绑定(binding) | 一个名字和一个值的对应关系 | 表格里的一行:x | 3 |
| 帧(frame) | 一组绑定的集合,外加一个 parent 指针 | 一个方框,标题写帧名和 parent |
| 环境(environment) | 一串帧:从当前帧开始,沿 parent 一路到全局帧 | 不是一个框,是一条路径 |
「环境」这个词最容易被误用。它不是某一个帧,而是「当前帧 + 它的 parent + parent 的 parent + …… + 全局帧」这一整条链。同一个程序在不同时刻、不同帧里执行,环境是不同的。你听到「在某某环境下求值这个表达式」,意思就是「按这条链的顺序去查名字」。
还有一条容易忽略的规定:全局帧没有 parent。它是查找链的终点,也是「再找不到就只能报错」的那道墙。所有 Python 程序都从全局帧起步。
规则 2:parent 是「定义处」,不是「调用处」
UnboundLocalError。把规则 2 拆成四句可执行的话:
def 或 lambda 被执行时所在的那一帧」。这个指针在函数诞生的那一刻就固定了,此后永不改变。x 的值:先在当前帧的绑定里找。找到就用,结束。NameError: name 'x' is not defined。第 2 步值得单独强调,因为它是唯一容易记反的地方。新帧的 parent 不是「谁调用了我」,而是「我在哪里出生」。 前者叫动态作用域(dynamic scoping),后者叫词法作用域(lexical scoping)——Python、Scheme 以及你能想到的绝大多数现代语言用的都是后者。
用一段代码把这个区别顶出来:
x = 'global'
def show():
return x # 这个 x 查哪儿?
def caller():
x = 'local'
return show() # 在 caller 帧里调用 show
caller() 返回 'global',不是 'local'。
def show:造出函数对象 func show(),parent = Global(因为这个 def 是在全局帧里执行的)。def caller:造出 func caller(),parent = Global。caller():新建 f1,f1 的 parent = caller 对象的 parent = Global。在 f1 里执行 x = 'local',f1 多出一条绑定 x → 'local'。show():算子 show 在 f1 查不到,去 parent(Global)查到函数对象。调用它,新建 f2。f2 的 parent = show 对象的 parent = Global——注意,不是 f1,尽管调用发生在 f1 里。x:f2 自己没有 x,去 parent(Global)查 → 'global'。f1 里那个 x 根本不在 f2 的查找链上,看不见。Global frame
x ──→ 'global'
show ──→ func show() [parent=Global]
caller ──→ func caller() [parent=Global]
f1: caller [parent=Global]
x ──→ 'local' # 只有 f1 自己看得见
返回值 ──→ 'global'
f2: show [parent=Global] # ← parent 是 Global,不是 f1
(无形参)
返回值 ──→ 'global'
把 f2 的 parent 画成 f1,你就会答成 'local'——这是初学阶段最高频的错误之一。防错的办法很笨但很有效:写 parent 时,不要看「我现在在哪一帧」,去看「这个函数对象是在哪一帧里被 def 出来的」。
为什么查找是「向上」而不是「向下」
顺带解释一个常被忽略的不对称性:内层能看见外层,外层看不见内层。caller 帧里定义的 x,全局看不见;但全局的 x,任何帧都可能看见。
这个设计是有道理的。函数是一个「封装」:它承诺不把自己的中间变量泄漏出去,否则你每写一个函数都得担心它的局部名字会不会撞坏别人。反过来,内层能看见外层,是为了让函数能用到它写作时周围的东西(全局的其他函数、常量、以及闭包捕获的值)。信息单向流动,从外往内。
「向上查找」查的是名字,不是「值的备份」。帧里存的是绑定,绑定随时可能被后续语句改掉。所以判断一个名字的值,永远要问「在执行到这一步的时刻,沿查找链第一个含有这个名字的帧里,它绑的是什么」。时刻很重要,第 7 节的 make_adder 会给出一个必须掐准时刻的例子。
3. UnboundLocalError:一次赋值决定整个函数体
回到第 1 节的第三个问题。这是这一讲唯一一个「规则之外的补充规则」,但它出现频率极高,必须单独说清。
x = 10
def bar():
print(x)
def foo():
print(x)
x += 1
先跑 bar():
>>> bar()
10
符合预期:bar 的帧里没有 x,向上查到全局的 10。
再跑 foo():
>>> foo()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in foo
print(x)
UnboundLocalError: local variable 'x' referenced before assignment
报错行是 print(x),也就是 foo 体内的第一行。它跟 bar 里那行一模一样,bar 跑得好好的,foo 却炸了。唯一的区别是 foo 在后面多了一句 x += 1。
Python 在编译函数体的时候(也就是执行 def 语句、把函数体变成字节码的时候,早于任何一次调用)就扫描整个函数体:只要函数体里有任何一处对名字 x 的赋值,x 就在这个函数的整个作用域内被判定为局部变量,并遮蔽外层的同名变量。
于是运行时读 x,Python 直接去局部帧取——不再向上查找。而此刻局部帧里的 x 还没被赋过值,所以报 UnboundLocalError。
注意这里的因果链,别记反了:
def foo 时,Python 编译 foo 的函数体,扫到第 2 行 x += 1——这是一条赋值(x += 1 等价于 x = x + 1,左边有 x =)。x 被登记为 foo 的局部名字。这个判定对整个函数体生效,包括第 1 行,跟赋值语句写在第几行无关。foo():新建帧 f1,parent = Global,f1 里暂时什么绑定都没有(foo 没有形参)。print(x):求值 x。由于 x 是局部名字,Python 只在 f1 里找——找不到值,并且不会向上查全局。抛 UnboundLocalError。x += 1 根本没机会执行。Global frame
x ──→ 10
bar ──→ func bar() [parent=Global]
foo ──→ func foo() [parent=Global]
f1: foo [parent=Global]
x ──→ ??? # 被判定为局部名字,但还没有值
# 读它 → UnboundLocalError(不会去 Global 拿 10)
「不会向上查找」是关键。普通情况下查不到就往上走,但一旦某个名字被判定为局部,向上查找这条路就被切断了——要么局部有值,要么报错,没有第三种可能。
误区一:把 UnboundLocalError 当成 NameError 处理。 两者的诊断方向完全相反:
NameError | UnboundLocalError | |
|---|---|---|
| 含义 | 整条查找链上都没有这个名字 | 这个名字是局部的,但还没赋值 |
| 典型原因 | 拼错了名字;用了还没定义的函数 | 想读外层变量,同时又在函数里给它赋了值 |
| 怎么修 | 检查拼写;确认定义在使用之前 | 换个局部名字;或把外层值当参数传进来 |
误区二:以为「先读后写就没事」。 顺序不影响判定。哪怕赋值写在函数体最后一行、写在一个永远不会进入的 if False: 分支里,x 照样被判定为局部:
>>> def g():
... print(x)
... if False:
... x = 1
...
>>> g()
UnboundLocalError: local variable 'x' referenced before assignment
误区三:以为 x += 1 不算赋值。 增强赋值(augmented assignment)也是赋值,它要先读 x 再写 x,两件事都在局部帧做。这正是本例的病根。
本页的报错信息在 Python 3.10 上实测为 UnboundLocalError: local variable 'x' referenced before assignment。Python 3.11 起这句话被改写成 cannot access local variable 'x' where it is not associated with a value。异常类型名 UnboundLocalError 没变,看类型名判断问题即可,别死记那句英文。
那怎么才能在函数里真的修改外层变量?Python 提供了 global 和 nonlocal 两个声明。61A 在这一讲不用它们(nonlocal 要到讲可变数据、账户余额那一讲才登场),现阶段你应该做的是换一种写法绕开:需要外层的值就当参数传进来,需要把结果送出去就 return。
这条规则听着别扭,但它保护的是可读性:看到函数体里有 x = ...,你就可以断定这个函数里所有的 x 都是它自家的,不必翻遍整个文件确认它有没有偷偷改全局状态。代价就是「读了一个还没写的局部变量」会报这个乍看莫名其妙的错。
4. 规则 3:定义函数时,究竟发生了什么
先立一个原则:定义 ≠ 执行。执行一条 def 语句,函数体一行都不会跑。
这不是细节,是环境图最常见的画错点。看到 def f(x): return 1/0,很多人会紧张——除以零!但只要没人调用 f,这段代码永远不会出错。函数体在定义时是一段被封存的代码,只在调用时才解封执行。
def 语句的两件事
def 的那一帧。写进环境图时,函数对象记成 func 名字(形参) [parent=某帧]。方括号里的 parent 是整张图的骨架,漏写 parent 的环境图等于没画。
def make_adder(n):
def adder(k):
return n + k
return adder
这段代码在全局帧执行时,只发生一件事:全局帧多了一条绑定 make_adder → func make_adder(n) [parent=Global]。里面那个 def adder 此刻完全没有执行——它是 make_adder 函数体的一部分,只有在 make_adder 被调用时才会执行。
Global frame
make_adder ──→ func make_adder(n) [parent=Global]
# 没有 adder。adder 这个名字此刻在任何地方都不存在。
lambda:一模一样,只是没名字
lambda 表达式被求值时做的事,和 def 语句做的第 1 步完全相同:造一个函数对象,parent 指向当前帧。区别只有两点:
def 语句 | lambda 表达式 | |
|---|---|---|
| 语法类别 | 语句(statement),被执行 | 表达式(expression),被求值 |
| 有没有内在名字 | 有,就是 def 后面那个名字 | 没有 |
| 会不会自动绑定名字 | 会,在当前帧绑定函数名 | 不会,值飘在那儿,你不接就没了 |
| 函数体 | 可以有多条语句 | 只能是一个表达式,其值即返回值 |
| 环境图里怎么写 | func adder(k) [parent=…] | func λ<行号>(z) [parent=…] |
因为 lambda 没有内在名字(intrinsic name),61A 的约定是用希腊字母 λ 加上它在源码里的行号来指代它,比如 λ3 表示「第 3 行那个 lambda」。
一段代码里可以有好几个 lambda,全叫「λ」就分不清谁是谁了。加上行号,图里写 λ2 和 λ5,你一眼就知道指的是哪一个——尤其是当同一个 lambda 表达式被求值多次、造出多个 parent 不同的函数对象时,行号能帮你把它们归类(比如「三个 λ4,parent 分别是 f1、f2、f3」)。
看一个「同一行 lambda 造出两个不同对象」的例子:
def make(n):
return lambda k: n + k # 第 2 行
p = make(1)
q = make(2)
Global frame
make ──→ func make(n) [parent=Global]
p ──→ func λ2(k) [parent=f1]
q ──→ func λ2(k) [parent=f2]
f1: make [parent=Global]
n ──→ 1
返回值 ──→ func λ2(k) [parent=f1]
f2: make [parent=Global]
n ──→ 2
返回值 ──→ func λ2(k) [parent=f2]
p 和 q 是两个不同的函数对象,虽然它们的代码来自同一行。p(10) 得 11,q(10) 得 12——差别全在 parent 指向的帧里那个 n。函数对象 = 代码 + parent;代码相同而 parent 不同,就是不同的函数。
lambda 和 def 可以互相改写
下面两段代码在环境图上产生的结构完全等价:
(lambda x, y: lambda z: x * y + z)(1, 2)(3)
def outer(x, y):
def inner(z):
return x * y + z
return inner
outer(1, 2)(3)
两者都得到 1 * 2 + 3 = 5。差别只有:lambda 版里的函数对象在图上叫 λ1 和内层的 λ,def 版里叫 func outer(x, y) 和 func inner(z);而且 lambda 版的外层函数从未被绑定到任何名字上——它被造出来、被立即调用、然后就没人指着它了。
误区一:以为 lambda 体里的 return 要写出来。 写 lambda z: return x + z 是语法错误:
>>> f = lambda z: return z
File "<stdin>", line 1
f = lambda z: return z
^^^^^^
SyntaxError: invalid syntax
lambda 的冒号后面必须是一个表达式,它的值自动成为返回值。
误区二:以为 lambda 体里能写 if 语句、赋值、循环。 都不行,它们是语句。要分支只能用条件表达式 a if cond else b(这是表达式)。
误区三:在环境图里给 lambda 写上「函数名」。 比如把 f = lambda x: x 里的函数对象写成 func f(x)。这是错的,正确写法是 func λ1(x)——f 只是一个恰好指着它的名字,把 f 重新绑定到别的值上,这个函数对象也不会改名。
5. 规则 4:调用函数时,究竟发生了什么
return 立即退出这一帧。三步流程
f(a, b)
f。按第 2 节的查找算法找到它的值,并确认这个值确实是个函数。不是函数就报 TypeError: 'int' object is not callable 之类的错。a、b。它们本身可能是任意复杂的表达式(包括别的调用),要先各自求成一个具体的值。顺序是确定的:先左后右。return <表达式>:求值该表达式,把值记为这一帧的返回值,立刻退出这一帧,后面的语句一概不执行。return:这一帧的返回值是 None。第 2 步的「从左到右」不是废话。f2(print('a'), 10 % 3) 这样的表达式里,print('a') 有副作用(往屏幕打字),什么时候打、打在哪一行输出之前,完全由求值顺序决定。第 9 节会用这道题验证。
新建一帧只有一个原因
在 61A 的环境图里,新建一帧的唯一原因是:调用一个用户定义的函数。
if/elif/else:不新建帧。while/for:不新建帧,循环变量就住在当前帧里。- 调用内置函数(
print、abs、max、len……):不画帧。它们是 C 写的,内部没有 Python 层面的帧可画。 - 调用你自己
def出来的函数,或任何一个 lambda:新建一帧。
帧的编号约定:全局帧不编号,就叫 Global frame;其余按创建的先后顺序编号 f1、f2、f3……编号不复用,就算 f1 已经返回退出了,下一帧也叫 f2。帧的标题写「f编号: 函数的内在名字 [parent=…]」。
「每个函数都会返回点什么」
没有 return 的函数返回 None,这一点在 WWPD 题里几乎每次都要考。三种容易混淆的情况:
| 函数体 | 返回值 | 在交互式解释器里输入调用后显示什么 |
|---|---|---|
return 5 | 5 | 5 |
print(5)(无 return) | None | 先打印 5,然后不显示返回值 |
x + y(无 return) | None | 什么都不显示——表达式算了,值被丢掉 |
return(光秃秃一个) | None | 什么都不显示 |
最后一行的 x + y 值得多说一句。作为一条语句单独出现的表达式,它确实被求值了——如果里面有函数调用,那些调用真的会发生、副作用真的会产生——但求出来的值随即被丢弃,因为没人接收它。第 9 节 Q3 里的 f1 结尾正是这么写的,这是一个经典的「以为写了 return 其实没写」的陷阱。
把「求值算子数」和「执行函数体」的顺序搞反。 参数一定先被求成具体的值,才进入函数体。所以:
>>> def f(x):
... return 'never used'
...
>>> f(1 / 0)
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
虽然 f 压根没用 x,1 / 0 照样在进入函数体之前就被求值并崩掉。这叫及早求值(eager evaluation),Python 的普通函数调用一律如此。唯一的例外是 and、or、条件表达式这些特殊形式——它们不是函数调用,所以能短路。
6. 完整走一遍:嵌套 lambda
规则齐了,现在拿上一讲那个嵌套 lambda 一步不落地走完。这是本讲第一个「必须动笔」的例子。
(lambda x, y: lambda z: x * y + z)(1, 2)(3)
先看清结构。整个表达式是两次调用:
- 外层调用:算子是
(lambda x, y: lambda z: x * y + z),算子数是1, 2。 - 再对它的结果调用一次:算子是上一步的返回值,算子数是
3。
写成 ((lambda x, y: ...)(1, 2))(3) 更清楚。Python 里 f(a)(b) 永远是「先算 f(a),再把结果当函数调用,传 b」。
为了标行号,把它按行摊开(假设这是文件的第 1 行):
1 (lambda x, y: lambda z: x * y + z)(1, 2)(3)
两个 lambda 都在第 1 行,为了区分,把外层记作 λ1a、内层记作 λ1b(考试里通常两个 lambda 不会挤在同一行,直接用行号就够)。
(lambda x, y: lambda z: x * y + z)。这是一个 lambda 表达式,求值它 → 造出函数对象 func λ1a(x, y) [parent=Global]。注意:它的函数体 lambda z: x * y + z 此刻完全没有被求值,只是被封存着。这个对象也没有绑定到任何名字。1 → 1,2 → 2。x → 1,y → 2。lambda z: x * y + z,求值它 → 造出 func λ1b(z) [parent=f1]。parent 是 f1,因为这个 lambda 是在 f1 里被求值的。这个值就是 f1 的返回值,f1 退出。<func λ1b(z) [parent=f1]>(3)。求值算子——已经是函数对象了;求值算子数 3 → 3。z → 3。x * y + z:x 在 f2 没有 → 去 parent f1 → 1;y 同理 → 2;z 在 f2 就有 → 3。算得 1 * 2 + 3 = 5。f2 返回 5。| (本例没有任何全局绑定) | |
| λ1a | func λ1a(x, y) [parent=Global] |
| x | 1 |
| y | 2 |
| 返回值 | func λ1b(z) [parent=f1] |
| z | 3 |
| 返回值 | 5 |
(上表 Global frame 里那行 λ1a 只是为了让你看到这个对象存在;实际画图时它是一个飘在旁边、没有名字指着的函数对象。)
关键的一步是第 6 步:f2 的 parent 是 f1。f1 已经返回、已经「用完」了,但它不能被丢掉,因为 λ1b 还记着它。这正是第 1 节问题一的答案——只要还有函数对象把某帧当 parent,那一帧就必须活着。这个「函数 + 它出生时所在的那个环境」的组合,就叫闭包(closure)。
改写成 def 版对照
1 def outer(x, y):
2 def inner(z):
3 return x * y + z
4 return inner
5
6 outer(1, 2)(3)
Global frame
outer ──→ func outer(x, y) [parent=Global]
f1: outer [parent=Global]
x ──→ 1
y ──→ 2
inner ──→ func inner(z) [parent=f1] # def inner 在 f1 里执行,故 parent=f1
返回值 ──→ func inner(z) [parent=f1]
f2: inner [parent=f1]
z ──→ 3
返回值 ──→ 5 # x 从 f1 拿 1,y 从 f1 拿 2,z 本帧 3
唯一的结构性差别:def 版里 f1 多了一条绑定 inner → func inner(z)(因为 def 会在当前帧绑定名字),lambda 版没有。返回值、parent 链、最终结果全都一样。
lambda 形参: 体 ⟶ 起个名字 g,写成 def g(形参): return 体,然后在原来那个 lambda 出现的位置放上 g。嵌套的话从外往里逐层翻。反过来,把 def 翻成 lambda 的前提是函数体只有一条 return 语句——有 if 语句、有循环、有多条语句的函数,翻不了。
7. Q1 Make Adder:闭包,和四条 WWPD 约定
课上的第一道练习。代码你已经在上一讲见过,但这次要用环境图把它算准,还要按 WWPD 的书写规范作答。
def make_adder(n):
def adder(k):
return n + k
return adder
>>> a = make_adder(2)
>>> a
____________
>>> a()
____________
>>> b = make_adder(3)
>>> a(1)
____________
>>> b(1)
____________
先说 WWPD 的四条书写约定
<function ... at 0x...>),会出错就写 Error(不必写出具体是哪种异常),什么都不显示(比如值是 None)就写 Nothing。第一条则是做题方法:动手画环境图。| 情况 | 该写 | 为什么不写别的 |
|---|---|---|
| 表达式求值成一个函数对象 | Function | 真实输出是 <function make_adder.<locals>.adder at 0x7e13cdd86170>,那串地址每次运行都不同,没有考查价值 |
| 求值过程中抛异常 | Error | 只要能判断出「会炸」就算对;能顺口说出是哪种异常更好,但不是必须 |
值是 None,或语句本身不产生显示 | Nothing | 交互式解释器不显示 None,也不显示赋值语句的结果 |
| 其他 | 照抄解释器会打的那一行 | 注意字符串会带引号:'hi' 而不是 hi |
Nothing 说的是「屏幕上什么都没多出来」。它可能是因为值是 None,也可能是因为这一行是赋值语句(a = make_adder(2) 本身也什么都不显示)。反过来,如果某个表达式的值是 None,但函数体里有 print,那 print 打出来的东西照样要写——先写打印内容,再写显示的值(此处为 Nothing)。第 9 节 Q3 大量考这个。
逐行算
def make_adder:Global 里绑定 make_adder → func make_adder(n) [parent=Global]。内层的 def adder 没有执行。make_adder(2):算子查到函数对象;算子数 2 → 2;新建 f1,parent=Global,绑定 n → 2。def adder(k): ... —— 造出 func adder(k) [parent=f1],并在 f1 里绑定 adder 指向它。函数体 return n + k 依然没执行。return adder:在 f1 查到 adder 的值(那个函数对象),作为 f1 的返回值,f1 退出。a 绑定到这个函数对象。这一行是语句,显示 Nothing。>>> a 求值成一个函数对象 → 答案 Function。
>>> a():a 确实是函数,但它的形参表是 (k),需要恰好一个实参,你给了零个 → 答案 Error。真实报错是:
>>> a()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: make_adder.<locals>.adder() missing 1 required positional argument: 'k'
这个 TypeError 发生在施加阶段之前——实参和形参对不上,函数体一行都没进。
>>> b = make_adder(3):新建 f2,n → 3,造出另一个 func adder(k),parent = f2。b 指向它。显示 Nothing。
| make_adder | func make_adder(n) [parent=Global] |
| a | func adder(k) [parent=f1] |
| b | func adder(k) [parent=f2] |
| n | 2 |
| adder | func adder(k) [parent=f1] |
| 返回值 | func adder(k) [parent=f1] |
| n | 3 |
| adder | func adder(k) [parent=f2] |
| 返回值 | func adder(k) [parent=f2] |
>>> a(1):新建 f3,parent = f1(a 指的那个对象记着 f1),绑定 k → 1。求值 n + k:n 在 f3 没有 → f1 → 2;k = 1。返回 3。
>>> b(1):新建 f4,parent = f2,k → 1。n 从 f2 拿 → 3。返回 4。
| 输入 | 答案 | 理由 |
|---|---|---|
a | Function | 求值成函数对象 |
a() | Error | adder 要 1 个参数,给了 0 个 → TypeError |
a(1) | 3 | 沿 f3 → f1 查到 n = 2,2 + 1 |
b(1) | 4 | 沿 f4 → f2 查到 n = 3,3 + 1 |
两次调用 make_adder 产生两个互不相干的帧,因而产生两个互不相干的 adder。 如果 f1 在返回后就被销毁,a(1) 会因为找不到 n 而报 NameError;如果两个 adder 共用同一个 n,那么 b = make_adder(3) 之后 a(1) 会变成 4。两种错觉都很常见,而正确答案(3 和 4)恰好把它们都排除了——这道题的四个空就是为此设计的。
误区一:把 a 写成 func adder(k) [parent=Global]。 adder 这个 def 是在 f1 里执行的,parent 只能是 f1。写成 Global,a(1) 就查不到 n,你会答成 Error。
误区二:a 和 a() 分不清。 a 是名字,求值得到函数对象;a() 是调用表达式,要真的执行函数体。这一字之差在 WWPD 里是两种完全不同的答案。
误区三:以为 a() 会报 NameError: name 'k' is not defined。 不会。参数个数对不上是调用协议的问题,属于 TypeError,在进入函数体之前就被拦下了,k 压根没机会被查找。
8. Q2 Too many letters!:同一个字母,三个不同的值
本讲难度最高的一道,也是最能检验你是不是真的在按规则走而不是在猜。
1 j = 3
2 def h(i):
3 j = 4
4 return lambda z: j * i(z)
5 h(lambda z: j + z)(j)
题目问:第 5 行这个表达式的值是多少?
先把结构看清
第 5 行是 (h(...))(j)——两次调用。外层调用的算子是 h(lambda z: j + z),算子数是 j。按规则 4,算子先于算子数求值,所以先把 h(...) 整个算完,再去算最后那个 j。
代码里有两个 lambda:第 4 行的 lambda z: j * i(z),记作 λ4;第 5 行的 lambda z: j + z,记作 λ5。它们的形参都叫 z,函数体里都有 j,但出生地不同——这就是全部的关窍。
j → 3。def h,Global 绑定 h → func h(i) [parent=Global]。函数体(含第 3、4 行)一行都没跑。<算子>(j),先求算子 h(lambda z: j + z)——这本身又是一个调用表达式,递归地按规则 4 处理。h(...) 的算子 h:Global 查到函数对象。h(...) 的算子数 lambda z: j + z:这是 lambda 表达式,求值它 → 造出 func λ5(z) [parent=Global]。parent 是 Global,因为这次求值发生在全局帧里。 体里的 j 现在不查,等它被调用时才查。h:新建 f1,parent = h 对象的 parent = Global。绑定形参 i → func λ5(z) [parent=Global]。j = 4:在 f1 里新增绑定 j → 4。全局的 j 仍然是 3,一点没动——这是新建绑定,不是修改。return lambda z: j * i(z):先求值 lambda 表达式 → 造出 func λ4(z) [parent=f1]。parent 是 f1,因为这次求值发生在 f1 里。 把它作为 f1 的返回值,f1 退出。func λ4(z) [parent=f1]。j。这次求值发生在全局帧(第 5 行是全局的一条语句),所以查到的是 j → 3。f1 里那个 j = 4 在这里完全看不见——f1 不在全局帧的查找链上。z → 3。j * i(z)。先求左操作数 j:f2 里没有 → 去 parent f1 → 查到 4。i(z):这是个调用。算子 i:f2 没有 → f1 → func λ5(z) [parent=Global]。算子数 z:f2 里就有 → 3。z → 3。j + z:j 在 f3 没有 → 去 parent Global → 3;z 在 f3 → 3。得 3 + 3 = 6。f3 返回 6。j * i(z) = 4 * 6 = 24。f2 返回 24。Global frame
j ──→ 3
h ──→ func h(i) [parent=Global]
func λ5(z) [parent=Global] # 作为实参传进 h,全局没有名字指着它
f1: h [parent=Global]
i ──→ func λ5(z) [parent=Global]
j ──→ 4 # 只属于 f1,遮蔽不了全局的 j
返回值 ──→ func λ4(z) [parent=f1]
f2: λ4 [parent=f1] # ← parent 是 f1
z ──→ 3 # 实参是在 Global 求出的 j,即 3
返回值 ──→ 24 # j(=4,来自 f1) * i(3)(=6)
f3: λ5 [parent=Global] # ← parent 是 Global,不是 f2
z ──→ 3
返回值 ──→ 6 # j(=3,来自 Global) + z(=3)
答案:24。
三个 j 各查到什么
| 代码位置 | 求值时所在的帧 | 查找链 | 查到 |
|---|---|---|---|
第 4 行 λ4 体里的 j | f2 | f2 → f1 | 4 |
第 5 行 λ5 体里的 j | f3 | f3 → Global | 3 |
第 5 行末尾作为实参的 j | Global | Global | 3 |
三处同名,两个不同的值,靠的全是「这个函数是在哪一帧里被造出来的」。λ5 写在第 5 行、在全局被求值,所以它的 j 永远是全局的 3——哪怕它被传进 h、在 f2 里被调用、身边全是 f1 的东西,也没用。
函数带着自己的环境走,不入乡随俗。 λ5 被「送」进 h 里执行,但它查名字时走的仍然是自己出生地那条链。这就是词法作用域的全部含义,也是闭包能可靠工作的原因——你写一个 lambda 时,看得见的名字在它以后跑到哪儿都还看得见;看不见的名字,跑到哪儿也看不见。
改写成 def 版验证
如果上面的推演还是绕,用课上给的建议把两个 lambda 都翻成 def:
j = 3
def h(i):
j = 4
def inner(z):
return j * i(z)
return inner
def arg(z):
return j + z
h(arg)(j)
翻完之后一切都直白了:arg 是在全局 def 的,它的 j 当然是全局那个 3;inner 是在 h 体内 def 的,它的 j 当然是 h 里那个 4。lambda 版之所以难,纯粹是因为它把「def 在哪儿」这条信息藏进了一行密集的表达式里。
误区一:把 f3 的 parent 画成 f2。 这是本题最主要的坑。i(z) 这个调用发生在 f2 里,于是很多人顺手把新帧的 parent 写成 f2,然后 j 查到 4,算出 4 * (4 + 3) = 28。记住:parent 看出生地,不看调用现场。
误区二:以为 f1 里的 j = 4 改掉了全局的 j。 没有。j = 4 在 f1 里新建了一条绑定;全局的 j 还是 3。顺带印证第 3 节:正因为 h 体内有 j = 4 这条赋值,j 在 h 内部是纯局部名字——要是把第 3 行删掉,λ4 体里的 j 就会一路查到 Global 得到 3,答案变成 3 * 6 = 18。
误区三:搞错最后那个实参 j 在哪儿求值。 它写在第 5 行,是全局语句的一部分,所以在全局帧求值,得 3。有人以为「它要传给 λ4,所以应该在 f1/f2 的环境里算」——算子数永远在调用发生的那一帧求值,跟被调函数的环境无关。
误区四:把 i 当成整数。 i 绑的是一个函数,所以 i(z) 是调用而不是乘法。如果误读成 j * i * z 之类,就完全跑偏了。看到形参被当算子用(i(...)),立刻意识到这是高阶函数。
9. Q3 Statements Galore:显示什么 vs 返回什么
这道题不考闭包,考的是 WWPD 的另一半:分清「屏幕上出现了什么」和「表达式的值是什么」。
def f1(x, y):
if x > (x + y):
print(x)
y = x
if x > (x - y):
print(y)
x = y
x + y
def f2(a, b):
if a:
return b and a
else:
return a or b
先看 f1 的两个「陷阱构件」:
- 它有两个独立的
if(不是if/elif)。第一个if执行与否,都要继续考察第二个if;而且第一个if里的y = x会改变第二个if的条件值。 - 最后一行是
x + y,不是return x + y。这个表达式被求值,值被丢掉,函数隐式返回None。
>>> z = f1(3, -5)
x → 3,y → -5。if:求值 x > (x + y) → 3 > (3 + (-5)) → 3 > -2 → True。进入 suite。print(x) → 屏幕出现 3。y = x → 帧里 y 改绑到 3。(现在 x = 3,y = 3)if:求值 x > (x - y) → 3 > (3 - 3) → 3 > 0 → True。注意这里用的是刚被改过的 y。 进入 suite。print(y) → 屏幕出现 3。x = y → x 改绑到 3(本来就是 3,无变化)。x + y → 求值成 6,值被丢弃。函数体结束,没碰到 return,返回 None。z = ...,本身不显示任何东西。屏幕上依次出现:3、3。答案就是这两行,没有第三行。
>>> z
z 绑的是 None,交互式解释器不显示 None。答案:Nothing。
这是 print 与 return 区别的经典考法。f1(3, -5) 在执行过程中打印了两行,但它返回的是 None。打印出去的东西拿不回来——你没法把 print 的输出接住再拿去做运算。想让调用者拿到值,只有 return 一条路。
>>> f1(-4, 7)
x → -4,y → 7。if:-4 > (-4 + 7) → -4 > 3 → False。整个 suite 跳过,print(x) 不执行,y = x 也不执行,y 仍是 7。if:-4 > (-4 - 7) → -4 > -11 → True。进入 suite。print(y) → 屏幕出现 7。x = y → x 改绑到 7。x + y → 14,丢弃。返回 None。None → 解释器不显示。屏幕上只有一行:7。不要再补一个 None——这正是很多人多写的那一行。
>>> f2(print('a'), 10 % 3)
这一问同时考三件事:算子数的求值顺序、print 的返回值、and/or 返回的到底是什么。
f2 → 函数对象。print('a'):它是一个调用,现在就执行 → 屏幕出现 a;print 的返回值是 None,所以第一个实参的值是 None。10 % 3 → 1。a → None,b → 1。if a: → None 是 falsy → 走 else。return a or b:求值左操作数 a = None,falsy → or 继续求右操作数 b = 1,整个表达式的值就是 1(不是 True)。1。这一行是表达式语句且值不是 None → 解释器显示 1。屏幕上依次出现:a、1。
因为 实参在进入函数体之前就被求值了(规则 4 第 2 步)。print('a') 的副作用发生在最早,等 f2 算完返回值、解释器再把返回值打出来时,a 早就在屏幕上了。这就是「及早求值」在 WWPD 题里的具体表现。
四问汇总
| 输入 | 屏幕上出现 | 关键点 |
|---|---|---|
z = f1(3, -5) | 33 | 两个 if 都进;第一个 if 里的 y = x 影响了第二个 if 的条件 |
z | Nothing | f1 结尾是 x + y 不是 return,返回 None |
f1(-4, 7) | 7 | 第一个 if 为假被整块跳过;返回的 None 不显示 |
f2(print('a'), 10 % 3) | a1 | 实参先求值;None or 1 的值是 1 而非 True |
误区一:在 f1(-4, 7) 后面多写一行 None。 交互式解释器对值为 None 的表达式语句什么都不打。这是 Python REPL 的规矩,不是 print 的规矩。
误区二:把两个独立的 if 当成 if/elif。 那样 f1(3, -5) 就只会打印一个 3。两个顶格的 if 是两条独立语句,第一条进没进都不影响第二条会被考察。
误区三:算第二个 if 时还用旧的 y。 f1(3, -5) 里 y 在第一个 suite 里被改成了 3;如果仍按 y = -5 算,会得到 3 > 8 → False,漏掉第二行输出。环境图里的绑定是会变的,必须按执行顺序更新。
误区四:以为 a or b 一定返回布尔值。 None or 1 的值是 1;b and a 在 a 为真时的值是 a。短路运算符返回的是操作数本身,不是 True/False。
误区五:忘了 print 返回 None。 于是把 a 当成字符串 'a'(truthy),走进 if a: 分支,答成 1 and 'a' → 'a'。print 的返回值永远是 None,它的作用全在副作用上。
对每一行输入,按顺序回答三个问题,答完抄下来就是答案:
- 求值这一行的过程中,有没有
print被执行?有的话,按执行顺序把打印内容逐行写下来。 - 这一行整体是不是一条语句(赋值、
def、import……)?是的话到此为止,不再有输出。 - 否则它是一个表达式,求出它的值:值是
None→ 不显示;是函数对象 → 写Function;出错 → 写Error;其他 → 按repr写(字符串带引号)。
10. Q4 Sheep Detectives:被名字骗了
sebastian(lily, winter_lamb, mopple)() 尾巴上那对空括号,说明这个调用的返回值还是个函数。1 winter_lamb = lambda george: 2 ** george
2 lily = lambda detective: lambda: detective
3 mopple = 3
4 def sebastian(f, g, h):
5 return f(g(h))
6 sebastian(lily, winter_lamb, mopple)()
先做三项体检
拿到这种题,先别急着算,花十秒钟看清三件事:
winter_lamb 是函数(一个参数),lily 是函数(一个参数,返回一个函数),mopple 是整数 3,sebastian 是函数(三个参数)。lambda: detective —— 冒号前面是空的,零个形参。所以调用它必须写 x(),一个实参都不能给。() 在调用谁? 在调用 sebastian(...) 的返回值。所以我们期待 sebastian(...) 返回一个零参函数。三个 lambda:第 1 行的记作 λ1(形参 george),第 2 行的外层记作 λ2a(形参 detective),第 2 行的内层记作 λ2b(无形参)。
lambda george: 2 ** george → func λ1(george) [parent=Global];Global 绑定 winter_lamb 指向它。lambda detective: lambda: detective → func λ2a(detective) [parent=Global]。内层的 lambda: detective 现在没有被求值,它是 λ2a 的函数体。Global 绑定 lily。mopple → 3。sebastian → func sebastian(f, g, h) [parent=Global]。<算子>()。先求算子 sebastian(lily, winter_lamb, mopple),这本身是个调用。sebastian → 函数对象。求算子数,从左到右:lily → func λ2a;winter_lamb → func λ1;mopple → 3。f → func λ2a,g → func λ1,h → 3。注意 h 绑的是整数 3,不是函数,尽管名字看起来像个函数。f(g(h))。先求算子 f → func λ2a [parent=Global]。再求算子数 g(h):这是个内嵌调用,算子 g → func λ1,算子数 h → 3。george → 3。求值 2 ** george → 2 ** 3 = 8。f2 返回 8。f(8)。施加 λ2a:新建 f3,parent = Global。绑定 detective → 8。lambda: detective → 现在才造出 func λ2b() [parent=f3]。这是 f3 的返回值。f(g(h)) 的值是 func λ2b() [parent=f3],return 它,f1 退出。func λ2b() [parent=f3]。算子数:一个都没有。detective:f4 里没有 → 去 parent f3 → 查到 8。f4 返回 8。Global frame
winter_lamb ──→ func λ1(george) [parent=Global]
lily ──→ func λ2a(detective) [parent=Global]
mopple ──→ 3
sebastian ──→ func sebastian(f, g, h) [parent=Global]
f1: sebastian [parent=Global]
f ──→ func λ2a(detective) [parent=Global]
g ──→ func λ1(george) [parent=Global]
h ──→ 3 # 是个整数!不是函数
返回值 ──→ func λ2b() [parent=f3]
f2: λ1 [parent=Global]
george ──→ 3
返回值 ──→ 8 # 2 ** 3
f3: λ2a [parent=Global]
detective ──→ 8
返回值 ──→ func λ2b() [parent=f3] # 内层 lambda 此刻才诞生
f4: λ2b [parent=f3]
(无形参)
返回值 ──→ 8 # detective 从 f3 拿到 8
答案:8。如果去掉末尾那对括号,sebastian(lily, winter_lamb, mopple) 的答案就是 Function。
这题的三个设计意图
| 考点 | 怎么体现的 | 不注意会怎样 |
|---|---|---|
| 形参名会遮蔽同名全局名字 | sebastian 的形参叫 f、g、h,其中 h 绑的是整数 | 把 h 当函数,试图「调用」它 |
| 零参函数 | lambda: detective 冒号前是空的 | 看漏了,以为要传参;或忘了末尾 () 是一次真调用 |
| 内层 lambda 的诞生时刻 | λ2b 直到 f3 被创建后才被求值 | 在第 2 行就把 λ2b 画出来,parent 错写成 Global,最后 detective 查不到 → 答成 Error |
lambda: detective 这种没有参数的函数看起来毫无意义——它每次都返回同一个东西,为什么不直接用那个东西?意义在于把一个值「包起来」,推迟到调用时才交出去。lily(8) 返回的是「一个承诺」,只有等你写 () 它才兑现。这个套路后面会以「thunk」「惰性求值」「promise」的名义反复出现(Scheme 的流就是这么做的)。现在你只要接受:空括号也是一次完整的调用,一样要新建帧。
误区一:f(g(h)) 读成「f 和 g 都作用在 h 上」。 它是嵌套:先算 g(h),再把结果喂给 f。求值顺序是从里往外。
误区二:把 f4 的 parent 写成 Global。 λ2b 是在 f3 里被求值出来的(它是 λ2a 的函数体),parent 必须是 f3。写成 Global 就查不到 detective,会得到 NameError: name 'detective' is not defined。
误区三:以为 2 ** george 是异或或者乘法。 Python 里 ** 是幂运算,2 ** 3 = 8。(异或是 ^,本课基本不用。)
误区四:漏掉末尾的 (),答成 Function。 反过来,如果题目问的是不带 () 的版本,答 8 也是错的。读表达式时,先数清楚有几对括号在做调用。
11. 画环境图的操作流程
把前面所有规则压成一套可以照着做的动作。考试时按这个顺序走,基本不会出错。
动手前
逐条语句处理
def:在右侧画一个函数对象 func 名字(形参) [parent=当前帧],在当前帧里加一条绑定,名字箭头指过去。不看函数体。名 = 表达式:先完整求值右边(这一步可能触发一连串调用、新建好几个帧),再在当前帧写下绑定。名字已存在就改箭头,不存在就新加一行。fN: 函数内在名字 [parent=函数对象身上的 parent]。func λ<行号>(形参) [parent=当前帧]。不绑定任何名字,除非外面有赋值或 return 接住它。return:在当前帧最下面加一行「返回值 → …」,然后划掉这一帧,回到调用它的地方。return:返回值那行写 None。别漏,WWPD 全靠它。查名字时
NameError。UnboundLocalError。五个高频错误自查
| 错误 | 症状 | 自查方法 |
|---|---|---|
| 把新帧的 parent 写成调用者的帧 | 闭包题全错,值查到「不该看见」的那个 | 写 parent 时,回到源码找那句 def/lambda,问「它是在哪一帧里被执行的」 |
执行 def 时顺手把函数体也跑了 | 内层函数提前出现,parent 全错 | 看到 def 只写一行绑定就翻页 |
| 忘记多次调用产生多个帧 | a 和 b 共用一个 n | 每写一个 fN,检查编号有没有重复使用 |
漏写返回值 / 漏写 None | WWPD 答案凭空多一行或少一行 | 每划掉一帧前,确认「返回值」那行已经填了 |
| 给内置函数画帧 | 图上冒出 f2: print | print、abs、max、len、pow 一律不画帧 |
画完之后可以用 Python Tutor(61A 定制版)对答案,它会一步步展示帧和箭头,画法和课上的约定基本一致。但顺序不能反:先自己在纸上画一遍,再去对;直接看动画会产生「我懂了」的错觉,考场上手一动就露馅。
本讲小结
| 概念 | 要点 | 典型陷阱 |
|---|---|---|
| frame / binding / environment | frame 是绑定的集合 + 一个 parent 指针;environment 是从当前帧沿 parent 到 Global 的整条链 | 把「环境」当成单个帧 |
| Global frame | 程序起点,没有 parent,查找链的终点 | 给 Global 写 parent |
| 函数的 parent | = def/lambda 被执行时所在的帧,出生即固定 | 写成「调用发生的那一帧」——本讲第一大坑 |
| 新建帧的条件 | 只有调用用户定义的函数(含 lambda)才新建帧 | 给 if/while/内置函数画帧 |
执行 def | 造函数对象 + 在当前帧绑定名字;函数体不执行 | 顺手把内层 def 也「定义」了 |
求值 lambda | 同上但不绑定名字;图上写 λ<行号> | 把它写成外面那个名字(如 func f(x)) |
| 调用三步 | 求值算子 → 从左到右求值算子数 → 施加(新帧 + 绑形参 + 执行体) | 以为实参会「按需」求值;f(1/0) 照样崩 |
| 返回 | 碰到 return 立刻退帧;走完没 return 隐式返回 None | 把 x + y 当成 return x + y |
| 名字查找 | 当前帧 → parent → …… → Global → NameError | 沿「调用栈」而不是「parent 链」查 |
UnboundLocalError | 函数体里任何一处对某名字赋值,该名字在整个函数体内即为局部,且切断向上查找 | 以为「先读后写」没事;以为 += 不算赋值 |
| 闭包 | 函数 + 它出生时的那一帧。只要函数还活着,那一帧就不能销毁 | 以为函数返回后局部变量就没了 |
| WWPD 约定 | 函数对象写 Function;报错写 Error;不显示写 Nothing | 给 None 多写一行;漏掉 print 的输出行 |
本讲四道题的答案速查
| 题 | 表达式 | 答案 | 一句话理由 |
|---|---|---|---|
| Q1 | a | Function | a 绑的是 adder 函数对象 |
a() | Error | 缺一个必需实参 k → TypeError | |
a(1) | 3 | parent 是第一次调用的帧,n = 2 | |
b(1) | 4 | parent 是第二次调用的帧,n = 3 | |
| Q2 | h(lambda z: j + z)(j) | 24 | 4 * (3 + 3):外层 λ 的 j 来自 h 帧,内层 λ 的 j 来自 Global |
| Q3 | z = f1(3, -5) | 打印 3 和 3 | 两个独立 if 都进;y 被改成 3 后第二个条件才成立 |
z | Nothing | 结尾是 x + y 不是 return,值为 None | |
f1(-4, 7) | 打印 7 | 第一个 if 假;返回的 None 不显示 | |
f2(print('a'), 10 % 3) | 打印 a 和 1 | 实参先求值;None or 1 的值是 1 | |
| Q4 | sebastian(lily, winter_lamb, mopple)() | 8 | 2 ** 3 = 8 被零参 lambda 包起来,末尾 () 把它取出来 |
整讲可以压缩成一句话:「查名字沿 parent 链走,parent 由出生地决定。」 你在考场上忘掉别的都行,忘掉这句就全盘皆输。下一讲的递归会让同一个函数的许多帧同时存在,它们的 parent 全是 Global——到时你会发现,正因为规则如此机械,递归才不神秘。
动手练习
五道题,都请先在纸上把环境图画完再展开答案。只在脑子里过一遍不算数——本讲的全部价值就在「能不能真的画对」。
练习 1:形参遮蔽
a = 1
def f(a):
a = a + 1
return a
在解释器里依次输入 f(a) 和 a,各显示什么?
看答案
f(a) 显示 2;随后 a 显示 1。
求值 f(a):先求算子 f,再求算子数 a——在 Global 求值,得 1。新建 f1,parent=Global,绑定形参 a → 1。
f1 里执行 a = a + 1:右边 a + 1 在 f1 里查到 a = 1,得 2;然后在 f1 里把 a 改绑到 2。全局的 a 一点没动——改的是 f1 自己的那条绑定。
return a → 2。
Global frame
a ──→ 1 # 全程没变
f ──→ func f(a) [parent=Global]
f1: f [parent=Global]
a ──→ 1 然后改成 2
返回值 ──→ 2
这里没有 UnboundLocalError,因为 a 是形参——调用时它就已经被绑定了,不存在「读一个没赋值的局部变量」。
练习 2:parent 到底是谁
def outer():
x = 'outer'
return inner
def inner():
return x
x = 'global'
outer()() 的值是什么?如果把最后一行 x = 'global' 删掉呢?
看答案
outer()() 的值是 'global'。删掉最后一行则报 NameError: name 'x' is not defined。
outer() 新建 f1(parent=Global),绑定 x → 'outer',然后求值 inner:f1 里没有,去 Global 查到函数对象,返回它。
接着调用它:新建 f2,parent = inner 对象的 parent = Global。inner 的 def 写在全局,所以它的 parent 是 Global——跟「它是被 f1 返回出来的」毫无关系。
f2 里求值 x:f2 没有 → Global → 'global'。f1 里那个 x = 'outer' 全程不在查找链上。
删掉 x = 'global' 后,Global 里没有 x,查找链走到头 → NameError。注意此时 f1 里明明有一个 x,但 f2 看不见它。 这道题就是为了把「返回」和「parent」这两件事彻底分开:一个函数从哪儿被返回出来,跟它的 parent 是谁没有任何关系。
练习 3:闭包捕获的是「帧」还是「值」
1 def make(x):
2 def helper(y):
3 return x * y
4 x = x + 1
5 return helper
6
7 m = make(3)
m(10) 是多少?是 30 还是 40?
看答案
40。
make(3) 新建 f1,x → 3。第 2–3 行的 def helper 造出 func helper(y) [parent=f1],并在 f1 里绑定 helper。函数体没执行,此刻它并没有把 x 的值 3「抄」下来。
第 4 行 x = x + 1:f1 里的 x 被改绑到 4。第 5 行返回 helper。
调用 m(10):新建 f2,parent=f1,y → 10。求值 x * y:x 在 f2 没有 → 去 f1 → 现在 f1 里的 x 是 4 → 4 * 10 = 40。
Global frame
make ──→ func make(x) [parent=Global]
m ──→ func helper(y) [parent=f1]
f1: make [parent=Global]
x ──→ 3,第 4 行后改成 4
helper ──→ func helper(y) [parent=f1]
返回值 ──→ func helper(y) [parent=f1]
f2: helper [parent=f1]
y ──→ 10
返回值 ──→ 40 # x 从 f1 现场读,读到的是 4
结论:闭包捕获的是「那一帧」,不是「定义时那个值的快照」。 名字是在调用发生的那一刻才去帧里查的,中间被改过就查到改过的值。这正是第 2 节末尾那条「时刻很重要」的注意事项。
练习 4:为什么这个计数器坏了
count = 0
def tick():
count = count + 1
return count
tick() 会发生什么?为什么?不改变「用一个全局变量记数」的思路的前提下,最小的修法是什么?
看答案
报错:
>>> tick()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in tick
UnboundLocalError: local variable 'count' referenced before assignment
tick 的函数体里有 count = ...,于是 count 在整个 tick 内被判定为局部名字,向上查找被切断。执行 count = count + 1 时要先求右边的 count + 1,此时局部 count 还没有值 → 报错。
不要下意识去改成读别的名字——病根不在「读」,在「写」。可行的修法有三种:
- 把值传进来、把结果返回去(本课这一阶段的标准做法):
def tick(count): return count + 1 count = tick(count) - 用
global count声明(Python 允许,但 61A 不用它,因为它破坏了「函数只通过参数和返回值与外界交流」这个抽象)。 - 做成闭包,把计数状态放在外层函数的帧里,配合
nonlocal——这是后面讲可变数据时才会正式展开的写法。
顺带验证一下第 3 节的规则:如果把函数体改成 return count + 1(去掉赋值),tick() 就正常返回 1——因为函数体里再没有对 count 的赋值,count 不再是局部名字,可以一路查到 Global。同一行代码,加不加那个赋值,行为天差地别。
练习 5:数一数一共几个帧
1 def compose(f, g):
2 return lambda x: f(g(x))
3 add1 = lambda n: n + 1
4 dbl = lambda n: n * 2
5 h = compose(add1, dbl)
6 h(5)
(a)h(5) 的值是多少?(b)从第 5 行开始算,一共新建了几个帧?(c)第 2 行那个 lambda 的 parent 是哪一帧?
看答案
(a)11。(b)4 个。(c)compose 的那一帧,即 f1。
逐步走:
func λ3(n) [parent=Global] 和 func λ4(n) [parent=Global],绑给 add1、dbl。这两行没有调用,不新建帧。compose(add1, dbl):新建 f1(parent=Global),f → λ3,g → λ4。lambda x: f(g(x)) → 造出 func λ2(x) [parent=f1],返回它。h 指向它。h(5):新建 f2,parent = λ2 的 parent = f1,x → 5。f(g(x)):算子 f → f2 没有 → f1 → λ3。算子数 g(x):g → f1 → λ4;x → f2 → 5。n → 5,返回 5 * 2 = 10。n → 10,返回 10 + 1 = 11。共 f1、f2、f3、f4 四个帧。注意 f3 和 f4 的 parent 都是 Global(λ3、λ4 在全局被求值),只有 f2 的 parent 是 f1。
如果你数成 3 个,多半是漏了 f1(以为 compose 返回 lambda 不算「真调用」);如果把 f3/f4 的 parent 写成 f2,那就是又踩了「parent 看调用现场」这个坑。