LECTURE 04

环境:名字到底在哪里被找到

把「函数调用时发生了什么」从一句口号变成一套可以在纸上一步步画出来的机械流程——环境图(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 节专门拆这个。

Demo: On which line would the error occur?
课上先做了一个投票再揭晓:出错的不是 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 是「定义处」,不是「调用处」

Environment Diagram Rules 2 of 4
第二组规则是整讲的重心。注意第一条的措辞:函数的 parent frame 是函数被定义时所在的那一帧,括号里特意写了「不是它被调用时所在的帧」。最后一条警告则预告了第 3 节要讲的 UnboundLocalError。

把规则 2 拆成四句可执行的话:

逐步推演:名字查找的算法
1 一个函数对象随身带着一个 parent 指针,指向「def 或 lambda 被执行时所在的那一帧」。这个指针在函数诞生的那一刻就固定了,此后永不改变。
2 每次调用一个用户定义的函数,新开一个局部帧(local frame)。这个新帧的 parent,就是被调函数对象身上记着的那个 parent。
3 要取名字 x 的值:先在当前帧的绑定里找。找到就用,结束。
4 没找到就跳到当前帧的 parent 帧,重复第 3 步;一路向上。走到全局帧还是没有,抛 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'。

逐步推演:caller() 为什么返回 'global'
1 全局帧执行 def show:造出函数对象 func show(),parent = Global(因为这个 def 是在全局帧里执行的)。
2 全局帧执行 def caller:造出 func caller(),parent = Global。
3 调用 caller():新建 f1,f1 的 parent = caller 对象的 parent = Global。在 f1 里执行 x = 'local',f1 多出一条绑定 x → 'local'。
4 f1 里求值 show():算子 show 在 f1 查不到,去 parent(Global)查到函数对象。调用它,新建 f2。f2 的 parent = show 对象的 parent = Global——注意,不是 f1,尽管调用发生在 f1 里。
5 f2 里求值 x:f2 自己没有 x,去 parent(Global)查 → 'global'。f1 里那个 x 根本不在 f2 的查找链上,看不见。
环境图:caller() 执行到 show 体内的一刻
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。

注意这里的因果链,别记反了:

逐步推演:错误是怎么一步步产生的
1 执行 def foo 时,Python 编译 foo 的函数体,扫到第 2 行 x += 1——这是一条赋值(x += 1 等价于 x = x + 1,左边有 x =)。
2 于是 x 被登记为 foo 的局部名字。这个判定对整个函数体生效,包括第 1 行,跟赋值语句写在第几行无关。
3 调用 foo():新建帧 f1,parent = Global,f1 里暂时什么绑定都没有(foo 没有形参)。
4 执行第 1 行 print(x):求值 x。由于 x 是局部名字,Python 只在 f1 里找——找不到值,并且不会向上查全局。抛 UnboundLocalError。
5 第 2 行 x += 1 根本没机会执行。
环境图:foo() 崩溃的那一刻
Global frame
    x    ──→ 10
    bar  ──→ func bar()  [parent=Global]
    foo  ──→ func foo()  [parent=Global]

f1: foo  [parent=Global]
    x    ──→ ???        # 被判定为局部名字,但还没有值
                        # 读它 → UnboundLocalError(不会去 Global 拿 10)

「不会向上查找」是关键。普通情况下查不到就往上走,但一旦某个名字被判定为局部,向上查找这条路就被切断了——要么局部有值,要么报错,没有第三种可能。

常见误区

误区一:把 UnboundLocalError 当成 NameError 处理。 两者的诊断方向完全相反:

NameErrorUnboundLocalError
含义整条查找链上都没有这个名字这个名字是局部的,但还没赋值
典型原因拼错了名字;用了还没定义的函数想读外层变量,同时又在函数里给它赋了值
怎么修检查拼写;确认定义在使用之前换个局部名字;或把外层值当参数传进来

误区二:以为「先读后写就没事」。 顺序不影响判定。哪怕赋值写在函数体最后一行、写在一个永远不会进入的 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 版本变化

本页的报错信息在 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 语句
1 造一个函数对象。这个对象身上带三样东西:形参表、函数体(未执行的代码)、parent 指针 = 当前正在执行这条 def 的那一帧。
2 在当前帧里,把函数名绑定到这个对象上。仅此而已,函数体不执行。

写进环境图时,函数对象记成 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 被调用时才会执行。

环境图:仅执行完 def 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)
环境图:两次调用 make 之后
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 可以互相改写

