CS 61A  /  作业解析
LAB 02

Lab 2:高阶函数与 lambda 表达式ok 13 项通过

函数第一次不再只是"被调用的东西",而变成可以被传来传去、被造出来、被返回的值。这份 lab 的全部难点都集中在一句话上:某个名字,到底在哪一帧里查?

对应讲次:Lecture 3 高阶函数、Lecture 4 环境 官方题面:cs61a.org/lab/lab02 代码:labs/lab02/lab02.py 本仓库 python3 ok --local:13 个测试用例全部通过

0. 这份作业在练什么

Lab 1 里你写的每个函数都是"输入数字、输出数字"。Lab 2 从第一题开始就换了赛道:and / or 不再返回布尔值,函数可以被当成参数塞进另一个函数,函数还可以造出一个新函数再把它返回出来。这三件事看上去毫无关联,但它们共享同一个底层机制——求值规则(evaluation rule)与环境(environment)。这份 lab 的六道必做题里有三道是 What Would Python Display(WWPD,"Python 会显示什么")概念题,一道也不能跳过:它们才是这次真正要考的东西,后面三道编程题反而是概念题的应用。

做之前你需要已经掌握:

  • 调用表达式(call expression)的求值顺序:先求算子(operator),再从左到右求各个算子数(operand),最后把函数用在实参上。
  • 帧(frame)与父帧(parent frame):调用一个函数时新建一帧,帧里绑定形参到实参;查一个名字先查当前帧,查不到去父帧,一路查到全局帧(global frame),再查不到就报 NameError。
  • return 与 print 的区别:return 把值交回给调用者,print 只是往屏幕上打字然后返回 None。这个区别在 WWPD 里会被反复地、恶意地考。

还有一条贯穿全篇、必须先钉死的规则,它是这份 lab 一半错误的来源:

