HW 1:函数、控制、高阶函数ok 5 项通过
第一份真正要求你「自己想出来」的作业:把函数当值传、当值返回,用 while 把重复劳动交给机器。
0. 这份作业在练什么
HW 1 一共六道题,前四道是必做(Q1–Q4),后两道是可选(Q5–Q6)。本仓库里
hw/hw01/hw01.py 把六道题全部写完了,跑 python3 ok --local
的结果是 5 test cases passed! No cases failed.——注意这个 5 是 ok 评分器统计的
「test case」条数,不是题目数;六道题的 doctest 全部通过,没有任何一项失败。这一点如实写在这里,
免得你对着数字发愣。
这份作业没有 What-Would-Python-Display 概念题(hw/hw01/tests/ 目录下只有一个空的
__init__.py,所有测试都是从 hw01.py 的 docstring 里直接抽出来的 doctest)。
所以本页就是六道编程题,每题按「题目要什么 → 怎么想到的 → 代码逐行讲 → 手动验证」四步展开。
它对应哪几讲
| 题目 | 依赖的讲次内容 | 真正在训练的能力 |
|---|---|---|
Q1 a_plus_abs_b | Lecture 1–2:表达式求值、函数是值、if 语句 | 意识到「函数名也是一个可以赋给变量的普通值」 |
Q2 hailstone | Lecture 2:while、print vs return、整除 // | 用循环维护「状态变量」,并区分副作用与返回值 |
Q3 product | Lecture 3:高阶函数(higher-order function) | 把函数当参数收进来,写出「累积循环」的通用骨架 |
Q4 make_repeater | Lecture 3:嵌套定义、返回函数、闭包 | 返回一个函数,并理解它记住了外层的 f 和 n |
Q5 largest_factor(可选) | Lecture 2:while 与取模 % | 把「搜索」写成循环,并论证循环一定会停 |
Q6 accumulate(可选) | Lecture 3:抽象出共同骨架 | 发现 sum 与 product 只差两个「参数」,把差异提升为参数 |
做之前你必须已经掌握的三件事
- 函数名是名字,函数体是值。
add这个名字绑定到一个函数对象;f = add只是让f也指向同一个函数对象,并没有「调用」它。只有加上括号f(a, b)才发生调用。Q1 全靠这一句话。 print与return是两件完全无关的事。print把字符画到屏幕上(副作用),return把值交回给调用者。 一个函数可以两件都做(Q2 就要求两件都做),也可以只做一件。- 循环的本质是「维护若干个状态变量」。写
while之前先问自己三个问题: 哪些变量在变?它们初始是多少?每轮怎么更新?循环条件是什么时候变假的? 把这四项答清楚,代码几乎是抄下来的。
Q1–Q2 与 Q3–Q4 的难度是一个台阶。Q1、Q2 只是「按规则办事」;
Q3、Q4 要求你把函数本身放进变量、放进参数表、放进返回值。
如果你在 Q3 卡住,多半不是不会写循环,而是心里还没接受「term 是一个可以被调用的东西」。
本页在 Q3 会专门把这层窗户纸捅破。
文件里已经给好的辅助函数
hw01.py 顶部预置了四个单参数函数,Q3、Q4、Q6 的 doctest 全靠它们当「测试用的 term」:
from operator import add, mul
def square(x):
return x * x
def identity(x):
return x
def triple(x):
return 3 * x
def increment(x):
return x + 1
这四个函数本身毫无技术含量,但它们的存在方式很关键:square 不是「一段要复制粘贴的代码」,
而是一个可以被当成实参传来传去的值。整份作业的高阶函数部分,就是围绕这个事实展开的。
另外第一行的 from operator import add, mul 把内置算术运算的函数版本拿了进来:
add(2, 3) 与 2 + 3 求值结果完全一样,区别只在于前者是个可以传递的对象,后者是一段语法。
1. Q1:A Plus Abs B
题目要什么
写一个函数 a_plus_abs_b(a, b),返回 a + abs(b),但是不许调用
abs。而且题面已经把函数的骨架给好了,你只能填两个空,
其余一个字都不能改——尤其是最后那行 return f(a, b),ok 里有一个专门的语法检查
a_plus_abs_b_syntax_check,它会用 inspect 把你的源码抠出来正则匹配,
确认这一行还是原样:
>>> import inspect, re
>>> re.findall(r'^\s*(return .*)', inspect.getsource(a_plus_abs_b), re.M)
['return f(a, b)']
这条检查的意思是:函数体里有且只有一条 return 语句,而且必须是
return f(a, b)。所以任何形如 return a - b、
return a + (-b) 的写法,哪怕答案对,也会被这条测试判负。
四条 doctest 覆盖了 b 的正负与 a 的正负的四种组合:
>>> a_plus_abs_b(2, 3)
5
>>> a_plus_abs_b(2, -3)
5
>>> a_plus_abs_b(-1, 4)
3
>>> a_plus_abs_b(-1, -4)
3
边界情况:b == 0。题面没测,但你要想清楚——
abs(0) 是 0,此时走 add 还是走 sub 都得到
a,两条分支殊途同归。所以判断条件写 b < 0 还是 b <= 0
都不影响正确性;但 b > 0 与 b < 0 谁在 if 谁在 else
是有讲究的,下面会说。
怎么想到的
第一反应几乎所有人都一样:abs(b) 不就是「b 是负数就取相反数」吗?于是想写
if b < 0:
return a - b
else:
return a + b
这个想法本身完全正确,数学上也无懈可击:当 b < 0 时 -b = abs(b),
所以 a - b == a + abs(b)。但它过不了语法检查——因为它写了两条
return,而且都不是 return f(a, b)。
于是被迫回头看题面给的骨架。骨架最后一行是 return f(a, b),这句话在逼你回答一个问题:
f 是什么?它出现在函数调用的算子(operator)位置上,说明它必须求值成一个
能被调用的、接受两个参数的东西。而题面第一段刚好告诉你:
operator 模块里有 add 和 sub 这样的双参数函数,
add(2, 3) 和 2 + 3 是一回事。
把「选哪种运算」这件事,从「写两条不同的 return」
变成「给同一个名字 f 绑定不同的函数对象」。
分支不再决定「算什么」,而是决定「f 指向谁」;真正的计算只在最后统一发生一次。
这个转换是 CS 61A 反复要教的思维方式:控制流的差异可以被降级成数据的差异。
原本 if 分支里放的是两段行为,现在分支里放的是两个值,行为只有一份。
后面 Q6 的 accumulate 把 fuse 做成参数,用的是完全同一招——
只不过 Q1 是你自己在函数体内选,Q6 是让调用者从外面传进来。
还有一个小弯路值得记:有人会写 f = sub(a, b),然后 return f(a, b) 报
TypeError: 'int' object is not callable
原因是 sub(a, b) 已经把函数调用掉了,f 拿到的是一个整数,
而整数不能再加括号调用。要绑定函数本身,就不能写括号:f = sub。
这是「名字 vs 调用」的第一次正面交锋,务必分清。
代码逐行讲
from operator import add, sub
def a_plus_abs_b(a, b):
"""Return a+abs(b), but without calling abs.
>>> a_plus_abs_b(2, 3)
5
>>> a_plus_abs_b(2, -3)
5
>>> a_plus_abs_b(-1, 4)
3
>>> a_plus_abs_b(-1, -4)
3
"""
if b < 0:
f = sub # b is negative, so a - b == a + abs(b)
else:
f = add # b is non-negative, so a + b == a + abs(b)
return f(a, b)
from operator import add, sub:这行是题面预置的。它在当前全局帧里创建了两个名字add和sub,分别绑定到内置的加法函数与减法函数对象。 注意hw01.py开头还有一行from operator import add, mul(给 Q6 用的), 两行不冲突,只是往同一个全局帧里多绑几个名字。if b < 0::条件只关心b的符号,与a无关—— 因为abs只作用在b上。写成b < 0而不是b > 0,是为了让「非负」这个包含 0 的情况落到else里, 读起来是「负数才需要特殊处理」,符合直觉。f = sub:赋值语句,不是调用。执行后,当前局部帧里多了一个绑定f → 减法函数。sub后面没有括号,这是全题的题眼。f = add:同理,f指向加法函数。两条分支之后,无论走哪边,f都是一个「能吃两个数、吐一个数」的函数。这就是所谓的接口一致: 不同的值,相同的用法。return f(a, b):题面给定,不可改。求值顺序是——先求算子f(得到某个函数对象), 再求两个算子数a、b,然后把这两个值送进那个函数,得到结果并返回。 如果你在这一行之前忘了给f赋值,就会看到NameError: name 'f' is not defined。
验证:手动追踪 a_plus_abs_b(-1, -4)
挑最容易出符号错误的一组:a = -1,b = -4,期望答案
-1 + abs(-4) = -1 + 4 = 3。
Global frame
add → 内置函数 add
sub → 内置函数 sub
a_plus_abs_b → func a_plus_abs_b(a, b)
f1: a_plus_abs_b [parent = Global]
a = -1
b = -4
---- 执行 if b < 0 → -4 < 0 → True,进入 then 分支
f → 内置函数 sub (新增绑定)
---- 执行 return f(a, b)
求算子 f → 减法函数
求算子数 a → -1
求算子数 b → -4
调用 sub(-1, -4) → -1 - (-4) → 3
返回值 = 3四条 doctest 全走一遍:
| 调用 | b < 0? | f 绑定到 | f(a, b) 展开 | 结果 |
|---|---|---|---|---|
a_plus_abs_b(2, 3) | False | add | 2 + 3 | 5 |
a_plus_abs_b(2, -3) | True | sub | 2 - (-3) | 5 |
a_plus_abs_b(-1, 4) | False | add | -1 + 4 | 3 |
a_plus_abs_b(-1, -4) | True | sub | -1 - (-4) | 3 |
四行输出与 doctest 完全一致。
- 给
f赋的是调用结果而不是函数:f = sub(a, b), 运行到return f(a, b)时报TypeError: 'int' object is not callable。 记住:要传函数就别写括号。 - 改了
return行:写成return a - b之类。python3 ok -q a_plus_abs_b会过,但python3 ok -q a_plus_abs_b_syntax_check会失败,Gradescope 上照样扣分。 - 把
f写成字符串:f = 'sub',报TypeError: 'str' object is not callable。字符串'sub'和名字sub是两回事,前者是一段文本,后者是对函数对象的引用。 - 分支里少写一条赋值:只在
if里写了f = sub、 忘了else,当b >= 0时报UnboundLocalError/NameError: name 'f' is not defined。
2. Q2:Hailstone
题目要什么
冰雹序列(hailstone sequence,又叫 Collatz 序列)的规则只有三条:
n 出发。n 是偶数,把它除以 2;如果是奇数,变成 3n + 1。n 变成 1 为止。函数 hailstone(n) 要做两件事:把序列里经过的每一个数
逐行打印出来,同时返回序列的长度(也就是打印了多少个数)。
>>> a = hailstone(10)
10
5
16
8
4
2
1
>>> a
7
>>> b = hailstone(1)
1
>>> b
1
这段 doctest 的写法本身就在教你东西。为什么写 a = hailstone(10) 而不是直接
hailstone(10)?因为如果直接写,交互式解释器会既显示打印的 7 行,又显示返回值
7,一共 8 行输出,读起来分不清哪行是 print 的、哪行是返回值的。
先把返回值接到变量 a 里,屏幕上就只剩下 print 的 7 行;
下一句单独求值 a,才把返回值 7 显示出来。
doctest 在用排版帮你区分「副作用」和「返回值」。
边界情况有两个,都要盯紧:
hailstone(1):序列只有一个数1,要打印一行1,返回1。 题面明确说了 “Note that if n == 1 initially, then the sequence is one step long.” 这意味着长度计的是「数的个数」,不是「变换的次数」。- 打印的必须是整数
4,不能是浮点数4.0。 Python 3 里8 / 2求值成4.0(float),只有8 // 2才是4(int)。doctest 是逐字符比对输出的,打出4.0就算错。
怎么想到的
先把「要维护什么」列清楚。序列在往前走,每一步都有两样东西在变:
| 状态变量 | 含义 | 初值 | 怎么更新 |
|---|---|---|---|
n | 当前走到的数 | 参数传进来的值 | 偶数 n // 2,奇数 3 * n + 1 |
length | 已经数过几个数 | 1(起点 n 本身算第一个) | 每走一步 + 1 |
循环条件呢?「一直做到 n 变成 1」,直译就是 while n != 1:。
写到这里第一版很自然长这样:
length = 1
while n != 1:
print(n)
if n % 2 == 0:
n = n // 2
else:
n = 3 * n + 1
length = length + 1
return length
然后它错了。拿 hailstone(10) 试:打印出 10、5、16、8、4、2,
到 n 变成 1 时循环条件为假,直接退出——最后那个 1 没被打印。
输出只有 6 行,doctest 要 7 行。
这是 while 循环最经典的一类错误:循环体负责处理「当前值」,
但循环条件把最后一个值挡在了门外。因为 print(n) 写在循环里,
而循环在 n == 1 时根本不进去,所以 1 永远打印不出来。
怎么补?有两条路,值得都想一遍:
路线 A:把 print 挪到循环外面补一次。
循环退出时 n 一定是 1(这是 while n != 1 的直接推论),
所以在 return 之前加一行 print(n) 就补上了。
而且这行对 hailstone(1) 也刚好正确——那次循环一次都没进,
length 保持初值 1,然后打印一行 1,返回 1,正是边界要求。
一处修改同时解决了主流程和边界,这是好设计的信号。
路线 B:改用 while True + 中途 break。
先 print(n),再判断 if n == 1: break,然后更新。
逻辑上也对,但多了一个 break,而且 length 的加一位置更容易搞混。
既然路线 A 只多一行且不引入新控制结构,就用 A。
再确认一下 length 的加一位置。它必须放在 n 更新之后
(或者说,与「产生了一个新数」这件事绑定),因为初值 1 已经把起点算进去了,
之后每产生一个新数才 +1。如果 length 初值写 0,那就得在循环外补 +1,
反而更绕——初值 1 是对边界情况 n == 1 的直接呼应。
最后是除法。写 n = n / 2 的话,10 / 2 是 5.0,
往后整条序列全变成浮点数,打印出 16.0、8.0……而且
n != 1 在 n 是 1.0 时其实是 False
(1.0 == 1 为真),循环还是会正常结束,只是输出全带小数点,doctest 全挂。
所以必须用整除 //——题面的 Hint 说的就是这件事。
代码逐行讲
def hailstone(n):
"""Print the hailstone sequence starting at n and return its
length.
>>> a = hailstone(10)
10
5
16
8
4
2
1
>>> a
7
>>> b = hailstone(1)
1
>>> b
1
"""
length = 1 # the starting value n counts as the first step
while n != 1:
print(n)
if n % 2 == 0:
n = n // 2 # floor division keeps n an int (4, not 4.0)
else:
n = 3 * n + 1
length = length + 1
print(n) # n is now 1, the last value of the sequence
return length
length = 1:起点n自己就是序列的第一个元素,所以从 1 起算。 这一行同时也是hailstone(1)能返回 1 的全部原因。while n != 1::只要还没到 1 就继续。用!=而不是> 1, 两者对正整数等价,但!= 1更贴近题面「continue until n is 1」的字面意思。print(n)(循环内):打印当前这个数,然后才把它变成下一个。 顺序很重要:先打印再更新,才能保证打印的是「进入本轮时的 n」。if n % 2 == 0::n % 2是n除以 2 的余数, 偶数余 0。这里必须写== 0而不是n % 2(后者虽然在 Python 里 0 也会被当作假,能跑通,但 61A 的风格是让条件显式表达布尔含义)。n = n // 2:整除。//对两个 int 求值仍得 int。 这是整道题唯一的「语言陷阱」。n = 3 * n + 1:奇数规则。注意3 * n + 1的运算优先级—— 乘法先于加法,不需要括号。length = length + 1:刚刚产生了一个新的n,序列多了一项。 放在if/else之后,两条分支共用一份,不用写两遍。print(n)(循环外):循环退出说明n == 1,把最后这个 1 补打印出来。 如果n一开始就是 1,循环体一次都没执行,这行就是唯一的输出。return length:把长度交回调用者。注意它在循环外面—— 写进循环里会导致第一轮就返回,函数直接结束。
print 与 return 在这个函数里同时出现,但互不干扰:
print 产生的是屏幕上的字符,return 产生的是
a = hailstone(10) 里 a 拿到的那个值。
如果你把最后一行写成 print(length),屏幕上会多出一个 7,
但函数返回 None,a 会变成 None,doctest 里
>>> a 那一步什么都不显示(None 在交互器里不回显),测试失败。
验证:手动追踪 hailstone(10)
| 时刻 | 进入循环前 n | n != 1 | 打印 | 更新规则 | 更新后 n | length |
|---|---|---|---|---|---|---|
| 初始化 | 10 | — | — | — | 10 | 1 |
| 第 1 轮 | 10 | True | 10 | 偶 → 10 // 2 | 5 | 2 |
| 第 2 轮 | 5 | True | 5 | 奇 → 3*5+1 | 16 | 3 |
| 第 3 轮 | 16 | True | 16 | 偶 → 16 // 2 | 8 | 4 |
| 第 4 轮 | 8 | True | 8 | 偶 → 8 // 2 | 4 | 5 |
| 第 5 轮 | 4 | True | 4 | 偶 → 4 // 2 | 2 | 6 |
| 第 6 轮 | 2 | True | 2 | 偶 → 2 // 2 | 1 | 7 |
| 检查条件 | 1 | False | — | 退出循环 | 1 | 7 |
| 循环后 | 1 | — | 1 | return 7 | 1 | 7 |
屏幕上依次出现 10、5、16、8、4、2、1 共 7 行,返回值 7。与 doctest 逐字符一致。
再追一遍边界 hailstone(1):length = 1;检查 1 != 1 得
False,循环体一次都不执行;执行循环外的 print(n)
打印 1;return 1。输出一行 1,返回 1——正确。
题面让你试 hailstone(27)。它会先一路飙到 9232,再慢慢掉回 1,
一共 112 个数。这就是「冰雹」这个名字的来历:数值像冰雹一样在空中被气流反复托举,
最终还是落地。数学上「所有正整数都会落到 1」(Collatz 猜想)至今没有被证明,
题面也如实写了 “nobody has ever proved that the sequence will terminate”。
换句话说,你写的这个 while 循环没有人能证明它一定会停——
这在编程作业里相当罕见,值得记住。
- 用
/而不是//:输出变成5.0、16.0……ok 报告 doctest 期望5实得5.0。 - 漏打最后的 1:忘了循环外那行
print(n),hailstone(10)只输出 6 行,而且hailstone(1)什么都不打印。 return写进了循环体:while第一轮就return,hailstone(10)只打印一个 10 就返回 2。ok 会报期望 7 实得 2。length从 0 开始:hailstone(10)返回 6、hailstone(1)返回 0,与「起点自己算一步」的约定不符。- 把
return length写成print(length): 函数隐式返回None,a变成None, doctest 里>>> a期望显示7却什么都没有。
3. Q3:Product
题目要什么
写 product(n, term),返回某个序列前 n 项的乘积:
term(1) * term(2) * ... * term(n)。
关键在于第二个参数 term 不是一个数,是一个函数。
题面写得很清楚:“term: a function that takes an index as input and produces a term”,
也就是 term(i) 给出序列的第 i 项。序列长什么样,完全由调用者传进来的
term 决定;product 自己不知道、也不需要知道。
>>> product(3, identity) # 1 * 2 * 3
6
>>> product(5, identity) # 1 * 2 * 3 * 4 * 5
120
>>> product(3, square) # 1^2 * 2^2 * 3^2
36
>>> product(5, square) # 1^2 * 2^2 * 3^2 * 4^2 * 5^2
14400
>>> product(3, increment) # (1+1) * (2+1) * (3+1)
24
>>> product(3, triple) # 1*3 * 2*3 * 3*3
162
六条 doctest 里 n 没变多少,变的全是 term:
identity 让它算阶乘,square 让它算平方之积,
increment 让它算 2*3*4,triple 让它算 3*6*9。
同一段循环代码,靠换一个参数就生成了四种完全不同的数学函数——
这就是高阶函数(higher-order function)的全部意义。
边界情况:docstring 里注明 “n: a positive integer”,所以理论上不用管
n == 0。但下面的写法用的是 while k <= n,
n 为 0 时循环一次都不进、直接返回 1,这恰好是数学上「空乘积等于 1」的正确答案,
白捡一个健壮性。这一点在 Q6 的 accumulate 里会变成必须——
那里的 doctest 明确测了 n == 0。
怎么想到的
如果 term 让你发懵,先做个降维练习:假装题目只要算阶乘。
那你会写
total, k = 1, 1
while k <= n:
total = total * k
k = k + 1
return total
这个骨架你在 Q2 里已经练过——两个状态变量,一个装累积结果,一个装计数器。
现在回头看:阶乘版里被乘进去的是 k 本身;平方之积版要乘 k * k;
三倍版要乘 3 * k。三个版本只有一个位置不同,就是
「由下标 k 算出这一项的值」这一步。
凡是「几段代码只在某一处不同」,就把那一处提出来做成参数。
如果不同的是一个值,参数就是个数;如果不同的是一段计算,
参数就得是个函数。这里不同的是「从 k 到项值」的计算,
所以参数 term 是函数,用法是 term(k)。
于是把 total * k 改成 total * term(k),题就做完了。
真正需要在脑子里过关的是这一句:term 是个名字,它当前绑定到某个函数对象;
term(k) 才是把那个函数调用起来、得到一个数。
和 Q1 里的 f = sub / f(a, b) 是同一个道理,只是这回函数是从外面传进来的。
几个想歪了的方向,也说一下:
- 想写
total = total * term:把函数对象直接乘进去,报TypeError: unsupported operand type(s) for *: 'int' and 'function'。 少写了括号,函数就没被调用。 - 想在
product里if term == square: ... elif term == identity: ...: 能跑,但完全违背了这道题的目的——你又把term当成了一个「标签」而不是一段可执行的计算, 而且遇到没见过的term就废了。 - 累积变量从 0 开始:
total = 0,那么第一次0 * term(1)就是 0, 之后永远是 0。乘法的单位元(identity element)是 1,不是 0。 这个「起始值必须是运算的单位元」的观察,正是 Q6 要把start做成参数的原因。
下标从哪开始?题目说 term(1) * ... * term(n),所以 k 从
1 开始,循环条件是 k <= n(含 n)。
写成 k < n 会少乘最后一项:product(3, identity) 得 2 而不是 6。
代码逐行讲
def product(n, term):
"""Return the product of the first n terms in a sequence.
n: a positive integer
term: a function that takes an index as input and produces a term
>>> product(3, identity) # 1 * 2 * 3
6
>>> product(5, identity) # 1 * 2 * 3 * 4 * 5
120
>>> product(3, square) # 1^2 * 2^2 * 3^2
36
>>> product(5, square) # 1^2 * 2^2 * 3^2 * 4^2 * 5^2
14400
>>> product(3, increment) # (1+1) * (2+1) * (3+1)
24
>>> product(3, triple) # 1*3 * 2*3 * 3*3
162
"""
total, k = 1, 1 # 1 is the identity for multiplication
while k <= n:
total = total * term(k)
k = k + 1
return total
total, k = 1, 1:一条语句同时建立两个绑定。 右边的1, 1先被求值成一个二元组,再按位置拆给左边两个名字。total初值取 1 是因为它是乘法单位元——任何数乘 1 不变, 所以「还没乘任何东西」的状态就该是 1。k初值取 1 是因为序列从第 1 项算起。while k <= n::含n。 循环体一共执行n次,k依次取 1, 2, …, n。total = total * term(k):求值顺序是——先算右边整个表达式: 取出total当前的值,调用term(k)得到第k项, 两者相乘;然后把结果重新绑定给total。term(k)的括号是全题的核心,没有它就不是调用。k = k + 1:推进计数器。忘了这行会得到无限循环——k永远是 1,条件永远真,程序挂死(要 Ctrl-C 才停)。return total:在循环外。放循环里的话第一轮就返回,product(3, identity)会得到 1 而不是 6。
顺带一提:这里也可以写成 total, k = total * term(k), k + 1 合成一行。
效果相同(右边先整体求值,所以用的都是旧的 k),但分成两行更好读,也更不容易搞错顺序。
验证:手动追踪 product(3, square)
square 是文件顶部定义的 def square(x): return x * x。
期望结果 1² * 2² * 3² = 1 * 4 * 9 = 36。
Global frame
square → func square(x)
product → func product(n, term)
f1: product [parent = Global]
n = 3
term → func square(x) ← 注意:绑定的是函数对象本身
total = 1, k = 1
第 1 轮: k <= n → 1 <= 3 → True
调用 square(1) ──→ f2: square [parent=Global] x = 1 返回 1
total = 1 * 1 = 1
k = 2
第 2 轮: 2 <= 3 → True
调用 square(2) ──→ f3: square x = 2 返回 4
total = 1 * 4 = 4
k = 3
第 3 轮: 3 <= 3 → True
调用 square(3) ──→ f4: square x = 3 返回 9
total = 4 * 9 = 36
k = 4
检查: 4 <= 3 → False,退出循环
return total → 36换个 term 再走一遍 product(3, triple)(triple(x) 返回 3 * x):
| 轮次 | k | term(k) 即 triple(k) | total 更新 | 更新后 total |
|---|---|---|---|---|
| 进入前 | 1 | — | — | 1 |
| 1 | 1 | 3 * 1 = 3 | 1 * 3 | 3 |
| 2 | 2 | 3 * 2 = 6 | 3 * 6 | 18 |
| 3 | 3 | 3 * 3 = 9 | 18 * 9 | 162 |
k 变成 4,条件为假,返回 162——与 doctest 注释
# 1*3 * 2*3 * 3*3 的 162 吻合。函数体一个字没改,只换了传进来的 term。
为什么这道题值得反复回味
product 把「怎么遍历、怎么累乘」这套控制骨架固定下来,
把「每一项到底是什么」这个可变部分交给调用者。
这正是函数抽象的第二层:第一层是把一段代码起个名字(square),
第二层是把行为本身当参数传(term)。
Q4 会走到第三层——把函数当返回值。
term忘了加括号:total = total * term,报TypeError: unsupported operand type(s) for *: 'int' and 'function'。- 循环条件写成
k < n:少算最后一项,product(3, identity)返回 2。 total初值写 0:任何输入都返回 0。- 忘了
k = k + 1:无限循环,终端卡死没有任何输出, 必须 Ctrl-C。这类 bug 没有报错信息,只能靠「循环变量在每条路径上都必须推进」来自查。 - 把
term当成序列的值:写total = total * n, 六条 doctest 里product(3, identity)会得到 27 而不是 6。
4. Q4:Make Repeater
题目要什么
make_repeater(f, n) 接受一个单参数函数 f 和一个正整数 n,
返回一个新的单参数函数。这个新函数拿到 x 之后,
把 f 连续套用 n 次:f(f(...f(x)...))。
>>> add_three = make_repeater(increment, 3)
>>> add_three(5)
8
>>> make_repeater(triple, 5)(1) # 3 * (3 * (3 * (3 * (3 * 1))))
243
>>> make_repeater(square, 2)(5) # square(square(5))
625
>>> make_repeater(square, 3)(5) # square(square(square(5)))
390625
先把 doctest 的写法读懂,因为它包含了这道题最重要的信息:
add_three = make_repeater(increment, 3):调用make_repeater不产生任何数字,它产生一个函数,然后被绑定到名字add_three。 此时还没有任何increment被执行过。add_three(5):现在才真正干活,把increment套 3 次到 5 上, 得到 5 → 6 → 7 → 8。make_repeater(triple, 5)(1):两对括号。 第一对调用make_repeater得到函数,第二对立刻调用那个函数。 这个写法在 61A 后面会出现无数次,现在就要看顺眼。
边界情况:题面说 n 是 positive integer,所以不必处理 n == 0。
但下面的写法在 n == 0 时会原样返回 x(f 套 0 次就是不动),
这在数学上也是对的——它相当于恒等函数。顺带说,make_repeater(square, 3)(5)
的结果 390625 值得自己算一下确认理解:5² = 25,25² = 625,
625² = 390625。不是 5^(2*3) = 15625——
反复平方是指数翻倍,套 3 次得到的是 5^(2³) = 5^8。
怎么想到的
第一个坎:函数怎么可能被 return?
如果你接受了 Q1 的 f = sub(函数可以绑给变量)和 Q3 的 term
(函数可以当参数),那 return 一个函数就没有任何新鲜之处——
函数就是一种普通的值,能赋值、能传参,当然也能返回。
Python 里用 def 在函数体内部再定义一个函数,然后 return 那个名字即可。
第二个坎:返回的那个函数,怎么知道 f 和 n 是谁?
这才是本题的真正内容。答案是:内层函数 repeat 是在 make_repeater
的帧里被定义的,所以它的 parent 指向那一帧;
之后调用 repeat(x) 时,x 在自己的局部帧里找得到,
而 f 和 n 在局部帧里找不到,就顺着 parent 链找到
make_repeater 的帧,在那里找到。这种「函数记住了它出生环境」的现象叫
闭包(closure)。
不要试图在 make_repeater 里就把 f 套 n 次——
你根本没有 x!x 是以后才会传进来的。
make_repeater 的全部职责就是「把 f 和 n 收好,
造一个知道该怎么干活的函数交出去」。真正的计算推迟到第二次调用才发生。
第三个坎:套 n 次怎么写?
这里可以走两条路,都值得看清楚。
路线 A(本仓库采用):在内层用 while 循环反复覆盖 x。
维护两个状态:当前值 x,已经套了几次 k。
每轮 x = f(x)、k = k + 1,套满 n 次退出。
这跟 Q2、Q3 的循环骨架是同一套东西,你已经熟了。
路线 B:递归地构造函数。
make_repeater(f, n) 可以写成「先套一次 f,再交给
make_repeater(f, n-1)」,base case 是 n == 0 时返回
identity。写法漂亮,但它每次调用都新建 n 层嵌套的函数对象,
理解成本更高。作业只要求正确,路线 A 更直白,也更贴合本周讲过的内容。
路线 A 有一个容易踩的坑:能不能在内层直接写 n = n - 1 来倒着数?
不能。repeat 里给 n 赋值,Python 会把 n
当成 repeat 的局部变量,而它在被赋值前又被 while n > 0
读了一次,于是报
UnboundLocalError: local variable 'n' referenced before assignment
(就算能改,也会有另一个问题:n 被改小之后,同一个 repeater 第二次调用就废了。)
所以正确做法是另开一个计数器 k,只读 n、不写 n。
这一点是整道题最实用的教训。
代码逐行讲
def make_repeater(f, n):
"""Returns the function that computes the nth application of f.
>>> add_three = make_repeater(increment, 3)
>>> add_three(5)
8
>>> make_repeater(triple, 5)(1) # 3 * (3 * (3 * (3 * (3 * 1))))
243
>>> make_repeater(square, 2)(5) # square(square(5))
625
>>> make_repeater(square, 3)(5) # square(square(square(5)))
390625
"""
def repeat(x):
k = 0
while k < n: # apply f exactly n times
x = f(x)
k = k + 1
return x
return repeat
def repeat(x)::在make_repeater的帧里创建一个函数对象, 并把名字repeat绑给它。这一行不执行函数体, 只是「造一个函数」。函数对象里记着它的 parent 是当前这一帧。k = 0:计数器从 0 开始,配合while k < n, 循环体正好执行n次(k取 0, 1, …, n-1)。 写k = 1配k <= n也一样,挑一套记熟即可。while k < n::n在repeat的局部帧里没有, Python 顺着 parent 找到make_repeater的帧,读到当初传进来的那个值。 这就是闭包在起作用。注意这里只读不写。x = f(x):这一行做了两件事——调用f(同样是顺 parent 链找到的), 把结果重新绑给repeat的局部变量x。 「用旧值算新值再覆盖」是循环里最常见的模式。x是局部的, 所以外面的世界不受影响,同一个 repeater 可以重复调用。k = k + 1:推进计数器。return x(内层):套完n次后的最终值。return repeat(外层):没有括号。 返回的是函数对象本身,不是调用它。写成return repeat(x)会直接报NameError: name 'x' is not defined——make_repeater的帧里根本没有x。
两个 return 层次完全不同:内层的 return x 属于 repeat,
在用这个 repeater 的时候执行;外层的 return repeat 属于
make_repeater,在造这个 repeater 的时候执行。
缩进决定了它们各自属于谁,写错缩进会让 make_repeater 返回 None,
接着报 TypeError: 'NoneType' object is not callable。
验证:手动追踪 make_repeater(increment, 3) 再调用 (5)
第一阶段:造函数。执行 add_three = make_repeater(increment, 3)。
Global frame
increment → func increment(x)
make_repeater→ func make_repeater(f, n)
add_three → func repeat(x) [parent = f1] ← 调用结束后才建立
f1: make_repeater [parent = Global]
f → func increment(x)
n = 3
repeat → func repeat(x) [parent = f1] ← def 语句造出来的
返回值 = func repeat(x) [parent = f1]
★ f1 这一帧在 make_repeater 返回后并不消失,
因为 repeat 还指着它当 parent。f 和 n 就活在这里。第二阶段:用函数。执行 add_three(5)。
f2: repeat [parent = f1] ← parent 是 f1,不是 Global!
x = 5
k = 0
查 while k < n:k=0 在 f2 找到;n 在 f2 没有 → 去 f1 找到 3
第 1 轮: 0 < 3 True
f 在 f2 没有 → 去 f1 找到 increment
调用 increment(5) ─→ f3: increment [parent=Global] x=5 返回 6
x = 6 (改的是 f2 里的 x)
k = 1
第 2 轮: 1 < 3 True
increment(6) → 7
x = 7 , k = 2
第 3 轮: 2 < 3 True
increment(7) → 8
x = 8 , k = 3
检查: 3 < 3 False,退出
return x → 8输出 8,与 doctest 一致。
再看 make_repeater(square, 3)(5),这次把每轮的 x 写出来:
| 轮次 | 进入时 k | k < 3 | 进入时 x | f(x) 即 square(x) | 退出时 x |
|---|---|---|---|---|---|
| 初始化 | 0 | — | 5 | — | 5 |
| 1 | 0 | True | 5 | 5 * 5 = 25 | 25 |
| 2 | 1 | True | 25 | 25 * 25 = 625 | 625 |
| 3 | 2 | True | 625 | 625 * 625 = 390625 | 390625 |
| 检查 | 3 | False | 390625 | — | 返回 390625 |
注意指数:5 → 5² → 5⁴ → 5⁸。套 n 次平方得到的是 x^(2ⁿ),
不是 x^(2n)。make_repeater(square, 2)(5) 同理得 5⁴ = 625。
三层抽象的对比
| 层次 | 做法 | 本作业的例子 | 调用形态 |
|---|---|---|---|
| 函数作为值 | 把函数绑给变量 | Q1 f = sub | f(a, b) |
| 函数作为参数 | 调用者决定「怎么算每一项」 | Q3 product(n, term) | term(k) |
| 函数作为返回值 | 先收好参数,之后再干活 | Q4 make_repeater(f, n) | make_repeater(f, n)(x) |
return repeat(x):外层帧里没有x,报NameError: name 'x' is not defined。返回函数就不能带括号。- 在
repeat里写n = n - 1:报UnboundLocalError: local variable 'n' referenced before assignment。 一旦在函数体里给某个名字赋值,Python 就把它当局部变量,不再去 parent 找。 解决办法是另设计数器k。 - 把
repeat定义在make_repeater外面: 那它的 parent 就是 Global,找不到f和n,报NameError。 内层函数必须写在外层函数体里,闭包才成立。 - 缩进错误让
return repeat落进了repeat体内:make_repeater隐式返回None, 调用add_three(5)报TypeError: 'NoneType' object is not callable。 - 误以为
make_repeater(square, 3)(5)是 5 的 6 次方: 概念错,不是代码错,但会让你在检查结果时白白怀疑自己的代码。
5. Q5:Largest Factor(可选题)
题目要什么
给一个大于 1 的整数 n,返回比 n 小的、能整除 n 的最大整数。
>>> largest_factor(15) # factors are 1, 3, 5
5
>>> largest_factor(80) # factors are 1, 2, 4, 5, 8, 10, 16, 20, 40
40
>>> largest_factor(13) # factors are 1, 13
1
三条 doctest 挑得很有心机:
15:合数,答案 5(15 = 3 × 5)。80:因数很多,答案 40,也就是80 / 2——这提示了一个规律, 偶数的最大真因数就是它的一半。13:质数,比它小的因数只有 1,答案就是 1。 这是必须单独想清楚的边界:质数的循环会一路数到 1 才停。
题面明确 “takes an integer n that is greater than 1”,所以不用考虑
n = 1(那样 factor 从 0 起步,第一步就 n % 0 触发
ZeroDivisionError)和负数。题面的 Hint 也把判断整除的方法给了:
a % b == 0 表示「a 除以 b 余数为 0」,即 b 整除 a。
怎么想到的
「最大的、满足某条件的数」——这类问题有个万能套路:从最大的候选开始往下试, 第一个满足条件的就是答案。因为是从大到小扫的,第一个命中的必然最大,不用比较、不用记录。
那最大的候选是谁?题目要求「比 n 小」,所以候选从 n - 1 起,
往下 n - 2、n - 3……最小到 1。
条件是什么?n % factor == 0。
把「找最大」翻译成「从大往小扫,一命中就停」。 反过来如果从 1 往上扫,你就得一路记录「目前见过的最大因数」,多一个状态变量,还容易写错。 扫描方向选对了,代码能省一半。
接下来是写法上的一个小选择。最直白的是「循环里判断,命中就 return」:
factor = n - 1
while factor > 0:
if n % factor == 0:
return factor
factor = factor - 1
这也是官方解答的写法,完全正确。但仔细想想:这个循环真的需要 factor > 0
这个保险吗?不需要。因为 1 整除任何整数,n % 1 恒为 0,
所以扫到 1 的时候一定命中,factor 永远不会掉到 0 以下。
既然循环一定会因为「找到因数」而停止,就可以把「找到因数」直接写成循环条件的否定:
factor = n - 1
while n % factor != 0:
factor = factor - 1
return factor
这版少了一层 if,也少了一个 return,循环体只剩一行。
读起来是「只要 factor 还不能整除 n,就继续往下找」,非常贴近口语描述。
本仓库用的是这一版。
这一版的正确性依赖一个必须自己论证的事实:循环一定会停。
论证是这样的——factor 每轮减 1,严格递减;当 factor 减到 1 时,
n % 1 == 0 必为真,循环条件为假,退出。所以最坏情况(n 是质数)
也会在 factor == 1 时停下,返回 1,正合 largest_factor(13) → 1。
写「没有显式下界」的循环时,这种论证是必做功课,否则就是在赌。
代码逐行讲
def largest_factor(n):
"""Return the largest factor of n that is smaller than n.
>>> largest_factor(15) # factors are 1, 3, 5
5
>>> largest_factor(80) # factors are 1, 2, 4, 5, 8, 10, 16, 20, 40
40
>>> largest_factor(13) # factors are 1, 13
1
"""
factor = n - 1 # search downward for the first divisor of n
while n % factor != 0:
factor = factor - 1
return factor
factor = n - 1:最大的合法候选。写n就错了——n % n == 0立刻成立,会返回n自己,而题目要求「比n小」。 这是本题唯一的一个「差一」陷阱。while n % factor != 0::循环条件是「还没找到」。%是取模运算符,n % factor是n除以factor的余数。余数不为 0 说明除不尽,继续找。factor = factor - 1:往下一个候选。循环体只有这一行—— 所有判断都在条件里做完了。return factor:循环退出意味着条件为假,即n % factor == 0, 也就是当前的factor整除n。因为是从大往小扫的,它就是最大的那个。 注意return在循环外,返回的是「导致循环停下的那个值」。
验证:手动追踪三条 doctest
largest_factor(15):
| 轮次 | factor | 15 % factor | != 0? | 动作 |
|---|---|---|---|---|
| 初始化 | 14 | 1 | True | 减 1 |
| 1 | 13 | 2 | True | 减 1 |
| 2 | 12 | 3 | True | 减 1 |
| 3 | 11 | 4 | True | 减 1 |
| 4 | 10 | 5 | True | 减 1 |
| 5 | 9 | 6 | True | 减 1 |
| 6 | 8 | 7 | True | 减 1 |
| 7 | 7 | 1 | True | 减 1 |
| 8 | 6 | 3 | True | 减 1 |
| 9 | 5 | 0 | False | 退出,return 5 |
得到 5,与 doctest 一致。
largest_factor(80):factor 从 79 起,
79、78、…… 都除不尽 80(80 的因数只有 1, 2, 4, 5, 8, 10, 16, 20, 40, 80),
一直减到 factor = 40 时 80 % 40 == 0,退出,返回 40。
一共试了 40 个候选。
largest_factor(13)(质数,最坏情况):
factor 从 12 一路减,13 % 12 = 1、13 % 11 = 2、
13 % 10 = 3、…、13 % 2 = 1,全都非 0;
减到 factor = 1 时 13 % 1 == 0,退出,返回 1。
这条追踪证明了「循环一定会停」的论证是对的。
这道题在教「把搜索写成循环」的两个套路:
(a) 扫描方向决定了要不要额外的「最优值」变量——找最大就从大往小扫;
(b) 如果能证明循环必定在某处终止,就可以把「找到了」写进循环条件的否定,省掉循环体里的 if+return。
但这份省事是有代价的:你必须真的证明它会停,否则一个不小心就是死循环或
ZeroDivisionError。
factor从n开始:立刻命中,返回n本身。largest_factor(15)返回 15 而不是 5。- 从 1 往上扫但忘了记录最大值:写成
while factor < n: if n % factor == 0: return factor, 返回的是最小的因数,永远是 1。 - 把整除判断写反:
factor % n == 0。 对n = 15,14 % 15是 14,13 % 15是 13…… 一直到factor变成 0 之前都不为 0,然后0 % 15 == 0成立,返回 0。 被除数和除数写反是取模最常见的错。 - 用
/判断整除:n / factor == n // factor之类的写法虽然 碰巧能对,但绕远且引入浮点数;%才是直接手段。 - 忘了
factor = factor - 1:死循环,无输出无报错。
6. Q6:Accumulate(可选题)
题目要什么
这题分三小问。第一问实现 accumulate(fuse, start, n, term),
它是 product 的进一步一般化:把序列的前 n 项
term(1), term(2), ..., term(n) 与初值 start
用双参数函数 fuse 依次「熔合」起来。四个参数各司其职:
| 参数 | 类型 | 作用 | 在 product 里对应什么 |
|---|---|---|---|
fuse | 双参数函数 | 怎么把「已累积的结果」和「当前项」合成一个新结果 | 写死的 * |
start | 值 | 累积的初值 | 写死的 1 |
n | 非负整数 | 要熔合多少项 | n |
term | 单参数函数 | term(i) 是第 i 项 | term |
>>> accumulate(add, 0, 5, identity) # 0 + 1 + 2 + 3 + 4 + 5
15
>>> accumulate(add, 11, 5, identity) # 11 + 1 + 2 + 3 + 4 + 5
26
>>> accumulate(add, 11, 0, identity) # 11 (fuse is never used)
11
>>> accumulate(add, 11, 3, square) # 11 + 1^2 + 2^2 + 3^2
25
>>> accumulate(mul, 2, 3, square) # 2 * 1^2 * 2^2 * 3^2
72
>>> # 2 + (1^2 + 1) + (2^2 + 1) + (3^2 + 1)
>>> accumulate(lambda x, y: x + y + 1, 2, 3, square)
19
三个必须盯住的点:
- 第三条
accumulate(add, 11, 0, identity)返回11, 注释还特意写了 “fuse is never used”。n == 0时循环一次都不能进,直接返回start。这是n被声明为「非负整数」而不是「正整数」的原因, 也是本题唯一硬性的边界要求。 - 最后一条的
fuse是个lambda x, y: x + y + 1, 不满足「fuse(a, b) 就是简单加法」的直觉。它存在的意义是防止你偷懒—— 如果你把fuse写死成+或者调换参数顺序做了什么手脚, 这条会立刻暴露。 - 题面明确 “Assume that fuse is commutative and associative”,
即
fuse(a,b) == fuse(b,a)且fuse(fuse(a,b),c) == fuse(a,fuse(b,c))。 这句话给了你自由:不必按题面示例里那种右结合的嵌套顺序去算, 从左往右一路累积得到的结果是一样的。
第二、三问要求把 summation_using_accumulate 和
product_using_accumulate 各写成一行 return。
doctest 里有一条用 ast 检查函数体结构:
>>> import inspect, ast
>>> [type(x).__name__ for x in ast.parse(inspect.getsource(summation_using_accumulate)).body[0].body]
['Expr', 'Return']
它把函数源码解析成语法树,列出函数体里每条语句的类型,要求正好是
['Expr', 'Return']——Expr 是那段 docstring(docstring 在语法树里
就是一条表达式语句),Return 是你写的那一行。多写任何一条语句都会失败。
怎么想到的
先把 Q3 的 product 和一个假想的 summation 并排放着看:
product | summation | |
|---|---|---|
| 初值 | total = 1 | total = 0 |
| 循环条件 | k <= n | k <= n |
| 更新 | total = total * term(k) | total = total + term(k) |
| 推进 | k = k + 1 | k = k + 1 |
| 返回 | total | total |
五行里有三行完全一样,只有初值和合并运算不同。
按 Q1 学到的那一招——「控制流的差异降级成数据的差异」——把这两处提成参数就完了:
初值提成 start,运算提成 fuse。运算是一段计算,所以它得是个函数,
写成 fuse(total, term(k))。
total * term(k) 里的 * 是语法,不能当参数传。
但 operator 模块里的 mul 是函数,
mul(a, b) 与 a * b 求值结果一样,而它可以被传来传去。
Q1 开头那句 “Python's operator module contains two-argument functions such as add and sub”
在这里终于兑现了它的价值——整份作业是有伏笔的。
然后是 n == 0 的边界。好消息是不用任何特殊处理:
total 初始化成 start,循环条件 k <= n 在
k = 1、n = 0 时是 1 <= 0 即 False,
循环体一次都不进,直接 return total 即 start。
fuse 确实一次都没被调用,跟注释说的一模一样。
把初值直接放进累加器,边界自动就对了——这是循环写法的一个通用技巧。
再看第二、三问。有了 accumulate,summation 就是
「用 add 熔合、从 0 开始」,product 就是
「用 mul 熔合、从 1 开始」。为什么起始值必须是这两个数?
因为它们分别是加法和乘法的单位元(identity element):
0 + x == x,1 * x == x。如果 summation 从 1 起,
结果会整体多 1;如果 product 从 0 起,结果恒为 0。
代码逐行讲
def accumulate(fuse, start, n, term):
"""Return the result of fusing together the first n terms in a sequence
and start. The terms to be fused are term(1), term(2), ..., term(n).
The function fuse is a two-argument commutative & associative function.
>>> accumulate(add, 0, 5, identity) # 0 + 1 + 2 + 3 + 4 + 5
15
>>> accumulate(add, 11, 5, identity) # 11 + 1 + 2 + 3 + 4 + 5
26
>>> accumulate(add, 11, 0, identity) # 11 (fuse is never used)
11
>>> accumulate(add, 11, 3, square) # 11 + 1^2 + 2^2 + 3^2
25
>>> accumulate(mul, 2, 3, square) # 2 * 1^2 * 2^2 * 3^2
72
>>> # 2 + (1^2 + 1) + (2^2 + 1) + (3^2 + 1)
>>> accumulate(lambda x, y: x + y + 1, 2, 3, square)
19
"""
total, k = start, 1
while k <= n:
total = fuse(total, term(k))
k = k + 1
return total
total, k = start, 1:与product唯一的区别就是total的初值不再写死 1,而是取自参数start。 这一处改动同时解决了「起始值可配置」和「n == 0直接返回start」两件事。while k <= n::n为 0 时直接跳过。含n, 所以term(n)会被算进去。total = fuse(total, term(k)):参数顺序是「已累积的在前,新项在后」。 题面保证fuse可交换,所以写反了对这几条 doctest 也不会翻车 (lambda x, y: x + y + 1也是对称的);但按「累积值 + 新项」的顺序写, 语义上更符合accumulate这个名字,也和product里total * term(k)的位置对得上。 注意这一行里有两次函数调用:先term(k)算出第 k 项, 再fuse(...)把它熔进来。k = k + 1、return total:与product完全相同。
def summation_using_accumulate(n, term):
"""Returns the sum: term(1) + ... + term(n), using accumulate.
...
"""
return accumulate(add, 0, n, term)
def product_using_accumulate(n, term):
"""Returns the product: term(1) * ... * term(n), using accumulate.
...
"""
return accumulate(mul, 1, n, term)
accumulate(add, 0, n, term):add是加法函数(from operator import add, mul在文件顶部已经导入),起始值 0 是加法单位元。accumulate(mul, 1, n, term):mul是乘法函数,起始值 1 是乘法单位元。- 两者都是单独一行
return,没有临时变量、没有if, 才能通过那条ast结构检查。
验证:手动追踪 accumulate(add, 11, 3, square)
期望 11 + 1² + 2² + 3² = 11 + 1 + 4 + 9 = 25。
| 轮次 | k | k <= 3 | term(k) 即 square(k) | fuse(total, term(k)) 即 add(...) | 新 total |
|---|---|---|---|---|---|
| 初始化 | 1 | — | — | — | 11(= start) |
| 1 | 1 | True | 1 | add(11, 1) | 12 |
| 2 | 2 | True | 4 | add(12, 4) | 16 |
| 3 | 3 | True | 9 | add(16, 9) | 25 |
| 检查 | 4 | False | — | 退出 | return 25 |
注意我们是从左往右累积的:((11+1)+4)+9。
题面示例写的是右结合的 add(11, add(square(1), add(square(2), square(3)))),
两者结果相同——这正是「假设 fuse 满足结合律」这句话在替我们兜底。
再追那条最刁钻的 lambda:accumulate(lambda x, y: x + y + 1, 2, 3, square)。
这里 fuse(x, y) 求值成 x + y + 1,每次熔合都白送一个 +1。
total = start = 2 , k = 1
轮 1: term(1) = square(1) = 1
total = fuse(2, 1) = 2 + 1 + 1 = 4
轮 2: term(2) = square(2) = 4
total = fuse(4, 4) = 4 + 4 + 1 = 9
轮 3: term(3) = square(3) = 9
total = fuse(9, 9) = 9 + 9 + 1 = 19
k 变成 4,退出 → return 19与 doctest 注释 # 2 + (1^2 + 1) + (2^2 + 1) + (3^2 + 1) 对得上:
2 + 2 + 5 + 10 = 19。三次熔合各贡献了一个 +1。
边界 accumulate(add, 11, 0, identity):
total = 11,k = 1;检查 1 <= 0 为 False;
循环体不执行,fuse 一次也没被调用;return 11。正确。
product_using_accumulate(4, square):
展开成 accumulate(mul, 1, 4, square),
逐轮 total 为 1 → 1*1=1 → 1*4=4 → 4*9=36 →
36*16=576,返回 576,与 doctest 一致。
抽象的方向永远是:写出两三个具体版本 → 找出它们唯一不同的地方 → 把不同处提升为参数。
accumulate 就是 product 被这样提升了两次的产物
(把 * 提成 fuse,把 1 提成 start)。
提升之后,原来的 product 退化成一行调用——这不是代码变短了,
而是「求积」这个概念被还原成了它的两个组成要素:乘法,和乘法的单位元。
- 循环条件写成
k < n再补一次:n == 0时补的那次会调用fuse,accumulate(add, 11, 0, identity)返回 12 而不是 11。 total初值写死 0 或 1:start被丢掉,accumulate(add, 11, 5, identity)返回 15 而不是 26。- 写
fuse = fuse(total, term(k)):把参数fuse覆盖成了一个数, 第二轮调用时报TypeError: 'int' object is not callable。 - 传
add()而不是add:summation_using_accumulate里写accumulate(add(), 0, n, term), 报TypeError: add expected 2 arguments, got 0。 - 在
summation_using_accumulate里多写了一行: 比如先result = accumulate(...)再return result, 功能对,但那条ast检查会得到['Expr', 'Assign', 'Return'],测试失败。 - 混淆
mul与sum/prod:operator模块里乘法叫mul,不叫times或multiply, 写错会报ImportError: cannot import name ...。
7. 整份作业回顾
六道题按题目看是零散的,但它们串起来讲的是同一条线: 函数从「一段被命名的代码」逐步变成「可以像数字一样搬来搬去的值」。
| 题目 | 核心手法 | 迁移到哪里 |
|---|---|---|
Q1 a_plus_abs_b | 分支不选「算什么」,只选「f 指向谁」;计算只写一份 | 策略模式;Q6 的 fuse 参数;后面的 dispatch 函数 |
Q2 hailstone | 循环 = 初值 + 更新规则 + 终止条件;print 与 return 各管一摊 | 任何迭代过程;调试时用 print 看中间状态而不改返回值 |
Q3 product | 把「每项怎么算」提成函数参数,骨架只写一遍 | map/filter 的思想;Lecture 之后的 summation、树递归里的 term |
Q4 make_repeater | 返回函数;内层函数通过 parent 链记住外层的 f 和 n | 闭包、装饰器、柯里化(currying)、后面的数据抽象与消息传递 |
Q5 largest_factor | 从大往小扫,第一个命中即最优;论证循环必停 | 所有「找最大/最小满足条件者」的搜索;循环不变式的思维 |
Q6 accumulate | 把运算和单位元一起提成参数,sum/product 退化成一行 | reduce/fold;后面 Scheme 里的 accumulate;SQL 的聚合函数 |
三条最该带走的话
- 名字 vs 调用:
f是一个指向函数对象的名字,f(x)才是调用。 本作业至少四处(Q1 的f = sub、Q3 的term(k)、 Q4 的return repeat、Q6 的accumulate(add, ...)) 都在考同一件事:该写括号的地方写括号,不该写的地方绝不写。 - 循环的四问:哪些变量在变?初值是多少?每轮怎么更新?什么时候停? Q2、Q3、Q5、Q6 的循环骨架几乎一模一样,差别只在这四项的填法。 把这四项在纸上填清楚,代码就是抄下来。
- 抽象的方法:先写两三个具体版本,找出唯一不同的那一处, 把它提升成参数。不同的是值就传值,不同的是计算就传函数。 Q3 → Q6 走的正是这条路。
动手练习
下面几题基于本作业的代码,答案折叠在里面。
练习 1. 不改 product 的定义,用它算出 1 + 2 + ... + n 之外的东西:
写一个表达式,算 2 * 4 * 6 * 8 * 10。
看答案
product(5, lambda k: 2 * k),或者先 def double(k): return 2 * k
再 product(5, double)。逐项是 2, 4, 6, 8, 10,乘积 3840。
关键在于把「第 k 项是什么」写成一个以 k 为参数的函数。
练习 2. make_repeater(f, 1)(x) 等于什么?make_repeater(f, 0)(x) 呢?
看答案
make_repeater(f, 1)(x) 就是 f(x):循环执行一轮,x = f(x),返回。
make_repeater(f, 0)(x) 返回 x 本身:while 0 < 0 为假,
一轮都不执行,直接 return x。所以 make_repeater(f, 0) 相当于 identity。
题面说 n 是正整数,但代码在 0 上恰好也给出了数学上正确的答案。
练习 3. 下面这段代码输出什么?为什么?
def f(x):
print('called with', x)
return x + 1
r = make_repeater(f, 3)
print('---')
r(10)
看答案
先输出一行 ---,然后才是三行 called with 10、called with 11、
called with 12。因为 make_repeater(f, 3) 只是造了一个函数,
一次都没调用 f;f 直到 r(10) 那一刻才被真正执行三次。
这就是「返回函数 = 把计算推迟」。注意最后 r(10) 的返回值 13 没有被打印
(脚本里单独一个表达式不会回显,只有交互式解释器才会)。
练习 4. 用 accumulate 写出 hailstone 吗?为什么不行?
看答案
不行。accumulate 的循环次数由参数 n 事先决定,
而且第 k 项只依赖下标 k(通过 term(k))。
但冰雹序列的下一项依赖上一项的值,而且总长度事先不知道——
它甚至没人能证明一定有限。这类「循环次数取决于运行时状态」的过程,
必须用带条件的 while,不能套进 accumulate 的骨架。
认清一个抽象覆盖不了什么,和知道它能干什么同样重要。