Q2: Too many letters!
本讲把上一讲的嵌套 lambda 又拉回来练了一遍。课上给的建议很实用:看不懂 lambda 就把它改写成 def;反过来,题目要求写嵌套 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:调用函数时,究竟发生了什么

Environment Diagram Rules 4 of 4
调用规则拆成三步:求值算子(确认真的是个函数)、从左到右求值算子数(把实参算成具体的值,绑到新局部帧的形参上)、然后才施加——执行函数体。最后两条同样重要:每个函数都会返回点什么(显式或隐式),以及碰到 return 立即退出这一帧。

三步流程

逐步推演:求值一个调用表达式 f(a, b)
1 求值算子 f。按第 2 节的查找算法找到它的值,并确认这个值确实是个函数。不是函数就报 TypeError: 'int' object is not callable 之类的错。
2 从左到右求值每个算子数 a、b。它们本身可能是任意复杂的表达式(包括别的调用),要先各自求成一个具体的值。顺序是确定的:先左后右。
3 施加:新建一帧,parent = 第 1 步拿到的函数对象身上的 parent;把形参按位置一一绑定到第 2 步算出的值;在新帧里执行函数体。
4 执行到 return <表达式>:求值该表达式,把值记为这一帧的返回值,立刻退出这一帧,后面的语句一概不执行。
5 若函数体走到底也没碰到 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 555
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 不会挤在同一行,直接用行号就够)。

逐步推演
1 求值外层调用的算子:(lambda x, y: lambda z: x * y + z)。这是一个 lambda 表达式,求值它 → 造出函数对象 func λ1a(x, y) [parent=Global]。注意:它的函数体 lambda z: x * y + z 此刻完全没有被求值,只是被封存着。这个对象也没有绑定到任何名字。
2 求值外层调用的算子数:1 → 1,2 → 2。
3 施加:新建 f1,parent = λ1a 的 parent = Global。绑定 x → 1,y → 2。
4 在 f1 里执行函数体。λ1a 的体是一个表达式 lambda z: x * y + z,求值它 → 造出 func λ1b(z) [parent=f1]。parent 是 f1,因为这个 lambda 是在 f1 里被求值的。这个值就是 f1 的返回值,f1 退出。
5 现在整个表达式变成 <func λ1b(z) [parent=f1]>(3)。求值算子——已经是函数对象了;求值算子数 3 → 3。
6 施加:新建 f2,parent = λ1b 的 parent = f1(不是 Global!)。绑定 z → 3。
7 在 f2 里求值 x * y + z:x 在 f2 没有 → 去 parent f1 → 1;y 同理 → 2;z 在 f2 就有 → 3。算得 1 * 2 + 3 = 5。f2 返回 5。
Global frame
(本例没有任何全局绑定)
λ1afunc λ1a(x, y) [parent=Global]
f1: λ1a [parent=Global]
x1
y2
返回值func λ1b(z) [parent=f1]
f2: λ1b [parent=f1]
z3
返回值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)
环境图:def 版,执行到 f2 返回时
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 翻成 def

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 的四条书写约定

WWPD Conventions
WWPD(What Would Python Display,「Python 会显示什么」)题的作答规范:求值成函数对象就写 Function(不必抄那串 <function ... at 0x...>),会出错就写 Error(不必写出具体是哪种异常),什么都不显示(比如值是 None)就写 Nothing。第一条则是做题方法:动手画环境图。
情况该写为什么不写别的
表达式求值成一个函数对象Function真实输出是 <function make_adder.<locals>.adder at 0x7e13cdd86170>,那串地址每次运行都不同,没有考查价值
求值过程中抛异常Error只要能判断出「会炸」就算对;能顺口说出是哪种异常更好,但不是必须
值是 None,或语句本身不产生显示Nothing交互式解释器不显示 None,也不显示赋值语句的结果
其他照抄解释器会打的那一行注意字符串会带引号:'hi' 而不是 hi
注意:Nothing 不等于 None

