Lab 2:高阶函数与 lambda 表达式ok 13 项通过
函数第一次不再只是"被调用的东西",而变成可以被传来传去、被造出来、被返回的值。这份 lab 的全部难点都集中在一句话上:某个名字,到底在哪一帧里查?
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
True and 13:and 要找第一个假值。求值 True —— 真的,不能下结论,继续。求值 13 —— 真的,操作数用完了。没有假值,于是返回最后一个求值的东西,即 13。不是 True。False or 0:or 要找第一个真值。False 是假的,继续;0 也是假的,用完了。返回最后一个,即 0。注意显示的是 0 而不是 False——它俩在 Python 里相等(0 == False 为真)但不是同一个对象,解释器显示的是这个整数。not 10:not 和那两位不一样,它永远返回布尔值。10 是真值,取反得 False。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
True and 1 / 0:and 求值 True,真值,还不能下结论(因为 and 要求全部为真),必须继续求值 1 / 0。这一求值就抛出 ZeroDivisionError。答案 Error。True or 1 / 0:or 求值 True,真值,立刻能下结论(至少有一个真值),于是根本不去碰 1 / 0。返回 True。同样一个 1 / 0,换了运算符就从爆炸变成了无害——这就是"短路"两个字的全部含义:那段代码没有被执行。-1 and 1 > 0:先看优先级,比较运算符 > 比 and 紧,所以这是 (-1) and (1 > 0)。-1 非零,是真值(很多人在这里栽跟头,以为负数是假的),不能下结论,继续求 1 > 0 得 True。操作数用完,返回最后一个:True。-1 or 5:or 求值 -1,是真值,立刻短路,返回 -1 本身。注意不是 True,也不是 5。(1 + 1) and 1:1 + 1 先算出 2,真值,继续;求 1,真值,用完。返回最后一个 1。接下来这一问是整组里最容易答错的,因为它同时考 print 的返回值和解释器的显示规则:
>>> print(3) or ""
3
''
要产生两行输出,而且两行来源完全不同。拆开看:
or 从左往右,第一个操作数是调用表达式 print(3)。要判断它真假,必须先真的调用它。print(3) 产生一个副作用:屏幕上出现 3。这就是第一行输出。它是 print 打的,不是解释器显示的。print 的返回值是 None。None 是假值,所以 or 不能下结论,继续。"",空字符串,也是假值。操作数用完,返回最后一个求值结果 ""。''。交互式解释器会把非 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)
''
0 or f(1):0 是假值,不能下结论,必须调用 f(1)。1 != 0、1 > 0,返回 "positive"。它是真值,or 短路并返回它。显示 'positive'。f(0) or f(-1):调用 f(0) 得 "zero"。非空字符串是真值,or 立刻短路,f(-1) 根本没被调用。返回 'zero'。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 只做两件事:造一个函数对象、把名字绑上去。函数体里写了什么,此刻一律不看。
逐行推演
def cake(): ... 执行:全局帧多了绑定 cake → func cake()。屏幕无输出(def 语句的值不是表达式,解释器什么都不显示)。chocolate = cake():先求右边。调用 cake(),新建一帧 f1(parent 是全局帧)。帧里依次执行:print('beets') → 屏幕打出 beets;def pie(): ... → 在 f1 里绑定 pie → func pie(),注意这个 pie 的 parent 是 f1;return pie → 把这个函数对象交回去。然后赋值:全局帧 chocolate → func pie()。整行是赋值语句,赋值语句没有值,所以解释器不额外显示什么。唯一输出就是 beets。chocolate:这是一个表达式,求值得到那个函数对象。解释器显示 <function cake.<locals>.pie at 0x...>,按 WWPD 规则写作 Function。chocolate():调用 pie。新建一帧,parent 是 f1。print('sweets') → 屏幕打出 sweets;return 'cake' → 表达式的值是字符串 'cake'。它不是 None,所以解释器再显示一行 'cake'(带引号,因为是 repr)。两行输出,来源不同。more_chocolate, more_cake = chocolate(), cake:多重赋值,右边整个元组先全部求值完,才做绑定。求 chocolate() → 又打印一次 sweets,值为 'cake';求 cake → 就是那个函数对象本身(没有括号,不是调用,所以不会打印 beets)。然后绑定:more_chocolate → 'cake'(字符串),more_cake → func cake()。这一行只输出 sweets。more_chocolate:值是字符串,显示 'cake'。这里的陷阱是名字叫 more_chocolate,但它存的是字符串 'cake';而名字叫 more_cake 的那个存的却是函数。命名是故意反着来的。def snake(x, y): ...:只是在全局帧绑定 snake。函数体里的 cake、more_cake、chocolate 此刻一个都不查——这是本题的核心考点,名字要等到函数被调用时才在环境里查找。snake(10, 20):新建帧,x=10, y=20。求 cake == more_cake:查 cake(全局帧里是那个函数对象)、查 more_cake(第 5 步绑的同一个函数对象)。两者是同一个对象,相等,条件为真,return chocolate。返回的是函数 pie。解释器显示 Function。整个过程没有任何打印——因为从头到尾没有调用过 cake 或 pie。snake(10, 20)():先算 snake(10, 20) 得到函数 pie(无输出),再对它加一对括号调用它:打印 sweets,返回 'cake',解释器显示 'cake'。cake = 'cake':全局帧里 cake 的绑定被改写成字符串。注意 more_cake 没有跟着变——它早在第 5 步就抓住了那个函数对象,赋值只是给全局帧的 cake 换了个值,跟 more_cake 指向的对象无关。snake(10, 20):再次调用,这次 cake == more_cake 是 'cake' == func cake(),字符串和函数不相等,为假,走 else:return x + y = 30。显示 30。把全局帧的变化列成表,第 10、11 步为什么翻转就一目了然:
| 执行到 | cake | more_cake | chocolate | more_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 的函数体只能是单个表达式,其值自动作为返回值。
lambda | def | |
|---|---|---|
| 语法类别 | 表达式,求值成一个值 | 语句,会改变环境 |
| 执行结果 | 造出一个匿名函数,没有固有名字 | 造出一个有固有名字的函数,并把它绑到当前帧的那个名字上 |
| 对环境的影响 | 不创建也不修改任何变量 | 创建/覆盖一个名字绑定 |
| 可以出现在哪 | 任何需要表达式的地方:赋值右边、实参、算子位置 | 只能作为独立语句 |
| 函数体 | 只能是一个表达式 | 任意多条语句 |
第一组 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
lambda x: x 单独一行:它是表达式,求值出一个函数对象。解释器把这个值显示出来:<function <lambda> at 0x...>,按规则写 Function。环境没有任何变化——你没法再引用到这个函数了,它当场就被丢弃。a = lambda x: x:赋值语句,右边求值出函数对象,绑到 a。无输出。a(5):调用,新建帧 x=5,返回表达式 x 求值得 5。(lambda: 3)():lambda: 3 是零参数函数。外面的括号只是为了让语法解析对(否则 Python 会把 () 当成 lambda 体的一部分)。这里 lambda 表达式直接充当算子,求值后立刻调用,返回 3。这演示了"lambda 可以出现在任何需要表达式的地方"。b = lambda x, y: lambda: x + y:b 是个两参数函数,它的返回表达式又是一个 lambda。此刻内层 lambda 一次都没被求值。c = b(8, 4):调用 b,新建帧 f1(x=8, y=4,parent=Global)。求值返回表达式 lambda: x + y —— 造出一个函数对象,parent 是 f1,但不执行 x + y。把它返回并绑给 c。c:显示 Function。c():调用它,新建帧 f2(无形参,parent=f1)。求 x + y:f2 里没有 x,去 parent f1 找到 x=8、y=4,得 12。这就是闭包:b 那次调用早已返回,但它的帧因为还被 c 的 parent 指着而活着,8 和 4 被记住了。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
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。报错点在第二次调用,不在第一次。higher_order_lambda(g)(2):这回顺序对了。f = g,返回内层函数;再用 2 调用它,x = 2,求 f(x) = g(2) = 2 * 2 = 4。两问放一起是在逼你问:"第一对括号里的实参到底绑给谁?"答案永远是最外层那个函数的形参。call_thrice(lambda y: y + 1)(0):f 绑到"加一"函数,x = 0,求 f(f(f(x)))。从最里往外算:f(0)=1 → f(1)=2 → f(2)=3。答案 3。print_lambda = lambda z: print(z):赋值,无输出。print_lambda 单独一行:显示函数对象,Function。注意到现在为止一次都没打印过东西——返回表达式 print(z) 还没执行过,这正是概念题第三问的答案在起作用。one_thousand = print_lambda(1000):现在函数被调用了,z = 1000,执行 print(1000) → 屏幕打出 1000。print 返回 None,lambda 把这个 None 作为自己的返回值交回去,绑给 one_thousand。屏幕上只有 1000 这一行。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 里逐步算:
== 左边 f(g(x))。要先求算子数,即先算 g(x)。查 g:f2 里没有 → 上溯 parent f1 → 找到 add_one。查 x:f2 里有,4。调用 add_one(4) → 新帧 x=4,返回 4 + 1 = 5。5 喂给 f(在 f1 里查到是 square):square(5) → 5 ** 2 = 25。左边 = 25。g(f(x))。先 f(4) = square(4) = 16。g(16) = add_one(16) = 17。右边 = 17。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):
i | n * i | sum_digits | 条件 | 本轮后 count |
|---|---|---|---|---|
| 1 | 10 | 1+0 = 1 | 否 | 0 |
| 2 | 20 | 2+0 = 2 | 否 | 0 |
| 3 | 30 | 3 | 否 | 0 |
| 4 | 40 | 4 | 否 | 0 |
| 5 | 50 | 5+0 = 5 | 是 | 1 |
| 6 | 60 | 6 | 否 | 1 |
| 7 | 70 | 7 | 否 | 1 |
| 8 | 80 | 8 | 否 | 1 |
| 9 | 90 | 9 | 否 | 1 |
| 10 | 100 | 1+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 已经替你处理了,你不用自己写。
怎么想到的
这题的解法几乎是被三条限制逼出来的,值得逐条看它怎么收窄可能性:
caesar_generator 收 num 和 op,返回的东西收一个字母。def。那内层能不能塞进一个 lambda?能——因为整个变换是一个表达式,没有循环也没有分支。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'
num_to_letter(op(letter_to_num(letter), num))。最内层先算:letter_to_num('a')。查 letter_to_num:f2 没有 → f1 没有 → Global 找到。letter_to_num('a'):'a'.isupper() 为假,走 return ord('a') - LOWERCASE_SHIFT = 97 - 97 = 0。op:f2 没有 → f1 找到 add。求 num:f2 没有 → f1 找到 2。调用 add(0, 2) = 2。num_to_letter(2):int(2) 成功;2 % 52 = 2;2 > 25 为假,走 return chr(2 + 97) = chr(99) = 'c'。'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
怎么想到的
填空题的解法不是"想一个算法",而是从没被挖空的部分反推被挖空的部分必须是什么。三条线索:
return y == n。n 全程没被改过,所以循环结束时 y 必须等于"n 的数位反转"。整个算法就是"把 n 倒过来,看倒过来是不是它自己"。x, y = n, 0 且循环条件是 x > 0。要让循环终止,x 必须越来越小直到 0。对整数做这件事的标准手法是 x // 10(每次砍掉最低一位)。所以第二个空是 x // 10。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 | 轮初 y | f() = y*10 + x%10 | 轮末 x | 轮末 y |
|---|---|---|---|---|---|
| 进入前 | 12321 | 0 | — | — | — |
| 1 | 12321 | 0 | 0*10 + 1 = 1 | 1232 | 1 |
| 2 | 1232 | 1 | 1*10 + 2 = 12 | 123 | 12 |
| 3 | 123 | 12 | 12*10 + 3 = 123 | 12 | 123 |
| 4 | 12 | 123 | 123*10 + 2 = 1232 | 1 | 1232 |
| 5 | 1 | 1232 | 1232*10 + 1 = 12321 | 0 | 12321 |
x 变成 0,0 > 0 为假,退出。return 12321 == 12321 → True。
再看反例 is_palindrome(2015):
| 轮次 | 轮初 x | 轮初 y | f() | 轮末 x | 轮末 y |
|---|---|---|---|---|---|
| 1 | 2015 | 0 | 0*10 + 5 = 5 | 201 | 5 |
| 2 | 201 | 5 | 5*10 + 1 = 51 | 20 | 51 |
| 3 | 20 | 51 | 51*10 + 0 = 510 | 2 | 510 |
| 4 | 2 | 510 | 510*10 + 2 = 5102 | 0 | 5102 |
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-circuit | and/or 返回最后一个被求值的操作数,能下结论就停手 | 用 or 写默认值;用 and 做安全前置检查(先判存在再取用) |
Q2 hof-wwpd | 逐行维护绑定表,区分"打印了什么/值是什么/绑定改了没" | 所有环境图题;调试时判断某个名字到底指着谁 |
Q3 lambda | lambda 是表达式;每对括号消耗一层函数;函数体到调用才执行 | 写高阶函数时的参数顺序推理;理解回调(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/ 下经哈希校验的解锁文件。