本次要点
  • 函数的父帧,是它被定义时所在的那一帧,不是它被调用时所在的那一帧。(官方 lab checkoff 原话:"Frame in which the function was defined (not called).")这条规则决定了闭包(closure)为什么能记住外层变量。
  • and / or 短路(short-circuit),并且返回的是最后一个真正被求值的操作数本身,不是 True/False。
  • lambda 是表达式,求值出一个函数对象、不改变环境;def 是语句,会在当前帧里绑定一个名字。
  • lambda 的函数体在定义时一行都不执行,只有被调用时才执行。所以 lambda: 1/0 写出来无事发生,一调用才炸。
  • 写高阶函数的目的是 DRY(Don't Repeat Yourself,别写重复代码)与泛化——官方 checkoff 也是这么答的。Q5 count_cond 就是这句话的完整演示。

为什么高阶函数值得单开一次 lab?因为不引入它,你会卡在一个很具体的地方:Q5 里 count_fives 和 count_primes 这两个函数,除了 if 里那一行判断条件不同,其余一模一样——循环变量、计数器、边界、返回值全都重复。以前学过的抽象手段(把重复代码提成函数)在这里失效了,因为不同的部分不是一个数,而是一段逻辑。只有当"逻辑"本身也能作为参数传进去,这段重复才消得掉。这就是高阶函数存在的理由。

1. Q1 WWPD:短路求值 short-circuit

题目要什么

题面给你一串在交互式解释器里输入的表达式,你要说出 Python 会显示什么。规则:如果显示的是 <function...> 就写 Function,如果报错就写 Error,如果什么都不显示就写 Nothing。这题考的是 and / or / not 三个逻辑运算符的精确语义——不是"大概是真是假",而是"到底返回哪个对象"。

先把规则完整写出来,后面每一小问都只是它的实例:

运算符它在检查什么从左到右求值,停在哪返回什么
and是不是所有值都为真第一个假值那个假值本身;若没有假值,返回最后一个操作数
or是不是至少有一个值为真第一个真值那个真值本身;若没有真值,返回最后一个操作数
not—总是要求值它的操作数永远是 True 或 False

另外要记住 Python 里哪些值是假值(falsy):False、0、0.0、''(空字符串)、None、空容器。除此之外全是真值——特别注意 -1 是真值,'0'(字符串零)也是真值。

怎么想到的

新手在这题上的通病是把 and/or 当成"返回 True 或 False 的东西"。这个直觉来自数学里的逻辑联结词,在 Python 里是错的。想通它只需要问一个问题:为什么要设计成返回操作数本身?

因为这样 or 可以当"默认值"用:name or '匿名',name 是空串时自动取 '匿名'。如果 or 只返回 True,这个惯用法就废了。理解了动机,"返回最后一个被求值的东西"这条规则就不用背了。

第二个思维习惯是:把每个表达式当成一次逐操作数的扫描来演算,边扫边问"我现在能下结论了吗"。能下结论就立刻停手,把手头这个值交出去。

题目与逐条推演

第一组(labs/lab02/tests/short-circuit.py 第一个 suite):

>>> True and 13
13
>>> False or 0
0
>>> not 10
False
>>> not None
True
逐步推演
1 True and 13:and 要找第一个假值。求值 True —— 真的,不能下结论,继续。求值 13 —— 真的,操作数用完了。没有假值,于是返回最后一个求值的东西,即 13。不是 True。
2 False or 0:or 要找第一个真值。False 是假的,继续;0 也是假的,用完了。返回最后一个,即 0。注意显示的是 0 而不是 False——它俩在 Python 里相等(0 == False 为真)但不是同一个对象,解释器显示的是这个整数。
3 not 10:not 和那两位不一样,它永远返回布尔值。10 是真值,取反得 False。
4 not None:None 是假值,取反得 True。

第二组,短路真正开始救命的地方:

>>> True and 1 / 0
Error
>>> True or 1 / 0
True
>>> -1 and 1 > 0
True
>>> -1 or 5
-1
>>> (1 + 1) and 1
1
逐步推演
1 True and 1 / 0:and 求值 True,真值,还不能下结论(因为 and 要求全部为真),必须继续求值 1 / 0。这一求值就抛出 ZeroDivisionError。答案 Error。
2 True or 1 / 0:or 求值 True,真值,立刻能下结论(至少有一个真值),于是根本不去碰 1 / 0。返回 True。同样一个 1 / 0,换了运算符就从爆炸变成了无害——这就是"短路"两个字的全部含义:那段代码没有被执行。
3 -1 and 1 > 0:先看优先级,比较运算符 > 比 and 紧,所以这是 (-1) and (1 > 0)。-1 非零,是真值(很多人在这里栽跟头,以为负数是假的),不能下结论,继续求 1 > 0 得 True。操作数用完,返回最后一个:True。
4 -1 or 5:or 求值 -1,是真值,立刻短路,返回 -1 本身。注意不是 True,也不是 5。
5 (1 + 1) and 1:1 + 1 先算出 2,真值,继续;求 1,真值,用完。返回最后一个 1。

接下来这一问是整组里最容易答错的,因为它同时考 print 的返回值和解释器的显示规则:

>>> print(3) or ""
3
''

要产生两行输出,而且两行来源完全不同。拆开看:

逐步推演
1 or 从左往右,第一个操作数是调用表达式 print(3)。要判断它真假,必须先真的调用它。
2 调用 print(3) 产生一个副作用:屏幕上出现 3。这就是第一行输出。它是 print 打的,不是解释器显示的。
3 print 的返回值是 None。None 是假值,所以 or 不能下结论,继续。
4 求值第二个操作数 "",空字符串,也是假值。操作数用完,返回最后一个求值结果 ""。
5 整个表达式的值是 ''。交互式解释器会把非 None 的表达式值用 repr 显示出来,字符串的 repr 带引号,于是第二行是 ''(两个单引号),而不是一个空行。

第三组,把短路和函数调用混在一起:

>>> def f(x):
...     if x == 0:
...         return "zero"
...     elif x > 0:
...         return "positive"
...     else:
...         return ""
>>> 0 or f(1)
'positive'
>>> f(0) or f(-1)
'zero'
>>> f(0) and f(-1)
''
逐步推演
1 0 or f(1):0 是假值,不能下结论,必须调用 f(1)。1 != 0、1 > 0,返回 "positive"。它是真值,or 短路并返回它。显示 'positive'。
2 f(0) or f(-1):调用 f(0) 得 "zero"。非空字符串是真值,or 立刻短路,f(-1) 根本没被调用。返回 'zero'。
3 f(0) and f(-1):f(0) 得 "zero",真值,and 不能下结论,必须调用 f(-1)。-1 既不等于 0 也不大于 0,走 else,返回 ""。操作数用完,返回最后一个:空字符串。解释器显示 ''。

把第 2、3 步对着看:同样两个调用,只换了运算符,一个只调用了一次 f,另一个调用了两次。如果 f 里有 print 或者会修改全局状态,短路就不只是性能问题,而是行为差异。

常见误区
  • 把 True and 13 答成 True。and/or 返回的是操作数对象,只有 not 返回布尔值。
  • 把 -1 当假值。Python 里只有 0 是假的整数,负数照样为真。
  • 把 print(3) or "" 答成一行 3。print 打印是副作用,表达式的值另算,两者会各显示一次。
  • 把最后一行答成空行。空字符串作为表达式的值被解释器显示时走 repr,看到的是 '';只有被 print 出来才是空行。

验证

这组题在本仓库里通过 python3 ok -q short-circuit 校验。ok 的解锁答案存在 labs/lab02/tests/short-circuit.py 里,且经过哈希校验,上面每一条与该文件逐字一致。想自己确认,可以在解释器里逐行敲一遍,尤其把 True and 1 / 0 和 True or 1 / 0 连着敲,亲眼看一次同一段除零代码"炸"与"不炸"的分别。

2. Q2 WWPD:高阶函数 hof-wwpd

题目要什么

这一组只有一段代码,但它是整份 lab 里信息密度最高的一段。它同时考四件事:函数定义语句的执行效果、返回函数、多重赋值、以及名字在什么时候被查。完整代码(与 labs/lab02/tests/hof-wwpd.py 逐字一致):

>>> def cake():
...    print('beets')
...    def pie():
...        print('sweets')
...        return 'cake'
...    return pie
>>> chocolate = cake()
beets
>>> chocolate
Function
>>> chocolate()
sweets
'cake'
>>> more_chocolate, more_cake = chocolate(), cake
sweets
>>> more_chocolate
'cake'
>>> def snake(x, y):
...    if cake == more_cake:
...        return chocolate
...    else:
...        return x + y
>>> snake(10, 20)
Function
>>> snake(10, 20)()
sweets
'cake'
>>> cake = 'cake'
>>> snake(10, 20)
30

怎么想到的

面对这种题,最不该做的事是"顺着读一遍,凭感觉答"。名字起得极具迷惑性(cake、more_cake、chocolate、more_chocolate 相互纠缠),靠感觉必错。正确的做法是老老实实维护一张全局帧的绑定表,每执行一行就更新一次,同时严格区分三件事:

  • 屏幕上打印了什么(print 的副作用)
  • 表达式的值是什么(这决定解释器要不要再显示一行)
  • 哪个名字被绑到了哪个值

第二个关键决定是:识别出 cake 的函数体里有两条语句会产生效果——print('beets') 立刻打印,而 def pie(): ... 是在 cake 的那一帧里创建函数并绑定名字 pie。pie 的函数体在这时一行都不执行;'sweets' 要等到 pie 真的被调用才会打印。看不清这一点,后面全乱。

关键一步

把"定义函数"和"调用函数"在脑子里彻底分开。def 只做两件事:造一个函数对象、把名字绑上去。函数体里写了什么,此刻一律不看。

逐行推演

逐步推演
1 def cake(): ... 执行:全局帧多了绑定 cake → func cake()。屏幕无输出(def 语句的值不是表达式,解释器什么都不显示)。
2 chocolate = cake():先求右边。调用 cake(),新建一帧 f1(parent 是全局帧)。帧里依次执行:print('beets') → 屏幕打出 beets;def pie(): ... → 在 f1 里绑定 pie → func pie(),注意这个 pie 的 parent 是 f1;return pie → 把这个函数对象交回去。然后赋值:全局帧 chocolate → func pie()。整行是赋值语句,赋值语句没有值,所以解释器不额外显示什么。唯一输出就是 beets。
3 chocolate:这是一个表达式,求值得到那个函数对象。解释器显示 <function cake.<locals>.pie at 0x...>,按 WWPD 规则写作 Function。
4 chocolate():调用 pie。新建一帧,parent 是 f1。print('sweets') → 屏幕打出 sweets;return 'cake' → 表达式的值是字符串 'cake'。它不是 None,所以解释器再显示一行 'cake'(带引号,因为是 repr)。两行输出,来源不同。
5 more_chocolate, more_cake = chocolate(), cake:多重赋值,右边整个元组先全部求值完,才做绑定。求 chocolate() → 又打印一次 sweets,值为 'cake';求 cake → 就是那个函数对象本身(没有括号,不是调用,所以不会打印 beets)。然后绑定:more_chocolate → 'cake'(字符串),more_cake → func cake()。这一行只输出 sweets。
6 more_chocolate:值是字符串,显示 'cake'。这里的陷阱是名字叫 more_chocolate,但它存的是字符串 'cake';而名字叫 more_cake 的那个存的却是函数。命名是故意反着来的。
7 def snake(x, y): ...:只是在全局帧绑定 snake。函数体里的 cake、more_cake、chocolate 此刻一个都不查——这是本题的核心考点,名字要等到函数被调用时才在环境里查找。
8 snake(10, 20):新建帧,x=10, y=20。求 cake == more_cake:查 cake(全局帧里是那个函数对象)、查 more_cake(第 5 步绑的同一个函数对象)。两者是同一个对象,相等,条件为真,return chocolate。返回的是函数 pie。解释器显示 Function。整个过程没有任何打印——因为从头到尾没有调用过 cake 或 pie。
9 snake(10, 20)():先算 snake(10, 20) 得到函数 pie(无输出),再对它加一对括号调用它:打印 sweets,返回 'cake',解释器显示 'cake'。
10 cake = 'cake':全局帧里 cake 的绑定被改写成字符串。注意 more_cake 没有跟着变——它早在第 5 步就抓住了那个函数对象,赋值只是给全局帧的 cake 换了个值,跟 more_cake 指向的对象无关。
11 snake(10, 20):再次调用,这次 cake == more_cake 是 'cake' == func cake(),字符串和函数不相等,为假,走 else:return x + y = 30。显示 30。

把全局帧的变化列成表,第 10、11 步为什么翻转就一目了然:

执行到cakemore_cakechocolatemore_chocolate
第 1 行后func cake()———
chocolate = cake() 后func cake()—func pie()—
多重赋值后func cake()func cake()func pie()'cake'
cake = 'cake' 后'cake'func cake()func pie()'cake'

验证

拿第 9 行 snake(10, 20)() 完整走一遍环境图,确认它真的输出两行:

全局帧 Global
  cake          -> func cake()          [parent = Global]
  chocolate     -> func pie()           [parent = f1]
  more_chocolate-> 'cake'
  more_cake     -> func cake()
  snake         -> func snake(x, y)     [parent = Global]

f3: snake            parent = Global
  x = 10, y = 20
  求 cake == more_cake -> 在 Global 查到两者是同一函数对象 -> True
  return chocolate -> 在 Global 查到 func pie()
  返回值 = func pie()

对返回值再加 () 调用:
f4: pie             parent = f1     <-- 注意 parent 不是 f3!
  print('sweets')   -> 屏幕: sweets
  return 'cake'     -> 返回值 = 'cake'

解释器显示表达式的值: 'cake'

f4 的 parent 是 f1(cake 那次调用产生的帧)而不是 f3,正是因为 pie 是在 cake 的帧里被 def 出来的。父帧看定义处,不看调用处。本题的 pie 没有用到 f1 里的任何名字,所以这条规则的威力还没显出来;到了 Q5 count_cond,返回的内层函数要读外层的 condition,这条规则就是全部关键。

常见误区
  • chocolate = cake() 之后以为还会显示点什么。赋值语句没有值,解释器不显示任何东西,那行 beets 是函数体里 print 打的。
  • 第 8 行答成 sweets / 'cake'。snake(10, 20) 只是返回了函数 pie,没调用它,函数体一行都没跑。少写一对括号,输出完全不同。
  • 以为 cake = 'cake' 会让 more_cake 也变成字符串。赋值改的是名字到值的绑定,不是"值本身",more_cake 指着的那个函数对象毫发无损。
  • 以为 def snake 那一刻就检查了 cake 存不存在。函数体里的名字在调用时才查——这也是为什么 snake 两次调用能给出不同答案。

3. Q3 WWPD:lambda 表达式 lambda

题目要什么

这一题分两部分:先是三道单选概念题(ok 里叫 concept suite),再是两组 WWPD。概念题问的正是后面 WWPD 会考的东西,先把它们答清楚,WWPD 就是顺水推舟。

三道概念题

问:下面哪一句描述了 def 语句和 lambda 表达式的区别?(选项来自 labs/lab02/tests/lambda.py)

选项对错为什么
lambda 表达式不会自动把它产生的函数绑定到一个名字上正确答案这正是二者最本质的差别:def f(x): ... 会在当前帧写下 f 这个绑定;lambda x: ... 只是算出一个函数对象,环境一个字都不改
lambda 表达式不能有超过两个参数错参数个数不限,lambda a, b, c, d: ... 完全合法
lambda 表达式不能返回另一个函数错lambda x, y: lambda: x + y 就返回了函数,后面 WWPD 里天天用
def 语句的函数体只能有一行错反了。lambda 的函数体只能是一个表达式;def 想写多少行写多少行

问:lambda a, b: c + d 有几个形式参数(formal parameter)?答案:两个。

这题在考"形参"这个词的定义。冒号左边列出的名字才是形参,这里是 a 和 b。冒号右边的 c、d 是函数体(返回表达式)里用到的名字,它们既不是形参,此刻也不会被查找;等这个函数被调用时,才会顺着环境链去找 c 和 d,找不到就 NameError。顺带一提:这个 lambda 定义出来完全合法,哪怕 c 和 d 根本不存在——不调用就不报错。

问:lambda 表达式的返回表达式什么时候被执行?答案:当这个 lambda 产生的函数被调用的时候。

不是"求值 lambda 表达式的时候",不是"把它赋给名字的时候",也不是"把它传进别的函数的时候"。这句话是后面 print_lambda 那一问的全部答案,也是 lambda: 1/0 写出来不炸的原因。

核心结论

lambda 与 def 造出来的函数在运行时完全是同一种东西(都是函数对象,都有形参、函数体、父帧)。区别只在两点:(1) lambda 是表达式、不绑定名字、没有固有名字(intrinsic name);(2) lambda 的函数体只能是单个表达式,其值自动作为返回值。

lambdadef
语法类别表达式,求值成一个值语句,会改变环境
执行结果造出一个匿名函数,没有固有名字造出一个有固有名字的函数,并把它绑到当前帧的那个名字上
对环境的影响不创建也不修改任何变量创建/覆盖一个名字绑定
可以出现在哪任何需要表达式的地方:赋值右边、实参、算子位置只能作为独立语句
函数体只能是一个表达式任意多条语句

第一组 WWPD

>>> lambda x: x
Function
>>> a = lambda x: x
>>> a(5)
5
>>> (lambda: 3)()
3
>>> b = lambda x, y: lambda: x + y
>>> c = b(8, 4)
>>> c
Function
>>> c()
12
>>> d = lambda f: f(4)
>>> def square(x):
...     return x * x
>>> d(square)
16
逐步推演
1 lambda x: x 单独一行:它是表达式,求值出一个函数对象。解释器把这个值显示出来:<function <lambda> at 0x...>,按规则写 Function。环境没有任何变化——你没法再引用到这个函数了,它当场就被丢弃。
2 a = lambda x: x:赋值语句,右边求值出函数对象,绑到 a。无输出。a(5):调用,新建帧 x=5,返回表达式 x 求值得 5。
3 (lambda: 3)():lambda: 3 是零参数函数。外面的括号只是为了让语法解析对(否则 Python 会把 () 当成 lambda 体的一部分)。这里 lambda 表达式直接充当算子,求值后立刻调用,返回 3。这演示了"lambda 可以出现在任何需要表达式的地方"。
4 b = lambda x, y: lambda: x + y:b 是个两参数函数,它的返回表达式又是一个 lambda。此刻内层 lambda 一次都没被求值。
5 c = b(8, 4):调用 b,新建帧 f1(x=8, y=4,parent=Global)。求值返回表达式 lambda: x + y —— 造出一个函数对象,parent 是 f1,但不执行 x + y。把它返回并绑给 c。
6 c:显示 Function。c():调用它,新建帧 f2(无形参,parent=f1)。求 x + y:f2 里没有 x,去 parent f1 找到 x=8、y=4,得 12。这就是闭包:b 那次调用早已返回,但它的帧因为还被 c 的 parent 指着而活着,8 和 4 被记住了。
7 d = lambda f: f(4):形参 f 被当成函数用。d(square):f 绑到 square 函数对象,f(4) 就是 square(4) = 16。注意实参写的是 square 而不是 square()——传的是函数本身。

第 5、6 步的环境图值得画出来,它是整份 lab 的模板:

Global
  b -> func <lambda>(x, y)   [parent = Global]
  c -> func <lambda>()       [parent = f1]     <-- 关键

f1: b 的调用帧        parent = Global
  x = 8
  y = 4
  return value = func <lambda>()  (parent = f1)

f2: c 的调用帧        parent = f1
  (没有形参)
  求 x + y: f2 无 x -> 上溯 f1 -> x=8, y=4 -> 12
  return value = 12

第二组 WWPD

>>> higher_order_lambda = lambda f: lambda x: f(x)
>>> g = lambda x: x * x
>>> higher_order_lambda(2)(g)
Error
>>> higher_order_lambda(g)(2)
4
>>> call_thrice = lambda f: lambda x: f(f(f(x)))
>>> call_thrice(lambda y: y + 1)(0)
3
>>> print_lambda = lambda z: print(z)
>>> print_lambda
Function
>>> one_thousand = print_lambda(1000)
1000
>>> one_thousand
Nothing
逐步推演
1 higher_order_lambda(2)(g):外层参数叫 f,你传进去的是 2,所以 f = 2。它返回内层函数 lambda x: f(x),此时还没错——函数体没执行。接着用 g 调用它,x = g,开始求 f(x),即 2(g):把整数 2 当函数调用,抛 TypeError: 'int' object is not callable。答案 Error。报错点在第二次调用,不在第一次。
2 higher_order_lambda(g)(2):这回顺序对了。f = g,返回内层函数;再用 2 调用它,x = 2,求 f(x) = g(2) = 2 * 2 = 4。两问放一起是在逼你问:"第一对括号里的实参到底绑给谁?"答案永远是最外层那个函数的形参。
3 call_thrice(lambda y: y + 1)(0):f 绑到"加一"函数,x = 0,求 f(f(f(x)))。从最里往外算:f(0)=1 → f(1)=2 → f(2)=3。答案 3。
4 print_lambda = lambda z: print(z):赋值,无输出。print_lambda 单独一行:显示函数对象,Function。注意到现在为止一次都没打印过东西——返回表达式 print(z) 还没执行过,这正是概念题第三问的答案在起作用。
5 one_thousand = print_lambda(1000):现在函数被调用了,z = 1000,执行 print(1000) → 屏幕打出 1000。print 返回 None,lambda 把这个 None 作为自己的返回值交回去,绑给 one_thousand。屏幕上只有 1000 这一行。
6 one_thousand:值是 None。交互式解释器对 None 什么都不显示。答案 Nothing。
直觉

lambda f: lambda x: f(x) 这种"套娃"读法:每一对括号消耗一层 lambda。h(a)(b) 就是"先把 a 喂给最外层,拿到中间函数,再把 b 喂给它"。数括号和数 lambda 的个数,两边要对得上。

验证

用 call_thrice(lambda y: y + 1)(0) 完整追踪一次,确认答案是 3 而不是 1:

Global
  call_thrice -> func <lambda>(f)  [parent = Global]

f1: call_thrice 的调用帧    parent = Global
  f = func <lambda>(y)   (即 y -> y + 1)
  return value = func <lambda>(x)   parent = f1

f2: 内层函数的调用帧        parent = f1
  x = 0
  求 f(f(f(x))):算子数先算最内层
     f(0): f3  y=0  -> 返回 1
     f(1): f4  y=1  -> 返回 2
     f(2): f5  y=2  -> 返回 3
  return value = 3

f2 里查 f 时,f2 自己只有 x,于是上溯到 parent f1 找到 f。如果 lambda 的 parent 是"调用它的地方"而不是"定义它的地方",这里就会在全局帧里找不到 f 而报 NameError。这个例子是"父帧看定义处"最直接的证据。

常见误区
  • 把 higher_order_lambda(2)(g) 的报错点算在第一对括号上。f = 2 这一步完全合法,函数体还没跑;错误发生在真正去调用 2 的那一刻。
  • 把 one_thousand 那行答成 None。解释器遇到值为 None 的表达式什么都不显示,所以是 Nothing;只有 print(one_thousand) 才会显示 None。
  • 以为 lambda 后面能写 return。lambda x: return x 是 SyntaxError,lambda 体只能是表达式,返回是隐式的。
  • 把 f(f(f(x))) 从外往里算。调用表达式的算子数必须先求值完才能调用,所以永远从最内层开始。

4. Q4 复合恒等函数 composite_identity

题目要什么

写一个函数 composite_identity(f, g),它接收两个单参数函数,返回一个新函数。这个新函数有一个参数 x,当 f(g(x)) 等于 g(f(x)) 时返回 True,否则返回 False。题面允许你假设 g(x) 的输出是 f 的合法输入,反之亦然(所以不用担心类型错误)。

>>> add_one = lambda x: x + 1        # adds one to x
>>> square = lambda x: x**2          # squares x [returns x^2]
>>> b1 = composite_identity(square, add_one)
>>> b1(0)                            # (0 + 1) ** 2 == 0 ** 2 + 1
True
>>> b1(4)                            # (4 + 1) ** 2 != 4 ** 2 + 1
False

翻译成人话:数学里"复合"一般不满足交换律,\(f \circ g \neq g \circ f\)。但对某些具体的 x,两种复合顺序可能碰巧给出同一个值。这个函数就是造一个检测器,专门检查某个 x 是不是这样的"巧合点"。

要点明的边界与陷阱:

  • 返回的不是布尔值,是函数。composite_identity(square, add_one) 本身不知道任何 x,它只是把 f 和 g "存起来",等着以后有人拿 x 来问。
  • 比较用 ==。不要写成 is——两次算出来的可能是数值相等但不是同一个对象(大整数尤其如此),is 会给出错误的 False。
  • doctest 里的 b1(0) 之所以为 True:\((0+1)^2 = 1\),\(0^2 + 1 = 1\),相等;b1(4):\((4+1)^2 = 25\),\(4^2+1 = 17\),不等。

怎么想到的

看到这题的第一反应,大多数人会写出这样一个东西:

def composite_identity(f, g, x):     # ← 错的
    return f(g(x)) == g(f(x))

逻辑没问题,但签名不对。题面给的函数头是 def composite_identity(f, g):,只有两个参数;doctest 里也明明白白分了两步:先 b1 = composite_identity(square, add_one),再 b1(0)。这两步之间隔着一次赋值,说明第一次调用必须吐出一个还能再调用的东西。

于是问题变成:怎么让一个函数返回另一个函数?这正是 lab 题面里 multiply_by 演示的模式:

def multiply_by(m):
    def multiply(n):
        return n * m
    return multiply

照着这个骨架填空即可:外层拿 f 和 g,内层拿 x,内层的函数体就是那句"逻辑没问题"的比较。这里有个必须想清楚的疑问:内层函数里的 f 和 g 从哪来?它们不是内层函数的形参,内层帧里没有。答案是环境链:内层函数是在 composite_identity 的帧里被 def 出来的,所以它的 parent 就是那一帧,而 f、g 正好绑在那一帧上。这就是闭包在干活。

关键一步

看到 doctest 形如 h = foo(a, b) 然后 h(c),立刻断定 foo 要返回函数。参数被分成了两批送达,第一批只能靠闭包记住。

还可以问一句:能不能用 lambda 写成一行?能:return lambda x: f(g(x)) == g(f(x)),效果完全一样。本仓库通过 ok 的版本用的是 def 内层函数写法,因为它更容易加注释、也更容易在环境图里指认。Q6 会专门练 lambda 写法。

代码

以下与 labs/lab02/lab02.py 中通过 ok 的实现逐字一致:

def composite_identity(f, g):
    """
    Return a function with one parameter x that returns True if f(g(x)) is
    equal to g(f(x)). You can assume the result of g(x) is a valid input for f
    and vice versa.

    >>> add_one = lambda x: x + 1        # adds one to x
    >>> square = lambda x: x**2          # squares x [returns x^2]
    >>> b1 = composite_identity(square, add_one)
    >>> b1(0)                            # (0 + 1) ** 2 == 0 ** 2 + 1
    True
    >>> b1(4)                            # (4 + 1) ** 2 != 4 ** 2 + 1
    False
    """
    # The returned function compares the two orders of composition on its own x.
    def identity_check(x):
        return f(g(x)) == g(f(x))
    return identity_check

逐行:

  • def identity_check(x): —— 内层函数只要一个参数 x,因为 f 和 g 已经由外层帧提供了。名字取什么都行(叫 h 也通过),但取个说人话的名字有价值:出错时 traceback 里会显示这个名字。
  • return f(g(x)) == g(f(x)) —— 求值顺序是:先算 g(x),把结果喂给 f;再算 f(x),把结果喂给 g;最后 == 比较两个值,得到 True 或 False,直接 return 出去。不要写成 if ...: return True else: return False——比较表达式本身就是布尔值,多写四行只是噪音。
  • return identity_check —— 没有括号。写成 return identity_check() 会立刻调用它,而调用需要一个 x,于是报 TypeError: identity_check() missing 1 required positional argument: 'x'。这里返回的是函数对象本身。

另外注意 def identity_check 这条语句每次调用 composite_identity 都会重新执行一次,造出一个新的函数对象,它的 parent 指向这一次的调用帧。所以 composite_identity(square, add_one) 和 composite_identity(add_one, square) 得到的是两个互不干扰的函数,各自记着自己的 f 和 g。

验证

手动追踪 b1(4),目标是得到 doctest 里的 False。

Global
  add_one -> func <lambda>(x)    x -> x + 1     [parent = Global]
  square  -> func <lambda>(x)    x -> x ** 2    [parent = Global]
  b1      -> func identity_check(x)             [parent = f1]

f1: composite_identity 的调用帧      parent = Global
  f = func <lambda>(x)  (square)
  g = func <lambda>(x)  (add_one)
  identity_check -> func identity_check(x)   [parent = f1]
  return value = func identity_check(x)

f2: identity_check 的调用帧          parent = f1
  x = 4
  求 f(g(x)) == g(f(x))

在 f2 里逐步算:

逐步推演
1 算 == 左边 f(g(x))。要先求算子数,即先算 g(x)。查 g:f2 里没有 → 上溯 parent f1 → 找到 add_one。查 x:f2 里有,4。调用 add_one(4) → 新帧 x=4,返回 4 + 1 = 5。
2 把 5 喂给 f(在 f1 里查到是 square):square(5) → 5 ** 2 = 25。左边 = 25。
3 算右边 g(f(x))。先 f(4) = square(4) = 16。
4 再 g(16) = add_one(16) = 17。右边 = 17。
5 25 == 17 → False。return False,f2 结束。解释器显示 False,与 doctest 一致。

同法追 b1(0):左边 square(add_one(0)) = square(1) = 1,右边 add_one(square(0)) = add_one(0) = 1,1 == 1 → True。

常见误区
  • 返回值忘了去括号:return identity_check() → TypeError: identity_check() missing 1 required positional argument: 'x'。
  • 把内层函数写在外面:如果把 identity_check 定义在模块顶层,它的 parent 变成全局帧,函数体里查 f、g 会得到 NameError: name 'f' is not defined。内层函数必须写在外层函数体里,才能借到外层的名字。
  • 直接返回比较结果:return f(g(x)) == g(f(x)) 写在 composite_identity 里(没有内层函数)→ NameError: name 'x' is not defined,因为这一层根本没有 x。
  • 用 is 代替 ==:小整数在 CPython 里有缓存,b1(0) 可能侥幸对;换成大数就会莫名其妙返回 False。判断"值相等"永远用 ==。

5. Q5 条件计数 count_cond

题目要什么

题面先给你看两个已经写好的函数。count_fives(n) 数的是:在 1 到 n(含 n)里,有多少个 i 使得 sum_digits(n * i) 等于 5。count_primes(n) 数的是:1 到 n 里有多少个质数。两者的代码结构如下(题面里被网页排版截断了,还原成可读形式):

i = 1
count = 0
while i <= n:
    if <某个条件>:
        count += 1
    i += 1
return count

除了 <某个条件> 那一行,其余完全相同。题目要你把这段共同骨架抽出来,写成 count_cond(condition):它接收一个两参数谓词函数(predicate function) condition(n, i)——所谓谓词函数就是返回 True 或 False 的函数——然后返回一个单参数函数,这个函数拿到 n 之后,数出 1 到 n 里满足 condition(n, i) 的 i 有几个。

>>> count_fives = count_cond(lambda n, i: sum_digits(n * i) == 5)
>>> count_fives(10)   # 50 (10 * 5)
1
>>> count_fives(50)   # 50 (50 * 1), 500 (50 * 10), 1400 (50 * 28), 2300 (50 * 46)
4
>>> is_i_prime = lambda n, i: is_prime(i) # need to pass 2-argument function into count_cond
>>> count_primes = count_cond(is_i_prime)
>>> count_primes(2)    # 2
1
>>> count_primes(20)   # 2, 3, 5, 7, 11, 13, 17, 19
8

必须点明的三处边界:

  • 范围含两端:从 i = 1 数到 i = n,循环条件是 i <= n 而不是 i < n。count_primes(2) 答案是 1(就是 2 本身),若写成 i < n 会得到 0。
  • condition 的两个参数顺序是 (n, i):第一个是上界 n,第二个是当前被检查的数 i。写反了 count_fives 会全错(因为 n * i 虽然乘法可交换,但 sum_digits(n*i) 里的 n 和 i 在别的条件下并不对称)。
  • 即使条件只用到 i,也必须写成两参数:这就是 doctest 里 is_i_prime = lambda n, i: is_prime(i) 的用意——它多接一个用不上的 n,只为满足 count_cond 约定的接口。这是"适配器"思想的第一次亮相。

怎么想到的

先说一条走不通的路,因为很多人第一反应就是它:

def count_cond(condition, n):        # ← 错的
    i, count = 1, 0
    while i <= n:
        ...

这样写,逻辑对,但 doctest 第一行 count_fives = count_cond(lambda n, i: ...) 就跑不通了——只给了一个实参。题目的接口设计是有意的:条件和上界在不同时刻到达。条件是"这类计数问题"的定义,上界是"这一次计数"的参数。count_cond 拿到条件后应该产出一个专用的计数器,之后这个计数器可以被反复调用(count_fives(10)、count_fives(50)),不必每次都重报条件。

认清这一点后,套路和 Q4 一模一样:外层收 condition,返回内层函数,内层收 n,函数体就是那段共同骨架,把 <某个条件> 换成 condition(n, i)。

接下来是这题唯一需要动脑的地方:内层函数该用 def 还是 lambda?必须用 def。因为内层要跑一个 while 循环、要维护 count 变量、要有多条语句,而 lambda 的函数体只能是单个表达式,装不下循环。这也回答了"既然 lambda 更短,为什么不都用 lambda":能不能用 lambda,取决于函数体是不是一个表达式。

关键一步

把"重复的代码"和"变化的部分"分开:重复的是循环骨架,变化的是那个 if 的判断。变化的部分不是一个数,而是一段逻辑——只有把逻辑打包成函数当参数传,才能把它抽出去。这就是官方 checkoff 说的 DRY 和泛化。

还有一个容易忽略的小决定:内层的循环变量为什么必须叫 i 且从 1 开始、每轮 +1?因为 condition 是外部提供的,它对 i 的含义有约定("1 到 n 之间的那个数")。如果你把 i 从 0 开始,count_fives(10) 会多检查一个 n * 0 = 0,sum_digits(0) 是 0,不等于 5,恰好不影响结果;但 count_primes 会多检查 is_prime(0),行为就未必对了。老实按 1 到 n。

代码

以下与 labs/lab02/lab02.py 中通过 ok 的实现逐字一致(含题面给定的两个辅助函数):

def sum_digits(y):
    """Return the sum of the digits of non-negative integer y."""
    total = 0
    while y > 0:
        total, y = total + y % 10, y // 10
    return total

def is_prime(n):
    """Return whether positive integer n is prime."""
    if n == 1:
        return False
    k = 2
    while k < n:
        if n % k == 0:
            return False
        k += 1
    return True

def count_cond(condition):
    def counter(n):
        i, count = 1, 0
        while i <= n:
            if condition(n, i):
                count += 1
            i += 1
        return count
    return counter

逐行:

  • def counter(n): —— 内层函数只收 n。condition 不在这一层,靠环境链从外层帧取。
  • i, count = 1, 0 —— 同时初始化,等价于两条赋值语句。i 从 1 开始,是因为题目要求"从 1 数到 n"。
  • while i <= n: —— 用 <= 保证 n 本身也被检查。count_primes(2) 要数到 i=2 才能得到 1。
  • if condition(n, i): —— 关键的一行。condition 在 counter 帧里查不到,上溯到 count_cond 的帧找到。参数顺序严格按题目约定 (n, i)。这里不需要写 == True,if 本来就按真假判断,而且谓词函数只保证返回真值/假值。
  • count += 1 —— 只在条件成立时加。
  • i += 1 —— 必须放在 if 外面。放进 if 里,条件一旦不成立 i 就不再变,程序死循环。
  • return count —— 缩进在 while 外面。缩进到 while 里面,第一轮就返回,count_primes(20) 会得到 0。
  • return counter —— 返回函数对象,无括号。

验证

追踪 count_fives(10),目标是 doctest 里的 1。先看环境:

Global
  count_cond -> func count_cond(condition)   [parent = Global]
  count_fives-> func counter(n)              [parent = f1]

f1: count_cond 的调用帧      parent = Global
  condition -> func <lambda>(n, i)   即 sum_digits(n * i) == 5
  counter   -> func counter(n)       [parent = f1]
  return value = func counter(n)

f2: counter 的调用帧          parent = f1
  n = 10, i 从 1 开始, count 从 0 开始
  每轮的 condition 都在 f1 里查到那个 lambda

逐轮列表(n = 10,条件为 sum_digits(10 * i) == 5):

in * isum_digits条件本轮后 count
1101+0 = 1否0
2202+0 = 2否0
3303否0
4404否0
5505+0 = 5是1
6606否1
7707否1
8808否1
9909否1
101001+0+0 = 1否1

i 变成 11 时 11 <= 10 为假,退出循环,return 1。与 doctest 一致。注意如果循环写成 i < n,i=10 那轮不会跑——本例碰巧结果还是 1,但 count_primes(2) 就会从 1 掉到 0,ok 会直接报错。

再验一个 count_primes(4),doctest 说是 2:条件是 is_prime(i),与 n 无关。i=1 → is_prime(1) 显式返回 False(这个特判很重要,1 不是质数);i=2 → k 从 2 开始,2 < 2 为假,循环一次不进,返回 True,count=1;i=3 → k=2,3 % 2 = 1,k=3,3 < 3 假,返回 True,count=2;i=4 → k=2,4 % 2 == 0,返回 False。退出,返回 2。

核心结论

count_cond 是一台"计数器工厂"。喂进去一个判断标准,吐出来一台专用计数器。同一段循环骨架,配上不同的 condition,就变成数五、数质数、数任何东西的函数——这就是高阶函数的经济价值:把重复的控制流写一次,把变化的部分参数化。

常见误区
  • i += 1 写进 if 里:条件第一次不成立时程序卡死,终端毫无输出、只能 Ctrl+C。这是本题最常见的死循环来源。
  • return count 缩进错位:缩进到 while 体内,函数第一轮就返回,几乎所有 doctest 都错。
  • 调用条件时只传一个参数:写成 condition(i) → TypeError: <lambda>() missing 1 required positional argument: 'i'。约定就是两参数。
  • 参数顺序写反:condition(i, n) 在 count_fives 上侥幸能过(乘法可交换),但在别的条件上会错,ok 的 count_primes 用例会暴露它。
  • 试图用 lambda 写内层:return lambda n: ... 里塞不进 while 循环,会撞上 SyntaxError。函数体有多条语句时只能用 def。

6. Q6 字符串变换器 caesar_generator

题目要什么

造一个凯撒密码(Caesar cipher)函数的工厂。caesar_generator(num, op) 接收一个整数 num 和一个运算 op(只会是 operator 模块里的 add 或 sub),返回一个单参数函数,它把传入的字母"旋转" num 位。硬性限制:你的函数体只能有一条 return 语句,且必须用 lambda 表达式。

题目给了两个现成的工具函数(在 lab02.py 顶部):

  • letter_to_num(letter):把字母映射成数字。小写 'a'–'z' → 0–25,大写 'A'–'Z' → 26–51。
  • num_to_letter(num):反过来,把 0–51 的数字变回字母。它内部会先做 num = num % 52,所以越界会自动绕回。
>>> letter_to_num('a')
0
>>> letter_to_num('c')
2
>>> num_to_letter(3)
'd'

>>> caesar2 = caesar_generator(2, add)
>>> caesar2('a')
'c'
>>> brutus3 = caesar_generator(3, sub)
>>> brutus3('d')
'a'

要点明的地方:op 是函数不是符号。from operator import add, sub 引进来的 add(x, y) 就是 x + y,sub(x, y) 就是 x - y。之所以要把加减法包成函数传进来,正是因为"运算"这个变化的部分必须能当参数传——和 Q5 传 condition 是同一个道理。至于取模绕回的边界(比如 caesar_generator(3, sub) 作用在 'a' 上得到 -3),num_to_letter 里的 % 52 已经替你处理了,你不用自己写。

怎么想到的

这题的解法几乎是被三条限制逼出来的,值得逐条看它怎么收窄可能性:

1 "返回一个单参数函数" → 又是 Q4、Q5 的模式:外层 caesar_generator 收 num 和 op,返回的东西收一个字母。
2 "函数体只能有一条 return,且用 lambda" → 排除内层写 def。那内层能不能塞进一个 lambda?能——因为整个变换是一个表达式,没有循环也没有分支。
3 题目给了 letter_to_num 和 num_to_letter → 明摆着的暗示:字母不能直接做算术,得先转成数、算完再转回来。三明治结构:转数 → 运算 → 转字母。

把三步拼起来,内层函数的返回表达式就是 num_to_letter(op(letter_to_num(letter), num))。写的时候确实容易在参数顺序上卡一下:op 的两个参数谁在前?看 doctest:brutus3 = caesar_generator(3, sub),brutus3('d') 应得 'a'。'd' 是 3,'a' 是 0,所以要算的是 3 - 3 = 0,即 sub(字母的数, num) —— 字母在前,num 在后。写反成 sub(num, 字母的数) 在这个例子上恰好也是 0(因为两者都是 3),但换 brutus3('e') 就露馅:正确是 4-3=1 → 'b',写反是 3-4=-1 → %52 = 51 → 'Z'。所以别拿对称的例子验参数顺序。

关键一步

"把值转到一个方便计算的表示域里算,算完再转回来"是个通用招式:字母 ↔ 数字、日期 ↔ 时间戳、坐标 ↔ 极坐标。它和后面要学的数据抽象是一脉相承的——中间那层运算根本不需要知道自己处理的是字母。

代码

与 labs/lab02/lab02.py 中通过 ok 的实现逐字一致:

from operator import add, sub

def caesar_generator(num, op):
    return lambda letter: num_to_letter(op(letter_to_num(letter), num))

一行里做了四件事,从里往外读:

  • letter_to_num(letter) —— 把传进来的字母变成 0–51 的整数。letter 是 lambda 的形参。
  • op(..., num) —— 用外层传进来的运算函数去算。op 和 num 都不是 lambda 的形参,它们要在环境链上溯一层,到 caesar_generator 的调用帧里找。这就是闭包在这题里的用途:这个 lambda 被 return 出去、在别处被调用时,它依然记得 num=2 和 op=add。
  • num_to_letter(...) —— 把结果变回字母。它内部的 % 52 顺手解决了溢出和负数。
  • return lambda ... —— 整条语句是唯一的 return,符合题目"只能有一条 return 语句"的要求。不要在 lambda 后面加括号,这里返回的是函数本身。

为什么用 lambda 而不是内层 def?除了题目要求,本质原因是函数体确实只是一个表达式。Q5 那题用不了 lambda,是因为它需要 while 循环;这里不需要,所以 lambda 是更贴切的表达。判断标准始终是"函数体能不能写成单个表达式",而不是"哪个更时髦"。

还要注意 letter_to_num 和 num_to_letter 是全局帧里的名字。lambda 的帧里没有它们,caesar_generator 的帧里也没有,一路上溯到全局帧才找到——环境链天然支持这种跨层查找,不需要额外传参。

验证

追踪 caesar2('a'),目标 'c'。

Global
  add, sub          -> operator 模块里的两个函数
  letter_to_num     -> func letter_to_num(letter)
  num_to_letter     -> func num_to_letter(num)
  caesar_generator  -> func caesar_generator(num, op)  [parent = Global]
  caesar2           -> func <lambda>(letter)          [parent = f1]

f1: caesar_generator 的调用帧      parent = Global
  num = 2
  op  = func add(a, b)
  return value = func <lambda>(letter)  [parent = f1]

f2: caesar2 的调用帧               parent = f1
  letter = 'a'
逐步推演
1 在 f2 里求 num_to_letter(op(letter_to_num(letter), num))。最内层先算:letter_to_num('a')。查 letter_to_num:f2 没有 → f1 没有 → Global 找到。
2 letter_to_num('a'):'a'.isupper() 为假,走 return ord('a') - LOWERCASE_SHIFT = 97 - 97 = 0。
3 求 op:f2 没有 → f1 找到 add。求 num:f2 没有 → f1 找到 2。调用 add(0, 2) = 2。
4 num_to_letter(2):int(2) 成功;2 % 52 = 2;2 > 25 为假,走 return chr(2 + 97) = chr(99) = 'c'。
5 f2 返回 'c',与 doctest 一致。

再验 brutus3('d'):letter_to_num('d') = ord('d') - 97 = 100 - 97 = 3;sub(3, 3) = 0;num_to_letter(0) → 0 % 52 = 0,不大于 25,chr(0 + 97) = 'a'。得 'a',正确。

顺便看一个能体现 % 52 价值的例子:brutus3('a')。letter_to_num('a') = 0,sub(0, 3) = -3,num_to_letter(-3) → -3 % 52 在 Python 里是 49(Python 的取模结果符号跟除数走,这点和 C 不同),49 > 25,走 chr(49 - 26 + 65) = chr(88) = 'X'。所以字母表在 52 个位置上首尾相接,小写的开头绕回到大写的尾部——这是 num_to_letter 的既定行为,不是 bug。

常见误区
  • 把 op 当运算符号用:写成 letter_to_num(letter) op num → SyntaxError。op 是个函数名,只能用调用语法 op(a, b)。
  • 参数顺序写反:op(num, letter_to_num(letter))。在 add 下永远看不出来(加法可交换),在 sub 下且两数不等时才出错,非常隐蔽。
  • 忘了转回字母:直接 return lambda letter: op(letter_to_num(letter), num),doctest 期待 'c' 却得到 2。
  • 写成 return lambda letter: ...() 或在函数体里先算好:题目明确要求"只包含一条 return 语句",多写辅助赋值会被判不符合要求(虽然 ok 只测行为,但这是考试题型的规矩)。
  • 自己再写一遍 % 26:num_to_letter 已经做了 % 52,你再模一次 26 会把大写字母全压成小写。

7. Q7 回文数(选做) is_palindrome

题目要什么

这是选做题,但它是考试题型的标准形态:给你一个骨架,只准填空,不准改动其他任何部分。题面原文:"In the spirit of exam style questions, please do not edit any parts of the function other than the blanks."

def is_palindrome(n):
    x, y = n, 0
    f = lambda: _____
    while x > 0:
        x, y = _____, f()
    return y == n

回文数就是正着读反着读一样的数:12321 是,42 不是,2015 不是,55 是。

>>> is_palindrome(12321)
True
>>> is_palindrome(42)
False
>>> is_palindrome(2015)
False
>>> is_palindrome(55)
True

怎么想到的

填空题的解法不是"想一个算法",而是从没被挖空的部分反推被挖空的部分必须是什么。三条线索:

1 最后一行是 return y == n。n 全程没被改过,所以循环结束时 y 必须等于"n 的数位反转"。整个算法就是"把 n 倒过来,看倒过来是不是它自己"。
2 x, y = n, 0 且循环条件是 x > 0。要让循环终止,x 必须越来越小直到 0。对整数做这件事的标准手法是 x // 10(每次砍掉最低一位)。所以第二个空是 x // 10。
3 y 每轮变成 f(),而 f 是零参数 lambda。它要"把 x 的最低位接到 y 的末尾"。十进制里"往末尾接一位"的标准写法是 y * 10 + 新的一位,新的一位就是 x % 10。所以第一个空是 y * 10 + x % 10。

这里有个真正需要停下来想清楚的问题,也是这题为什么要用 lambda 的全部原因:f 是在循环开始之前就定义好的,为什么它每轮返回的值不一样?

因为 lambda 的函数体在定义时一行都不执行(回看 Q3 的概念题第三问)。f = lambda: y * 10 + x % 10 这一步只是造了个函数对象,y 和 x 在此刻没有被求值、没有被"快照"。等到循环里每次写 f(),函数体才真的执行一次,那时才去环境里查 x 和 y 当下的值。f 的 parent 就是 is_palindrome 的调用帧,而 x、y 正绑在那一帧上,每轮循环都被重新绑定。所以 f 读到的永远是最新值。

核心结论

闭包捕获的是帧(frame),不是捕获时的值。外层帧里的名字之后被改了,闭包再调用时读到的就是改后的新值。这条规则在这题里是"特性",但在别的场景下经常变成 bug 的来源(比如在循环里造一堆 lambda,结果它们全都读到同一个最终值)。

还有一个必须看清的点:x, y = x // 10, f() 是多重赋值,右边先整个求值完再一起绑定。这意味着 f() 里读到的 x 还是本轮更新前的 x。如果拆成两行写:

x = x // 10      # ← 这样写就错了
y = f()

那么 f() 读到的 x 已经被砍掉一位,x % 10 取到的是倒数第二位,整个反转全乱。骨架用多重赋值不是为了好看,是必需的。

代码

与 labs/lab02/lab02.py 中通过 ok 的实现逐字一致:

def is_palindrome(n):
    x, y = n, 0
    f = lambda: y * 10 + x % 10
    while x > 0:
        x, y = x // 10, f()
    return y == n

逐行:

  • x, y = n, 0 —— x 是"还没处理完的部分",y 是"已经反转好的部分"。n 保持不动,留着最后比。
  • f = lambda: y * 10 + x % 10 —— 零参数。x 和 y 都不是形参,而是从外层帧读的自由变量(free variable)。y * 10 把已反转部分整体左移一位腾出个位,x % 10 取出 x 当前的最低位填进去。
  • while x > 0: —— x 被 // 10 削到 0 时停。顺带说明为什么 is_palindrome(0) 这种输入不在 doctest 里:n=0 时循环一次都不进,y 保持 0,return 0 == 0 为 True,恰好也对。
  • x, y = x // 10, f() —— 右边同时求值:x // 10 用旧 x,f() 也用旧 x 和旧 y。然后一起绑定。
  • return y == n —— 比较反转结果和原数。

验证

追踪 is_palindrome(12321),逐轮记录。每轮右边都用本轮开始时的 x、y:

轮次轮初 x轮初 yf() = y*10 + x%10轮末 x轮末 y
进入前123210———
11232100*10 + 1 = 112321
2123211*10 + 2 = 1212312
31231212*10 + 3 = 12312123
412123123*10 + 2 = 123211232
5112321232*10 + 1 = 12321012321

x 变成 0,0 > 0 为假,退出。return 12321 == 12321 → True。

再看反例 is_palindrome(2015):

轮次轮初 x轮初 yf()轮末 x轮末 y
1201500*10 + 5 = 52015
220155*10 + 1 = 512051
3205151*10 + 0 = 5102510
42510510*10 + 2 = 510205102

5102 == 2015 为 False。注意第 3 轮里那个中间的 0:y 从 51 变成 510,前导的 0 在数值上"消失"了,但因为它在中间位置所以不影响。真正会出问题的是末尾带 0 的数,比如 is_palindrome(100) 会反转成 1,1 != 100 → False——这个答案恰好是对的(100 倒过来是 001,确实不算回文),所以算法无需特判。

最后画一下环境,确认 f 到底在哪查名字:

Global
  is_palindrome -> func is_palindrome(n)   [parent = Global]

f1: is_palindrome 的调用帧      parent = Global
  n = 12321
  x = 12321 -> 1232 -> 123 -> 12 -> 1 -> 0     (每轮被重新绑定)
  y = 0 -> 1 -> 12 -> 123 -> 1232 -> 12321
  f = func <lambda>()   [parent = f1]

f2..f6: 每次 f() 调用产生的帧    parent = f1
  没有形参,帧里空的
  求 y * 10 + x % 10 -> 两个名字都在 f1 里查,读到的是"此刻"的值
常见误区
  • 给 f 加参数:写成 f = lambda x, y: ...,但骨架里调用的是 f()(无实参)→ TypeError: <lambda>() missing 2 required positional arguments。骨架不许改,所以 f 必须零参数、靠闭包取值。
  • 把两个空填反:第一个空是 f 的返回表达式,第二个空是新的 x。填反会立刻死循环或结果全错。
  • 以为 lambda 定义时就把 x、y 定死了。如果真是那样,f() 每轮都返回同一个值,反转永远做不出来。lambda 体到调用时才执行,这是本题成立的前提。
  • 把多重赋值拆成两行:x = x // 10 先执行会污染 f() 读到的 x,is_palindrome(12321) 会得到 False。
  • 用 str(n) == str(n)[::-1] 绕过去:能得到正确答案,但这题的考点是数位操作和闭包,且填空题不允许改结构。

8. 整份作业回顾

这次真正学到的不是六道题的解法,而是三条能迁移的思维方法。

一、"参数分批到达" 就是返回函数

Q4、Q5、Q6 是同一道题的三个变体。识别信号永远是 doctest 里的两步调用:h = foo(a) 然后 h(b)。看到它就照抄骨架:外层收第一批参数,内层收第二批,外层 return 内层函数名(不加括号)。第一批参数靠闭包被记住,因为内层函数的 parent 帧就是外层的调用帧。

二、重复的代码里,"变化的部分"可能是一段逻辑

以前抽象重复代码,抽出去的是数(写成参数)。Q5 教的是:变化的部分是一整个判断时,把它包成函数传进去,一样能抽。count_fives 和 count_primes 的循环骨架从此只写一遍。is_i_prime = lambda n, i: is_prime(i) 那种"多接一个用不上的参数"的适配器写法,也值得记住——接口不匹配时,套一层 lambda 就能对上。

三、名字什么时候查,比名字是什么更重要

Q2 里 snake 两次调用给出不同答案,Q3 里 higher_order_lambda(2)(g) 的报错点在第二对括号,Q7 里 f 每轮返回不同的值——这三件事看似无关,其实是同一条规则的三个侧面:函数体在定义时不执行,里面的名字要等到调用那一刻才在环境里查。而查的起点是"函数定义处那一帧",不是"调用处那一帧"。

题目核心手法迁移到哪里
Q1 short-circuitand/or 返回最后一个被求值的操作数,能下结论就停手用 or 写默认值;用 and 做安全前置检查(先判存在再取用)
Q2 hof-wwpd逐行维护绑定表,区分"打印了什么/值是什么/绑定改了没"所有环境图题;调试时判断某个名字到底指着谁
Q3 lambdalambda 是表达式;每对括号消耗一层函数;函数体到调用才执行写高阶函数时的参数顺序推理;理解回调(callback)为何延迟执行
Q4 composite_identity返回函数的最小模板:外层记参数,内层做事Lab 3 的 make_adder 类题目;任何"配置一次、反复使用"的工厂
Q5 count_cond把 if 的判断条件参数化,消掉重复的循环骨架排序的 key 函数、过滤器、后面 filter/map 的思想
Q6 caesar_generator转到方便计算的表示域算完再转回;运算本身也能当参数数据抽象;operator 模块在 reduce 类函数里的用法
Q7 is_palindrome闭包捕获的是帧不是值;多重赋值右边先全部求值考试填空题的反推套路;循环里造 lambda 时的经典坑

官方 checkoff 的三个问题

lab 现场助教会问这三个(_src/txt/sol-lab02.txt 原文),自己先答一遍:

问题标准答案
What is a higher order function?接收函数作为参数、或返回函数、或两者兼有的函数。
What is the parent frame of a function?该函数被定义时所在的那一帧(不是被调用时的帧)。
Why do we use higher order functions?DRY(别写重复代码)与泛化。

本仓库的验证状态

在 labs/lab02/ 下运行 python3 ok --local,13 个测试用例全部通过,本次作业没有任何未完成或跳过的题目。lab02.ok 里登记的默认测试是 short-circuit、hof-wwpd、lambda、count_cond、composite_identity、caesar_generator,选做的 is_palindrome 通过 lab02.py 内的 doctest 校验。本页所有代码均直接摘自该目录下通过评分的源文件,WWPD 答案均摘自 labs/lab02/tests/ 下经哈希校验的解锁文件。