Nothing 说的是「屏幕上什么都没多出来」。它可能是因为值是 None,也可能是因为这一行是赋值语句(a = make_adder(2) 本身也什么都不显示)。反过来,如果某个表达式的值是 None,但函数体里有 print,那 print 打出来的东西照样要写——先写打印内容,再写显示的值(此处为 Nothing)。第 9 节 Q3 大量考这个。

逐行算

逐步推演:a = make_adder(2)
1 执行 def make_adder:Global 里绑定 make_adder → func make_adder(n) [parent=Global]。内层的 def adder 没有执行。
2 求值 make_adder(2):算子查到函数对象;算子数 2 → 2;新建 f1,parent=Global,绑定 n → 2。
3 在 f1 里执行函数体第一句 def adder(k): ... —— 造出 func adder(k) [parent=f1],并在 f1 里绑定 adder 指向它。函数体 return n + k 依然没执行。
4 执行 return adder:在 f1 查到 adder 的值(那个函数对象),作为 f1 的返回值,f1 退出。
5 赋值语句把 Global 里的 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。

Global frame
make_adderfunc make_adder(n) [parent=Global]
afunc adder(k) [parent=f1]
bfunc adder(k) [parent=f2]
f1: make_adder [parent=Global]
n2
adderfunc adder(k) [parent=f1]
返回值func adder(k) [parent=f1]
f2: make_adder [parent=Global]
n3
adderfunc 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。

输入答案理由
aFunction求值成函数对象
a()Erroradder 要 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,但出生地不同——这就是全部的关窍。

逐步推演(一):准备阶段
1 第 1 行:Global 绑定 j → 3。
2 第 2–4 行:执行 def h,Global 绑定 h → func h(i) [parent=Global]。函数体(含第 3、4 行)一行都没跑。
3 第 5 行开始求值。最外层是调用 <算子>(j),先求算子 h(lambda z: j + z)——这本身又是一个调用表达式,递归地按规则 4 处理。
4 求 h(...) 的算子 h:Global 查到函数对象。
5 求 h(...) 的算子数 lambda z: j + z:这是 lambda 表达式,求值它 → 造出 func λ5(z) [parent=Global]。parent 是 Global,因为这次求值发生在全局帧里。 体里的 j 现在不查,等它被调用时才查。
逐步推演(二):进入 h
6 施加 h:新建 f1,parent = h 对象的 parent = Global。绑定形参 i → func λ5(z) [parent=Global]。
7 f1 里执行第 3 行 j = 4:在 f1 里新增绑定 j → 4。全局的 j 仍然是 3,一点没动——这是新建绑定,不是修改。
8 f1 里执行第 4 行 return lambda z: j * i(z):先求值 lambda 表达式 → 造出 func λ4(z) [parent=f1]。parent 是 f1,因为这次求值发生在 f1 里。 把它作为 f1 的返回值,f1 退出。
9 至此外层调用的算子算完了,是 func λ4(z) [parent=f1]。
10 现在求外层调用的算子数 j。这次求值发生在全局帧(第 5 行是全局的一条语句),所以查到的是 j → 3。f1 里那个 j = 4 在这里完全看不见——f1 不在全局帧的查找链上。
逐步推演(三):调用 λ4
11 施加:新建 f2,parent = λ4 的 parent = f1。绑定 z → 3。
12 f2 里求值 j * i(z)。先求左操作数 j:f2 里没有 → 去 parent f1 → 查到 4。
13 求右操作数 i(z):这是个调用。算子 i:f2 没有 → f1 → func λ5(z) [parent=Global]。算子数 z:f2 里就有 → 3。
14 施加 λ5:新建 f3,parent = λ5 的 parent = Global(不是 f2,也不是 f1!)。绑定 z → 3。
15 f3 里求值 j + z:j 在 f3 没有 → 去 parent Global → 3;z 在 f3 → 3。得 3 + 3 = 6。f3 返回 6。
16 回到 f2:j * i(z) = 4 * 6 = 24。f2 返回 24。
环境图:Q2 全貌
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 体里的 jf2f2 → f14
第 5 行 λ5 体里的 jf3f3 → Global3
第 5 行末尾作为实参的 jGlobalGlobal3

三处同名,两个不同的值,靠的全是「这个函数是在哪一帧里被造出来的」。λ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)

逐步推演
1 新建 f1 帧,x → 3,y → -5。
2 第一个 if:求值 x > (x + y) → 3 > (3 + (-5)) → 3 > -2 → True。进入 suite。
3 print(x) → 屏幕出现 3。
4 y = x → 帧里 y 改绑到 3。(现在 x = 3,y = 3)
5 第二个 if:求值 x > (x - y) → 3 > (3 - 3) → 3 > 0 → True。注意这里用的是刚被改过的 y。 进入 suite。
6 print(y) → 屏幕出现 3。
7 x = y → x 改绑到 3(本来就是 3,无变化)。
8 x + y → 求值成 6,值被丢弃。函数体结束,没碰到 return,返回 None。
9 这一行是赋值语句 z = ...,本身不显示任何东西。

屏幕上依次出现:3、3。答案就是这两行,没有第三行。

>>> z

z 绑的是 None,交互式解释器不显示 None。答案:Nothing。

注意

这是 print 与 return 区别的经典考法。f1(3, -5) 在执行过程中打印了两行,但它返回的是 None。打印出去的东西拿不回来——你没法把 print 的输出接住再拿去做运算。想让调用者拿到值,只有 return 一条路。

>>> f1(-4, 7)

逐步推演
1 新建帧,x → -4,y → 7。
2 第一个 if:-4 > (-4 + 7) → -4 > 3 → False。整个 suite 跳过,print(x) 不执行,y = x 也不执行,y 仍是 7。
3 第二个 if:-4 > (-4 - 7) → -4 > -11 → True。进入 suite。
4 print(y) → 屏幕出现 7。
5 x = y → x 改绑到 7。
6 x + y → 14,丢弃。返回 None。
7 这一行不是赋值,是一个表达式语句。它的值是 None → 解释器不显示。

屏幕上只有一行:7。不要再补一个 None——这正是很多人多写的那一行。

>>> f2(print('a'), 10 % 3)

这一问同时考三件事:算子数的求值顺序、print 的返回值、and/or 返回的到底是什么。

逐步推演
1 求值算子 f2 → 函数对象。
2 从左到右求值算子数。第一个是 print('a'):它是一个调用,现在就执行 → 屏幕出现 a;print 的返回值是 None,所以第一个实参的值是 None。
3 第二个算子数 10 % 3 → 1。
4 新建帧:a → None,b → 1。
5 if a: → None 是 falsy → 走 else。
6 return a or b:求值左操作数 a = None,falsy → or 继续求右操作数 b = 1,整个表达式的值就是 1(不是 True)。
7 返回 1。这一行是表达式语句且值不是 None → 解释器显示 1。

屏幕上依次出现:a、1。

「a」为什么排在「1」前面

因为 实参在进入函数体之前就被求值了(规则 4 第 2 步)。print('a') 的副作用发生在最早,等 f2 算完返回值、解释器再把返回值打出来时,a 早就在屏幕上了。这就是「及早求值」在 WWPD 题里的具体表现。

四问汇总

输入屏幕上出现关键点
z = f1(3, -5)3
3
两个 if 都进;第一个 if 里的 y = x 影响了第二个 if 的条件
zNothingf1 结尾是 x + y 不是 return,返回 None
f1(-4, 7)7第一个 if 为假被整块跳过;返回的 None 不显示
f2(print('a'), 10 % 3)a
1
实参先求值;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,它的作用全在副作用上。

做 WWPD 题的固定流程

对每一行输入,按顺序回答三个问题,答完抄下来就是答案:

  1. 求值这一行的过程中,有没有 print 被执行?有的话,按执行顺序把打印内容逐行写下来。
  2. 这一行整体是不是一条语句(赋值、def、import……)?是的话到此为止,不再有输出。
  3. 否则它是一个表达式,求出它的值:值是 None → 不显示;是函数对象 → 写 Function;出错 → 写 Error;其他 → 按 repr 写(字符串带引号)。

10. Q4 Sheep Detectives:被名字骗了

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)()

先做三项体检

拿到这种题,先别急着算,花十秒钟看清三件事:

1 每个名字绑的是什么类型? winter_lamb 是函数(一个参数),lily 是函数(一个参数,返回一个函数),mopple 是整数 3,sebastian 是函数(三个参数)。
2 第 2 行那个内层 lambda 有没有形参? lambda: detective —— 冒号前面是空的,零个形参。所以调用它必须写 x(),一个实参都不能给。
3 第 6 行末尾的 () 在调用谁? 在调用 sebastian(...) 的返回值。所以我们期待 sebastian(...) 返回一个零参函数。

三个 lambda:第 1 行的记作 λ1(形参 george),第 2 行的外层记作 λ2a(形参 detective),第 2 行的内层记作 λ2b(无形参)。

逐步推演(一):准备
1 第 1 行:求值 lambda george: 2 ** george → func λ1(george) [parent=Global];Global 绑定 winter_lamb 指向它。
2 第 2 行:求值 lambda detective: lambda: detective → func λ2a(detective) [parent=Global]。内层的 lambda: detective 现在没有被求值,它是 λ2a 的函数体。Global 绑定 lily。
3 第 3 行:Global 绑定 mopple → 3。
4 第 4–5 行:Global 绑定 sebastian → func sebastian(f, g, h) [parent=Global]。
逐步推演(二):调用 sebastian
5 第 6 行最外层是 <算子>()。先求算子 sebastian(lily, winter_lamb, mopple),这本身是个调用。
6 求它的算子 sebastian → 函数对象。求算子数,从左到右:lily → func λ2a;winter_lamb → func λ1;mopple → 3。
7 新建 f1,parent=Global。绑定 f → func λ2a,g → func λ1,h → 3。注意 h 绑的是整数 3,不是函数,尽管名字看起来像个函数。
8 f1 里求值 f(g(h))。先求算子 f → func λ2a [parent=Global]。再求算子数 g(h):这是个内嵌调用,算子 g → func λ1,算子数 h → 3。
9 施加 λ1:新建 f2,parent = λ1 的 parent = Global。绑定 george → 3。求值 2 ** george → 2 ** 3 = 8。f2 返回 8。
10 回到 f1,现在是 f(8)。施加 λ2a:新建 f3,parent = Global。绑定 detective → 8。
11 f3 里求值 λ2a 的体 lambda: detective → 现在才造出 func λ2b() [parent=f3]。这是 f3 的返回值。
12 回到 f1:f(g(h)) 的值是 func λ2b() [parent=f3],return 它,f1 退出。
逐步推演(三):那对空括号
13 第 6 行的算子终于算完了,是 func λ2b() [parent=f3]。算子数:一个都没有。
14 施加:新建 f4,parent = λ2b 的 parent = f3。f4 里没有任何形参绑定(λ2b 无参)。
15 f4 里求值 λ2b 的体 detective:f4 里没有 → 去 parent f3 → 查到 8。f4 返回 8。
环境图:Q4 全貌
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. 画环境图的操作流程

把前面所有规则压成一套可以照着做的动作。考试时按这个顺序走,基本不会出错。

动手前

1 给每一行代码标行号,尤其是有 lambda 的行。后面写 λ 的名字要用。
2 先画一个 Global frame 的大框,放在纸的左上角。右边留一大片空白给函数对象,下面留空间给后续的帧。
3 函数对象画在帧的外面(右侧空白区),用箭头从名字指过去。这样一个对象被多个名字指着时不必重复画。

逐条语句处理

1 看到 def:在右侧画一个函数对象 func 名字(形参) [parent=当前帧],在当前帧里加一条绑定,名字箭头指过去。不看函数体。
2 看到赋值 名 = 表达式:先完整求值右边(这一步可能触发一连串调用、新建好几个帧),再在当前帧写下绑定。名字已存在就改箭头,不存在就新加一行。
3 看到调用表达式:按「算子 → 算子数(左到右)→ 施加」三步走。施加时新建帧,编号取下一个没用过的数字,标题写 fN: 函数内在名字 [parent=函数对象身上的 parent]。
4 看到 lambda 表达式被求值:右侧画 func λ<行号>(形参) [parent=当前帧]。不绑定任何名字,除非外面有赋值或 return 接住它。
5 看到 return:在当前帧最下面加一行「返回值 → …」,然后划掉这一帧,回到调用它的地方。
6 函数体走完没有 return:返回值那行写 None。别漏,WWPD 全靠它。

查名字时

1 用手指按住当前帧,在它的绑定表里找。
2 没有就把手指移到帧标题里写的那个 parent 上,继续找。不要跳到「调用我的那一帧」。
3 到 Global 还没有 → NameError。
4 特例:如果这个名字在当前函数体内有赋值语句,那它是局部名字,第 2 步不执行——本帧没有值就是 UnboundLocalError。

五个高频错误自查

错误症状自查方法
把新帧的 parent 写成调用者的帧闭包题全错,值查到「不该看见」的那个写 parent 时,回到源码找那句 def/lambda,问「它是在哪一帧里被执行的」
执行 def 时顺手把函数体也跑了内层函数提前出现,parent 全错看到 def 只写一行绑定就翻页
忘记多次调用产生多个帧a 和 b 共用一个 n每写一个 fN,检查编号有没有重复使用
漏写返回值 / 漏写 NoneWWPD 答案凭空多一行或少一行每划掉一帧前,确认「返回值」那行已经填了
给内置函数画帧图上冒出 f2: printprint、abs、max、len、pow 一律不画帧
工具

画完之后可以用 Python Tutor(61A 定制版)对答案,它会一步步展示帧和箭头,画法和课上的约定基本一致。但顺序不能反:先自己在纸上画一遍,再去对;直接看动画会产生「我懂了」的错觉,考场上手一动就露馅。

本讲小结

概念要点典型陷阱
frame / binding / environmentframe 是绑定的集合 + 一个 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 的输出行

本讲四道题的答案速查

题表达式答案一句话理由
Q1aFunctiona 绑的是 adder 函数对象
a()Error缺一个必需实参 k → TypeError
a(1)3parent 是第一次调用的帧,n = 2
b(1)4parent 是第二次调用的帧,n = 3
Q2h(lambda z: j + z)(j)244 * (3 + 3):外层 λ 的 j 来自 h 帧,内层 λ 的 j 来自 Global
Q3z = f1(3, -5)打印 3 和 3两个独立 if 都进;y 被改成 3 后第二个条件才成立
zNothing结尾是 x + y 不是 return,值为 None
f1(-4, 7)打印 7第一个 if 假;返回的 None 不显示
f2(print('a'), 10 % 3)打印 a 和 1实参先求值;None or 1 的值是 1
Q4sebastian(lily, winter_lamb, mopple)()82 ** 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 还没有值 → 报错。

不要下意识去改成读别的名字——病根不在「读」,在「写」。可行的修法有三种:

  1. 把值传进来、把结果返回去(本课这一阶段的标准做法):
    def tick(count):
        return count + 1
    
    count = tick(count)
    
  2. 用 global count 声明(Python 允许,但 61A 不用它,因为它破坏了「函数只通过参数和返回值与外界交流」这个抽象)。
  3. 做成闭包,把计数状态放在外层函数的帧里,配合 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。

逐步走:

1 第 3、4 行分别造出 func λ3(n) [parent=Global] 和 func λ4(n) [parent=Global],绑给 add1、dbl。这两行没有调用,不新建帧。
2 第 5 行 compose(add1, dbl):新建 f1(parent=Global),f → λ3,g → λ4。
3 f1 里求值 lambda x: f(g(x)) → 造出 func λ2(x) [parent=f1],返回它。h 指向它。
4 第 6 行 h(5):新建 f2,parent = λ2 的 parent = f1,x → 5。
5 f2 里求值 f(g(x)):算子 f → f2 没有 → f1 → λ3。算子数 g(x):g → f1 → λ4;x → f2 → 5。
6 调用 λ4:新建 f3(parent=Global),n → 5,返回 5 * 2 = 10。
7 调用 λ3:新建 f4(parent=Global),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 看调用现场」这个坑。