LECTURE 03

高阶函数:把函数当成值来传递

函数不只是「一段能跑的代码」,它本身也是一个可以被存进变量、传进参数、从别的函数里返回出来的值。这一讲把这件事讲透,顺便把「名字到底在哪个帧里找」这个问题第一次正式回答清楚。

0. 本讲导读

上一讲结束时,你手上的工具是这样一套:用 def 定义函数,用 if 挑分支,用 while 重复。用它们你已经能写出 sum_digits、fizzbuzz、Hailstone 这类程序了。

但这套工具有一条隐形的天花板:函数只能被调用,不能被摆弄。在你目前的世界观里,square 是一段代码的名字,你唯一能对它做的事就是在后面加一对括号。数字可以相加、可以存进变量、可以当参数传给别人;函数呢?函数好像是另一个次元的东西。

这条天花板会以一种很具体的方式撞到你。看下面两个函数:

def sum_of_squares(n):          def sum_of_cubes(n):
    i = 1                           i = 1
    total = 0                       total = 0
    while i <= n:                   while i <= n:
        total += i ** 2                 total += i ** 3
        i += 1                          i += 1
    return total                    return total

十行代码里有九行一模一样,只有 ** 2 和 ** 3 不同。如果明天要加 sum_of_fourth_powers、后天要加「1 到 n 的每个数取倒数再相加」,你就得再复制粘贴两次。这不是「代码有点丑」的问题——这是抽象层次不够的问题:你能把「加数」这件事抽象成参数 n,却没办法把「怎么由 i 算出被加项」这件事抽象成参数。

本讲要做的,就是把最后这层天花板拆掉。核心只有一句话:在 Python 里,函数是一等公民(first-class)——凡是数字能待的地方,函数都能待。它能被赋给变量、能被当实参传进去、能被当返回值送出来、能被放进数据结构里。一旦接受了这件事,上面那两个函数就能合成一个:

def summation(n, term):
    i = 1
    total = 0
    while i <= n:
        total += term(i)
        i += 1
    return total

而高阶函数(higher-order function, HOF)的定义就是:一个把函数当输入、或把函数当输出、或两者都占的函数。summation 属于前者。

这一讲还有第二条主线,比第一条更基础,也更容易被忽略:环境(environment)。一旦函数可以在别的函数里被定义、被返回、跑到别处再被调用,「x 这个名字指的是哪个 x」就不再是显然的了。上一讲你只需要区分「全局帧」和「函数的局部帧」两层;这一讲会出现三层、四层,还会出现「函数已经跑完了、它的帧却还得留着」的情况。所以本讲后半段要给出一套精确到可以拿笔算的名字查找规则。

核心结论
  • 函数是值。square(不带括号)求值成一个函数对象;square(3)(带括号)才是调用。这两件事天差地别,混淆它们是本讲最高频的错误。
  • lambda 表达式写作 lambda <参数>: <返回表达式>,它是一个表达式,求值成一个没有内在名字(intrinsic name)的函数。它只能装一个表达式,装不下 if 语句、while 循环和赋值。
  • 高阶函数存在的理由是泛化(generalization)与不重复自己(DRY, Don't Repeat Yourself):把几段代码里「唯一不同的那一小块」提炼成一个函数参数。
  • 一个环境是一串帧(frame);一个帧是一组「名字 → 值」的绑定。所有 Python 程序都从特殊的全局帧(global frame)开始。
  • 父帧由「函数在哪里被定义」决定,不由「函数在哪里被调用」决定。这是整门课最重要的一条规则之一。
  • 查名字:先看当前帧,找不到就顺着父帧往上找,一路找到全局帧还没有就抛 NameError。但赋值只会写在当前帧里——想在函数里修改外层的名字,会得到 UnboundLocalError。

本讲的两道随堂练习 combination 和 multiply_triple(配套文件 03.py)会放在第 8、9 节完整推演。它们不是花架子:Lab 02 里几乎每道题都是它们的变体。

1. 热身:把上一讲的控制流用在 Fibonacci 上

本讲正式开始前,先用一道题把上一讲的 while 循环再过一遍。之所以选 Fibonacci,是因为它有一个特别值得警惕的性质:循环里需要同时维护两个变量,而且它们的更新是互相纠缠的。这类题最容易在「更新顺序」上翻车。

Fibonacci 数列(Fibonacci sequence)的定义是三条:

  • 第 0 个 Fibonacci 数是 0;
  • 第 1 个 Fibonacci 数是 1;
  • 第 n 个 Fibonacci 数是前两个之和。

于是数列开头是 0, 1, 1, 2, 3, 5, 8, 13, …。(这个数列最早出现在公元前 200 年左右 Pingala 关于梵语诗律的著作里,用来数「由长短两种音节构成的格律有多少种」;欧洲人是通过 1202 年 Leonardo of Pisa 的《Liber Abaci》才知道它的,Fibonacci 是他的别名。)

先想清楚循环里要记什么

要算第 n 个数,我必须知道「前两个数」。所以循环状态至少是两个变量。约定:

  • prev:当前已经走到的位置的前一个 Fibonacci 数;
  • curr:当前已经走到的位置的 Fibonacci 数。

初始时我们站在「第 0 个」上,所以 prev = 0(可以想成第 −1 个)、curr = 1(第 1 个)… 这个索引对齐很容易绕晕。更稳妥的做法是先定死一个不变式(invariant),然后逼着代码去满足它。

逐步推演

取不变式:每次检查循环条件时,prev 是第 i 个 Fibonacci 数,curr 是第 i+1 个。

初始化:i = 0,此时 prev 应是第 0 个 = 0,curr 应是第 1 个 = 1。不变式成立。

循环体一次要把「窗口」右移一格:新的第 i+1 个 = 旧的 curr;新的第 i+2 个 = 旧的 prev + curr。所以:

prev, curr = curr, prev + curr
i += 1

退出条件:当 i == n 时,prev 恰好是第 n 个,直接 return prev。所以循环条件是 i < n。

def fib(n):
    """返回第 n 个 Fibonacci 数(n 从 0 开始计)。"""
    prev, curr = 0, 1
    i = 0
    while i < n:
        prev, curr = curr, prev + curr
        i += 1
    return prev

验证 fib(5),逐轮列表。注意表格里「进入循环体前」的 prev 一栏,从上往下读就是 0, 1, 1, 2, 3——正是数列本身:

检查条件时 ii < 5?prevcurr执行后 (prev, curr)
0True01(1, 1)
1True11(1, 2)
2True12(2, 3)
3True23(3, 5)
4True35(5, 8)
5False58返回 prev = 5

fib(0) 也对:条件 0 < 0 一开始就是假,循环一次都不跑,直接返回初值 0。这种「循环体零次执行仍然正确」的性质,是判断初始化写得对不对的最好试金石。

常见误区

把那行同时赋值拆成两行,是这道题最经典的错误:

prev = curr          # ← prev 已经被改成 curr 了
curr = prev + curr   # ← 这里的 prev 是「新的」prev,等于 curr

结果 curr 变成了 2 * curr,数列直接变成 1, 2, 4, 8, 16…。不会报错,只会算错——比报错难查得多。

根本原因:Python 的 a, b = <表达式1>, <表达式2> 是先把右边两个表达式全部求值完毕,再一起绑定。等号右边看到的永远是「这一行开始前」的旧值。拆成两行就打破了这个同时性。

如果实在不想用同时赋值,就得自己引入一个临时名字把旧值存下来:

old_prev = prev
prev = curr
curr = old_prev + curr

这道题的意义不止于复习。等下面讲到 summation 时你会发现,fib 的骨架(初始化累积变量 → while → 更新 → 返回)和 summation、和随堂练习 combination 的骨架完全一样。高阶函数并没有发明新的循环,它只是把骨架里「变化的那一小块」抽出来当参数。

2. 函数是数据

Python 里的函数是一等对象("first-class" object)。「一等」这个词的意思很朴素:它享有和数字、字符串完全同等的待遇。具体说是四条权利:

  • 可以被赋给一个名字;
  • 可以作为实参传给别的函数;
  • 可以作为返回值从函数里送出来;
  • 可以被放进数据结构里(列表、字典等,后面几讲会用到)。

不是所有语言都这样。在很多语言里,函数是编译期的一个静态实体,你没法把它当成运行时的一个值搬来搬去。Python 可以,而这一条正是本讲所有内容的地基。同一个函数于是有了两副面孔:它既是「一段可执行的过程(procedure)」,也是「一个可以被搬运的数据(data)」。第一讲把 min 和 max 存进变量 f 和 g,当时看起来只是个小把戏,其实已经在用这条性质了。

不带括号 vs 带括号

这是本讲第一个、也是最要命的一个分水岭。请在交互式解释器里亲手敲一遍:

>>> def square(x):
...     return x * x
...
>>> square
<function square at 0x77a41f963d90>
>>> square(3)
9

square 求值成一个函数对象(function object),解释器把它显示成 <function square at 0x…>——那串十六进制是它在内存里的地址,每次运行都不一样,不用管。square(3) 则是一个调用表达式(call expression),求值结果是 9。

换个说法:括号不是函数名字的一部分,括号是「调用」这个动作。名字本身只是指向那个函数对象的一根线。

>>> f = square          # 没有括号:把函数对象本身绑给 f
>>> f
<function square at 0x77a41f963d90>
>>> f(4)
16
>>> g = square(4)       # 有括号:先调用,把结果 16 绑给 g
>>> g
16
>>> g(2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not callable

注意 f 和 square 显示出来的地址一模一样——它们不是两个函数,是同一个函数对象的两个名字。这跟 a = 5; b = a 之后 a 和 b 指着同一个 5 是完全相同的机制。

常见误区

TypeError: 'int' object is not callable 是整个学期你会见到最多的报错之一。它的意思逐字翻译是「一个 int 类型的对象不能被调用」,也就是:你在某个东西后面写了括号,但那个东西不是函数。

八成的成因是「该传函数的地方,不小心把函数调用掉了」:

>>> def cube(x):
...     return x * x * x
...
>>> summation(3, cube(2))    # ← 错:cube(2) 先求值成 8,传进去的是整数 8
Traceback (most recent call last):
  ...
TypeError: 'int' object is not callable

summation 拿到 term = 8,然后执行 term(i),等于在写 8(1)。

另一种错法是「以为不写实参就不算调用」:

>>> summation(3, cube())
Traceback (most recent call last):
  ...
TypeError: cube() missing 1 required positional argument: 'x'

只要写了括号就是调用。要传函数本身,括号必须一个都不写:summation(3, cube)。

内建函数也是值

>>> min
<built-in function min>
>>> f, g = min, max
>>> f(3, 5)
3
>>> g(3, 5)
5

min 显示成 <built-in function min> 而不是 <function min at 0x…>,因为它是用 C 实现、随解释器一起内建的。但在「能不能当值用」这件事上,它和你自己写的 square 没有任何区别。

直觉

把 def square(x): ... 读成两步会清楚很多:(1)造出一个函数对象;(2)把这个对象绑定到全局帧里的名字 square 上。

也就是说,def 语句本质上是一种特殊的赋值。这个视角在下一节讲 lambda 时会立刻派上用场:lambda 只做第(1)步,第(2)步要不要做、绑到哪个名字上,由你自己决定。

3. lambda 表达式:造一个没有名字的函数

Lambda expressions 幻灯片:lambda 语法与 anonymous function
lambda 的全部语法就这一行:关键字 lambda、参数列表、一个冒号、一个返回表达式。它被叫做匿名函数,是因为它没有「内在名字」——def 造出来的函数自带一个名字,lambda 造出来的没有。

到目前为止,造函数的唯一方式是 def。def 是一条语句,语句不能出现在需要值的位置上。当你想「就地」造一个只用一次的小函数,比如作为实参塞进 summation,用 def 就得先在上面单独写三行,再把名字传下去——为了一个用完就扔的小东西,代价有点大。

lambda 表达式(lambda expression)解决的正是这件事。它是一个表达式,求值成一个函数:

lambda <parameters>: <return expression>

三个部分:关键字 lambda;逗号分隔的参数名(可以零个);一个冒号;一个表达式,它的值就是这个函数的返回值。没有 return 这个词——冒号后面那个表达式自动就是返回值。

>>> lambda x: x * x
<function <lambda> at 0x77a41ef86170>
>>> (lambda x: x * x)(5)
25
>>> square = lambda x: x * x
>>> square(5)
25
>>> (lambda: 42)()          # 零个参数
42
>>> (lambda x, y: x + y)(3, 4)
7

第一行值得盯一会儿:直接求值一个 lambda 表达式,得到的是 <function <lambda> at 0x…>——名字位置上写着 <lambda>。这就是「匿名函数(anonymous function)」和「没有内在名字(intrinsic name)」的字面含义:

>>> def h(): pass
...
>>> h.__name__
'h'
>>> f = lambda x: x * x
>>> f.__name__
'<lambda>'

f = lambda x: x * x 里,f 只是一个外部绑定,函数自己并不知道有人管它叫 f。def square(x) 造出来的函数则把 square 记在了自己身上。这个差别在读 traceback 时会显现出来:lambda 抛异常时,栈帧那一行显示的是 <lambda>,比函数名难定位得多。

lambda 装不下什么

冒号后面必须是一个表达式。这四个字排除了非常多东西:

写法能否放进 lambda说明
x * x能算术表达式
f(x) + g(y)能调用表达式也是表达式
x if x > 0 else -x能这是条件表达式,不是 if 语句
lambda y: x + y能lambda 本身就是表达式,可以嵌套
return x * x不能return 是语句
y = x + 1不能赋值是语句
while / if 语句不能复合语句
print(x)能(但没用)是调用表达式,可它返回 None
常见误区

在 lambda 里写 return,报错很不友好:

>>> f = lambda x: return x * x
  File "<stdin>", line 1
    f = lambda x: return x * x
                  ^^^^^^
SyntaxError: invalid syntax

在 lambda 里写赋值,报错稍微明确一点:

>>> g = lambda x: y = x + 1
  File "<stdin>", line 1
SyntaxError: cannot assign to lambda

记住一句话就够了:lambda 的冒号后面是「返回值」,不是「函数体」。

什么时候用 lambda,什么时候用 def

deflambda
它是什么语句(statement)表达式(expression)
能放在实参位置吗不能能
内在名字有(__name__ 是函数名)无(__name__ 是 '<lambda>')
函数体任意多条语句只有一个表达式
能写 docstring / doctest 吗能不能
适合什么有名字、要复用、逻辑长的函数一次性的小算式

实践建议:看不懂一个 lambda 在干什么,就把它翻译成 def 再看。两者的对应是机械的:

# 这两段完全等价(除了 __name__ 不同)
f = lambda x, y: x * y + 1

def f(x, y):
    return x * y + 1

反过来,题目要求「只用 lambda 写」时(Lab 02 和考试里常见),也照着这个对应关系倒着翻就行。

4. 函数作为输入:summation 的诞生

回到导读里那两个几乎一模一样的函数。

Functions as inputs (1 of 3):sum_of_squares 与 sum_of_cubes 并排
把两段代码并排放,重复就无所遁形:六行里有五行逐字相同,唯一的差别在 total += i ** 2 和 total += i ** 3 这一处。判断该不该上高阶函数,第一步永远是这样把候选代码并排对齐。
def sum_of_squares(n):
    i = 1
    total = 0
    while i <= n:
        total += i ** 2
        i += 1
    return total

def sum_of_cubes(n):
    i = 1
    total = 0
    while i <= n:
        total += i ** 3
        i += 1
    return total

差异只有 i ** 2 / i ** 3。现在问一个关键问题:这个差异是什么类型的东西?

它不是一个数——如果只是数不同,比如一个加 2 一个加 3,那加一个普通参数就解决了。它是一条「由 i 算出被加项」的规则。而在 Python 里,「由输入算出输出的规则」正好有一个现成的表示:函数。

既然函数是一等公民、可以当参数传,那就把这条规则抽成参数。习惯上叫它 term(「项」):

Functions as inputs (3 of 3):summation 的定义,term(i) 被高亮
抽象之后的样子:原来写死的 i ** 2 变成了 term(i)。summation 自己完全不知道 term 会算什么,它只负责「从 1 数到 n、把每一项累加起来」这个骨架。被高亮的 term(i) 是整个函数唯一的「留白」。
def summation(n, term):
    """把 term(1) + term(2) + ... + term(n) 加起来。

    >>> summation(3, cube)
    36
    """
    i = 1
    total = 0
    while i <= n:
        total += term(i)
        i += 1
    return total

用起来:

>>> def square(x):
...     return x * x
...
>>> def cube(x):
...     return x * x * x
...
>>> summation(3, square)
14
>>> summation(3, cube)
36
>>> summation(3, lambda x: 1 / x)
1.8333333333333333

注意最后一行:新需求「1 到 n 的倒数和」不需要新写一个函数了,一个 lambda 就地解决。这就是抽象带来的复利——summation 写一次,以后所有「从 1 加到 n」的问题都免费。

term 在函数体里到底是怎么被用的

很多人第一次看到 total += term(i) 会卡住:term 是个参数,参数不就是变量吗,变量后面怎么能加括号?

请回忆第一讲的调用表达式求值规则:先求算子(operator),再求算子数(operands),然后把算子的值施加到算子数的值上。规则里从来没说「算子必须是一个函数名字面量」。算子是个表达式,它求值成什么就用什么。

逐步推演

追踪 summation(3, cube),重点看 term(i) 那一步。

1 求值 summation(3, cube):先求算子 summation → 一个函数对象;再求算子数 3 → 3,cube → 一个函数对象(没有括号,所以不调用)。
2 新建一帧 f1,绑定 n = 3、term = <function cube>。执行函数体。
3 i = 1、total = 0 写进 f1。条件 1 <= 3 为真,进入循环体。
4 求值 term(i):算子 term 在 f1 里查到,值是 cube 那个函数对象;算子数 i 查到是 1。于是新建一帧 f2,绑定 x = 1,执行 return x * x * x,返回 1。
5 total += 1 → total = 1;i += 1 → i = 2。
6 第二轮:term(2) 新建 f3,绑定 x = 2,返回 8。total = 9,i = 3。
7 第三轮:term(3) 新建 f4,绑定 x = 3,返回 27。total = 36,i = 4。
8 条件 4 <= 3 为假,退出循环,return total → 36。

循环跑完的那一刻,帧的样子是这样(f2/f3/f4 各自在返回后就用不着了,这里一并画出来看清关系):

Global frame
squarefunc square(x)
cubefunc cube(x)
summationfunc summation(n, term)
f1: summation [parent=Global]
n3
termfunc cube(x)
i4
total36
返回值36
f4: cube [parent=Global]
x3
返回值27

这里有个细节现在先记住、第 7 节再讲透:f4 的 parent 是 Global,不是 f1。cube 是在全局帧里用 def 定义的,所以不管它被谁调用、在哪一层调用,它的父帧永远是 Global。

直觉

summation 干的事情可以用一句话概括:它把「循环的骨架」和「每一项算什么」拆开了。

骨架(初始化、条件、更新、累加、返回)是所有求和问题共有的,写一次就够;「每一项算什么」是每个问题独有的,用参数注入。

这个「骨架 + 留白」的模式,是本课程后面所有抽象手段的原型:数据抽象是「操作骨架 + 表示留白」,递归是「递归骨架 + base case 留白」,接口是「调用骨架 + 实现留白」。认准它。

为什么不用一个「标志参数」代替

初学者常想到另一条路:加一个参数说明「要平方还是立方」。

def sum_of_powers(n, which):
    i = 1
    total = 0
    while i <= n:
        if which == 'square':
            total += i ** 2
        elif which == 'cube':
            total += i ** 3
        i += 1
    return total

这确实能跑,但它只解决了「这两种情况」。要加第三种,就得回来改 sum_of_powers 的函数体,再加一个 elif。也就是说,每来一个新需求,你都得动老代码。而 summation 版本永远不用改——新需求只需要在调用处多写一个 lambda。

这条差别有名字:用数据做分支,只能穷举已知情况;用函数做参数,能容纳还没被想到的情况。

5. 函数作为输出:工厂函数与 make_discount

高阶函数的另一半是返回函数。这一半比「接收函数」更难消化,因为它要求你接受一件反直觉的事:一个函数可以在另一个函数的调用过程中被创造出来,而且它带着创造它时的那些信息一起离开。

还是从重复代码出发。一家店要打各种折扣:

def half_off(price):
    return price * 0.5

def ten_percent_off(price):
    return price * 0.9

def twenty_percent_off(price):
    return price * 0.8

def compute_new_price(discount, price):
    return discount(price)

compute_new_price 已经是个高阶函数了(它把 discount 当参数)。问题出在左边那三个:它们又是同一个模子刻出来的,只有那个乘数不同。而且每加一档折扣就得再写一个 def——三十档就是三十个函数。

直觉上你会想「不如把折扣率也当参数」:

def apply_discount(percent_off, price):
    return price * (1 - percent_off)

但这就不合用了:compute_new_price 要的是一个只吃一个参数 price 的函数。apply_discount 要两个参数,塞不进去。我们需要的是「一个已经知道折扣率是多少、只等着 price 的函数」。

解法是让一个函数生产这样的函数:

Functions as outputs (2 of 2):make_discount 与 compute_new_price 及交互结果
make_discount 内部用 def 造出 discount,再把 discount 这个名字(不带括号)返回出去。右边的 compute_new_price 完全不需要改动。下面两行交互展示了完整用法:先生产出 half_off,再拿它去算 20 元的新价,得到 10.0。
def make_discount(percent_off):
    def discount(price):
        return price * (1 - percent_off)
    return discount

def compute_new_price(discount, price):
    return discount(price)
>>> half_off = make_discount(0.5)
>>> compute_new_price(half_off, 20)
10.0
>>> make_discount(0.1)(20)
18.0

三处细节,一个都不能错:

1 def discount(price): 写在 make_discount 的函数体里面。这意味着它不是程序一启动就存在的——只有 make_discount 被调用一次,才会有一个 discount 函数对象被造出来。调用两次就造出两个不同的函数对象。
2 return discount 没有括号。返回的是函数对象本身。写成 return discount(price) 会立刻报 NameError: name 'price' is not defined,因为在 make_discount 这一层根本没有 price 这个名字。
3 discount 的函数体里用到了 percent_off——那是外层函数的参数,不是它自己的。等到 discount 被调用时,make_discount 早就返回结束了,可它还能查到 percent_off 的值。这是本节唯一真正的新东西。

把两次调用完整画出来

第三点必须靠环境图才能讲清。先看 half_off = make_discount(0.5) 这一行执行完的瞬间:

Global frame
make_discountfunc make_discount(percent_off)
compute_new_pricefunc compute_new_price(discount, price)
half_offfunc discount(price) [parent=f1]
f1: make_discount [parent=Global]
percent_off0.5
discountfunc discount(price) [parent=f1]
返回值func discount(price)

关键就在那个 [parent=f1]。discount 这个函数对象是在 f1 里被 def 出来的,所以它永久地记住了「我的父帧是 f1」。f1 虽然已经返回了,但只要还有人(这里是 discount 函数对象)指着它,它就不会消失。

接着执行 compute_new_price(half_off, 20):

f2: compute_new_price [parent=Global]
discountfunc discount(price) [parent=f1]
price20
f3: discount [parent=f1]
price20
返回值10.0
逐步推演

f3 里执行 return price * (1 - percent_off),要查两个名字:

1 查 price:f3 自己有,值是 20。停。
2 查 percent_off:f3 里没有。去 f3 的父帧找。f3 的父帧是 f1,不是调用它的 f2。
3 f1 里有 percent_off = 0.5。查到,停。
4 算 20 * (1 - 0.5) = 10.0。

若父帧规则是「调用它的那个帧」(这叫动态作用域),第 2 步就会去 f2 找,f2 里只有 discount 和 price,找不到 percent_off,再往 f2 的父帧 Global 找,还是没有,最后 NameError。Python 用的是「定义处」规则(词法作用域),所以能正确工作。

这种「函数 + 它记住的定义环境」的组合,正式名称叫闭包(closure)。CS 61A 不会一直用这个词,但你在别处会经常见到它。

为什么叫「工厂函数」

make_discount 是一台工厂(factory):喂进去一个折扣率,吐出来一个专用的折扣函数。它生产的每个产品都长得一样(函数体相同),但内部参数不同(父帧里的 percent_off 不同)。

>>> half_off = make_discount(0.5)
>>> ten_off = make_discount(0.1)
>>> half_off(100)
50.0
>>> ten_off(100)
90.0

half_off 和 ten_off 是两个不同的函数对象,父帧分别是两次调用产生的两个不同的帧。它们互不干扰。命名习惯上,凡是 make_xxx 这样的名字,通常就是工厂函数——Lab 02 和 HW 02 里会出现一堆。

常见误区

误区一:返回时多写了括号。

def make_discount(percent_off):
    def discount(price):
        return price * (1 - percent_off)
    return discount(price)      # ← 错
>>> make_discount(0.5)
Traceback (most recent call last):
  ...
NameError: name 'price' is not defined

报错发生在 make_discount 被调用的时候,而不是被定义的时候——def 语句只是把函数体记下来,body 里的名字要等到执行时才查。

误区二:忘了里层的 return。

def make_discount(percent_off):
    def discount(price):
        price * (1 - percent_off)   # ← 没有 return
    return discount
>>> make_discount(0.5)(20)
>>>

不报错,安静地返回 None,什么都不显示。这类 bug 只有在下一步拿返回值做运算时才炸:TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'。

误区三:忘了外层的 return。函数体里 def 了一个 discount 却不返回它,make_discount(0.5) 同样得到 None,然后 None(20) 会报 TypeError: 'NoneType' object is not callable。

6. WWPD:拆解 (lambda x, y: lambda z: x * y + z)(1, 2)(3)

下面这一行是本讲的「期末 boss」。第一眼看上去像键盘被猫踩了,但它其实只是把前两节的内容压缩到了一行里:

>>> (lambda x, y: lambda z: x * y + z)(1, 2)(3)
5

拆解它的唯一正确姿势是严格按照求值规则,从左到右一层一层剥。凭感觉猜没有任何胜算。

第一步:先分清哪些括号是「调用」

整行里有三对括号:

(lambda x, y: lambda z: x * y + z)  (1, 2)  (3)
└────────── 分组括号 ──────────┘  └调用┘  └调用┘

最外面那对是分组用的——必须加,否则 lambda x, y: ...(1, 2) 会被解析成「返回表达式是 ...(1,2)」,意思全变。后面两对才是调用。

所以这一行的结构是 某个函数(1, 2)(3),也就是连续调用两次。这个形状本身就在告诉你:某个函数(1, 2) 的结果必须还是一个函数,否则第二对括号无从下手。

第二步:看清楚 lambda 的边界在哪

这是最容易出错的一步。lambda x, y: lambda z: x * y + z 里面有两个 lambda,冒号后面的东西一直吃到哪儿为止?

规则:lambda 的返回表达式贪婪地向右延伸,直到遇到一个它吃不下的东西(比如逗号、右括号)为止。所以:

成分内容
外层 lambda 的参数x, y
外层 lambda 的返回表达式lambda z: x * y + z(整个内层 lambda)
内层 lambda 的参数z
内层 lambda 的返回表达式x * y + z

外层 lambda 返回的是一个函数。这就跟上一节的 make_discount 是同一件事了。

第三步:翻译成 def

Lambdas WWPD (5 of 5):把嵌套 lambda 翻译成等价的 def 版本
同一个东西的两种写法并排。看不懂 lambda 版就写出 def 版,两者逐字对应:外层 lambda 的参数 x, y 变成 outer 的形参,内层 lambda 整体变成嵌套的 def inner,return inner 对应「外层 lambda 的返回表达式是内层 lambda」这件事。
def outer(x, y):
    def inner(z):
        return x * y + z
    return inner

outer(1, 2)(3)

翻译完之后,这道题就退化成上一节讲过的工厂函数了:outer(1, 2) 生产出一个记得 x=1, y=2 的函数,再喂给它 3。

第四步:完整求值,画帧

逐步推演
1 求值 (lambda x, y: lambda z: x * y + z):这是一个 lambda 表达式,求值结果是一个函数对象。它是在全局帧里被求值的,所以它的 parent 是 Global。记作 λ_outer。注意此时内层 lambda 还没有被求值——它只是 λ_outer 函数体里的一段代码。
2 求值算子数 1 和 2。施加 λ_outer:新建帧 f1,parent = Global,绑定 x = 1、y = 2。
3 在 f1 里求值返回表达式 lambda z: x * y + z。这时才造出内层函数对象 λ_inner,它的 parent = f1(因为它是在 f1 里被求值出来的)。x * y + z 此刻一点都不算——函数体在被调用前永远不执行。
4 f1 返回 λ_inner。至此 (lambda x, y: ...)(1, 2) 整体求值成了 λ_inner。
5 处理第二对括号:把 λ_inner 施加到 3 上。新建帧 f2,parent = f1,绑定 z = 3。
6 求值 x * y + z:z 在 f2 找到 = 3;x 在 f2 没有 → 去 parent f1 找到 = 1;y 同理在 f1 找到 = 2。
7 1 * 2 + 3 = 5。
Global frame
(这一行没绑定任何名字)
f1: λ_outer [parent=Global]
x1
y2
返回值λ_inner [parent=f1]
f2: λ_inner [parent=f1]
z3
返回值5

把 f2 的 parent 记成 Global 是这道题的头号错因。这样的话第 6 步查 x 会在 Global 里找不到,得到 NameError: name 'x' is not defined。再说一遍:parent 看「在哪定义」,不看「在哪调用」。

直觉

连续括号 f(a)(b)(c) 从左往右读就行,每对括号消耗一层函数:

f(a)        → 得到一个函数 g
f(a)(b)     → g(b),得到一个函数 h
f(a)(b)(c)  → h(c),得到最终结果

反过来,看到 f(a)(b) 这种写法,你立刻就知道 f 一定是「返回函数的函数」。括号的对数 = 函数嵌套的层数。

常见误区

把参数一次性全塞进去:

>>> (lambda x, y: lambda z: x * y + z)(1, 2, 3)
Traceback (most recent call last):
  ...
TypeError: <lambda>() takes 2 positional arguments but 3 were given

外层只认 2 个参数。z 属于内层,必须单独用第二对括号送进去。这个报错里的 <lambda>() 就是「匿名」的代价——如果是 def 版,报错会写 outer(),一眼就知道是哪个函数。

忘记外层的分组括号:

>>> lambda x, y: lambda z: x * y + z(1, 2)(3)
<function <lambda> at 0x...>

不报错,但结果完全不对:z(1, 2)(3) 被当成了内层返回表达式的一部分,整行只是求值出一个函数对象,一次调用都没发生。不报错的错误比报错的错误危险。

7. 环境:帧、父帧与名字查找规则

前面几节反复用到「父帧」这个词,现在把它连同整套机制一次讲定。这一节的内容会在下一讲被展开成完整的环境图画法,但规则本身在这里就是完整的——考试和作业里的绝大多数环境题,靠下面这几条就能算对。

三个定义

1 帧(frame):一组「名字 → 值」的绑定。你可以把它想成一张两列的表。
2 环境(environment):一个帧的序列。不是一堆帧,是有顺序的一串——从某个帧出发,沿着父帧一路串到全局帧。查名字时就沿着这条链走。
3 全局帧(global frame):所有 Python 程序开始的地方,也是每条链的终点。它没有父帧。

程序员也常把环境叫做帧的「栈(stack)」——第一讲里出现的 Traceback (most recent call last) 之所以叫 stack trace,「爆栈」之所以叫 stack overflow,都源于这个说法。(栈溢出下周讲递归时会亲眼见到。)

四条规则

Name Lookup Rules 幻灯片:父帧的定义、局部帧、查找顺序与 UnboundLocalError 警告
整门课最该背下来的一张。四条规则从上到下:父帧由定义处决定;每次调用开一个新的局部帧;查名字先看当前帧再递归看父帧,到全局帧还找不到就 NameError;最后一条带警告标志的是最容易踩的坑——不能在局部帧里更新外层的变量。
核心结论
  1. 一个函数的父帧,是它被定义时所在的那个帧(不是它被调用时所在的帧)。
  2. 每次调用函数,都新开一个局部帧(local frame);这个帧的父帧就是规则 1 说的那个。
  3. 查名字时,先看当前帧;找不到就去父帧找,再找不到就去父帧的父帧……一路到全局帧。全局帧也没有,抛 NameError。
  4. 你不能在局部帧里更新(重新绑定)局部帧之外的名字,硬试会得到 UnboundLocalError。

规则 1 的分量:定义处 vs 调用处

这条规则值得一个专门的反例。下面这段代码里,g 里的 x 指的是哪个 x?

def f():
    x = 'f-local'
    return g()

def g():
    return x

x = 'global'
>>> f()
'global'

g 是在全局帧里 def 的,所以不管是谁调用它,g 的局部帧的父帧永远是 Global。f 的局部帧里那个 x = 'f-local',g 看都看不见。

Global frame
ffunc f()
gfunc g() [parent=Global]
x'global'
f1: f [parent=Global]
x'f-local'
正在算 g()
f2: g [parent=Global]
(没有局部绑定)
返回值'global'

f2 的父帧是 Global,尽管在调用栈上 f2 是从 f1 里被调起来的。调用关系和父帧关系是两条互不相干的链。这套规则叫词法作用域 / 静态作用域(lexical scoping)——「词法」的意思是:只看源代码的书写位置就能确定,不用运行程序。

对照第 5 节的 discount:它是在 make_discount 的帧里 def 的,所以父帧是那个帧,能看到 percent_off。同一条规则,两种表现。

规则 3 的分量:查找会一路向上,但只向上

>>> def outer():
...     n = 5
...     def inner():
...         return n + 1      # inner 里没有 n,去父帧(outer 的帧)找
...     return inner()
...
>>> outer()
6

读外层的名字完全没问题。反过来则不行:外层帧看不到内层帧的任何东西,全局帧更看不到任何函数的局部名字。链是单向的。

>>> def h():
...     return y
...
>>> h()
Traceback (most recent call last):
  File "<stdin>", line 2, in h
NameError: name 'y' is not defined

注意 NameError 是在 调用 h() 时才抛的,不是定义 h 时。def 只是把函数体存起来;函数体里的名字要等到真正执行那一行时才去查。这解释了为什么你可以写一个引用了尚未定义的名字的函数,只要在调用它之前把名字补上就行。

规则 4:UnboundLocalError 是怎么来的

这是本讲最容易让人一头雾水的报错。看这段:

>>> count = 0
>>> def bump():
...     count = count + 1
...
>>> bump()
Traceback (most recent call last):
  File "<stdin>", line 2, in bump
UnboundLocalError: local variable 'count' referenced before assignment

(Python 3.11 及以后这句话改成了 cannot access local variable 'count' where it is not associated with a value,意思一样。)

迷惑之处在于:全局帧里明明有 count = 0,为什么说「referenced before assignment」?

逐步推演
1 Python 在编译函数体时(还没运行呢)先扫一遍:函数体里出现了对 count 的赋值(count = ...)。
2 于是它判定:count 是这个函数的局部变量。这个判定是静态的、一次性的,对整个函数体生效——包括赋值语句之前的代码。
3 运行 bump():新建局部帧。执行 count = count + 1,先要算右边的 count + 1。
4 查 count:因为第 2 步已经把它钉成局部变量了,解释器只在局部帧里找,根本不会去父帧。而局部帧里此刻还没有 count(赋值还没完成)。
5 抛 UnboundLocalError。

换句话说:一个名字在一个函数里,要么全程是局部的,要么全程是外部的,不能前半段外部、后半段局部。只要函数体里有对它的赋值,它就是局部的。

对比一下就清楚了——把赋值去掉,同一个函数立刻正常:

函数体count 是结果
return count外部名字(局部帧没赋值)正常读到全局的 0
count = count + 1局部名字(有赋值)UnboundLocalError
count = 10;return count局部名字返回 10,全局的 count 不变

嵌套函数里同样会撞上:

>>> def outer():
...     n = 0
...     def inner():
...         n = n + 1        # ← n 被判定为 inner 的局部变量
...         return n
...     return inner()
...
>>> outer()
Traceback (most recent call last):
  ...
UnboundLocalError: local variable 'n' referenced before assignment
注意

Python 确实有 global 和 nonlocal 两个关键字可以破除这条限制,但 CS 61A 在这个阶段不用它们,作业里也基本禁止。原因不是它们「不好」,而是这门课想让你先掌握「不靠副作用也能把状态传下去」的思路——需要跨调用保存状态时,正确答案通常是把状态放进外层函数的参数里,就像 make_discount 里的 percent_off 那样。

三种「找不到」的报错对照

报错典型触发含义
NameError: name 'y' is not defined用了一个从没绑定过的名字沿着环境链一路找到全局帧都没有
UnboundLocalError: local variable 'x' referenced before assignment函数里对外层名字做 x = x + 1名字被判定为局部,但局部帧里还没值
TypeError: 'int' object is not callable把调用结果当函数传/用在一个非函数后面写了括号

8. 随堂练习一:combination

随堂代码 03.py 里的第一题。它是 summation 的加强版:summation 把「每一项算什么」抽成了参数,combination 连「怎么把项合并起来」和「从什么值开始」也一并抽出来。

题目到底要什么

def combination(n, initial, combiner, term):
    """
    Returns the result of combining the numbers from 1 to `n` (inclusive)
    repeatedly with the `combiner` and `term` functions. `combiner` is a
    function that takes two arguments and returns a single value, while
    `term` is a function that takes one argument and returns a single value.
    `initial` is the initial value to start the combination.

    >>> from operator import add, mul
    >>> combination(1, 0, add, lambda x: x + 1)  # (0) + (1+1)
    2
    >>> combination(5, 0, add, lambda x: x + 1)  # (0) + (1+1) + (2+1) + (3+1) + (4+1) + (5+1)
    20
    >>> combination(5, 1, mul, lambda x: x + 1)  # (1) * (1+1) * (2+1) * (3+1) * (4+1) * (5+1)
    720
    >>> combination(5, 0, add, lambda x: x * x)  # (0) + 1*1 + 2*2 + 3*3 + 4*4 + 5*5
    55
    >>> combination(5, 1, mul, lambda x: x * x)  # (1) * 1*1 * 2*2 * 3*3 * 4*4 * 5*5
    14400
    """

四个参数各是什么,doctest 里的注释已经说得很直白了:

参数类型作用
n整数从 1 数到 n(含 n)
initial数累积的起点。求和时是 0,求积时是 1
combiner函数,吃两个参数怎么把「已累积的结果」和「新的一项」合起来
term函数,吃一个参数由 i 算出第 i 项

from operator import add, mul 这一行值得单独说:operator 模块把运算符包装成了普通函数。add(3, 4) 就是 3 + 4,mul(3, 4) 就是 3 * 4。为什么需要它?因为 + 是语法,不是值,你没法写 combination(5, 0, +, ...)。要把「加法」当参数传,就必须有一个函数形态的加法。

边界情况:combination(0, 7, add, ...) 应该返回 7——一项都不合并,直接把 initial 原样交出。这条不在 doctest 里,但它是判断代码写得干不干净的试金石。

怎么想到的

关键是把 doctest 第三行的注释重新断句。它写的是:

(1) * (1+1) * (2+1) * (3+1) * (4+1) * (5+1)

看起来像「六个东西连乘」,但那样想会卡住——因为 combiner 一次只吃两个参数。正确的读法是从左往右逐个吞:

total ← 1                      # initial
total ← mul(total, term(1))    # mul(1, 2)   = 2
total ← mul(total, term(2))    # mul(2, 3)   = 6
total ← mul(total, term(3))    # mul(6, 4)   = 24
total ← mul(total, term(4))    # mul(24, 5)  = 120
total ← mul(total, term(5))    # mul(120, 6) = 720

这一读就露馅了:这不就是 summation 换了个累加方式吗。summation 里的 total += term(i) 相当于 total = add(total, term(i));把 add 换成参数 combiner,把起点 0 换成参数 initial,就完了。

直觉

拿到一道 HOF 题不会做时,屡试不爽的一招:先把它退化成一个具体的、不含函数参数的版本写出来。这里就是先写「1 到 n 求和」,写完再问「哪几个地方是写死的?」,把写死的地方逐个换成参数。

抽象是从具体里提炼出来的,不是凭空设计出来的。

代码逐行讲

def combination(n, initial, combiner, term):
    i = 1
    total = initial
    while i <= n:
        total = combiner(total, term(i))
        i += 1
    return total
1 i = 1:题目说「from 1 to n (inclusive)」,起点就是 1。
2 total = initial:不能写 total = 0。求积那两个 doctest 的 initial 是 1,从 0 开始的话乘出来永远是 0。这就是 initial 这个参数存在的理由——不同的 combiner 有不同的「单位元」。
3 while i <= n:inclusive,所以是 <= 不是 <。写成 < 会少算最后一项,combination(1, 0, add, lambda x: x + 1) 会返回 0 而不是 2。
4 total = combiner(total, term(i)):参数顺序是 (已累积的, 新的一项)。add 和 mul 都满足交换律,所以这两个 doctest 反过来写也能过;但对减法、除法这类不满足交换律的 combiner 就会出错。养成按 docstring 语义写的习惯。
另外这里不能用 +=——total += ... 写死了「合并方式是加法」,恰恰是我们要抽走的那件事。
5 i += 1:漏了就是死循环。
6 return total:缩进在 while 外面。缩进到里面的话第一轮就返回了,combination(5, 0, add, lambda x: x + 1) 会得到 2。

验证

手动追踪 combination(5, 1, mul, lambda x: x * x),目标是 doctest 里的 14400。term 是平方,combiner 是乘法,initial 是 1:

i进入时 totalterm(i)mul(total, term(i))更新后 total
1111 × 11
2141 × 44
3494 × 936
4361636 × 16576
557625576 × 2514400
66 <= 5 为假,退出,返回 14400 ✓

(心算校验:(1·2·3·4·5)² = 120² = 14400。)跑一遍确认:

$ python3 -m doctest 03.py -v
...
9 tests in 3 items.
9 passed and 0 failed.
Test passed.
常见误区

写成 total = combiner(total, term)——把函数本身当成了项。

>>> combination(5, 0, add, lambda x: x + 1)
Traceback (most recent call last):
  ...
TypeError: unsupported operand type(s) for +: 'int' and 'function'

报错里的 'int' and 'function' 直接点名了:你拿一个整数去加一个函数。term 是规则,term(i) 才是数。

写成 total = combiner(term(i))——少给一个参数。

TypeError: add expected 2 arguments, got 1

docstring 白纸黑字写了「combiner is a function that takes two arguments」。读 docstring 里对函数参数的类型描述,是做 HOF 题最省时间的一步。

9. 随堂练习二:multiply_triple 与「一次只吃一个参数」

题目到底要什么

Using only functions with a single argument, implement multiply_triple so that you can use it to return the product of 3 numbers x, y, and z.

逐字翻译:只许用「单参数函数」,实现 multiply_triple,使得它能算出三个数 x、y、z 的乘积。要达到的效果是:

>>> multiply_triple(1)(2)(3)
6
>>> multiply_triple(2)(3)(4)
24
>>> multiply_triple(10)(5)(7)
350

注意 starter code 里函数签名是 def multiply_triple(___):——连形参名都要你自己填。这是提示:先想清楚第一层该接什么。

「单参数」这个限制看起来像刁难,其实它逼你回答一个真问题:一个函数一次只能收一个值,怎么攒够三个?

怎么想到的

从 doctest 的形状倒推。multiply_triple(1)(2)(3) 有三对括号,按第 6 节的口诀——括号的对数 = 函数嵌套的层数——所以要三层:

1 multiply_triple(1):吃掉 x = 1。它还不能算乘积(只知道一个数),所以只能返回一个函数,这个函数得记住 1。
2 (2):第二层吃掉 y = 2。仍然算不出来(缺 z),继续返回一个函数,这个函数得同时记住 1 和 2。
3 (3):第三层吃掉 z = 3。三个数齐了,返回 x * y * z = 6。

「记住前面的数」这件事,第 5 节已经解决过了:内层函数能通过父帧看到外层函数的参数。所以不需要任何额外机制,嵌套 def 就够了。

直觉

把它想成攒齐材料才能开工:每一层函数收一份材料,收完发现还不够,就把「已收到的材料 + 还要收几份」打包成一个新函数交回去。等最后一份材料到手,立刻开工。

这个「把多参数函数拆成一串单参数函数」的技巧有正式名字:柯里化(currying),以逻辑学家 Haskell Curry 命名。教材 §1.6 里会正式讲,本讲只要会手写就行。

代码逐行讲

def multiply_triple(x):
    def outer(y):
        def inner(z):
            return x * y * z
        return inner
    return outer
1 def multiply_triple(x):starter code 里的 ___ 填 x——第一个括号送进来的就是 x。
2 def outer(y):在 multiply_triple 的帧里定义,所以它的父帧就是那个帧,能看到 x。
3 def inner(z):在 outer 的帧里定义,父帧是 outer 的帧;沿链再往上是 multiply_triple 的帧。所以 x 和 y 它都能查到。
4 return x * y * z:三个名字分别在三个不同的帧里被找到——z 在自己帧,y 在父帧,x 在父帧的父帧。这一行是本讲名字查找规则最紧凑的演示。
5 两个 return(return inner 和 return outer)都不带括号,返回函数对象本身。

写成 lambda 更短,语义完全一样:

def multiply_triple(x):
    return lambda y: lambda z: x * y * z

甚至可以整个都是 lambda(这就是第 6 节那道 WWPD 的三层版):

multiply_triple = lambda x: lambda y: lambda z: x * y * z

验证:把 multiply_triple(2)(3)(4) 的帧全画出来

逐步推演
1 求值 multiply_triple(2):新建 f1,parent = Global,绑定 x = 2。
2 f1 里执行 def outer(y)::造出一个函数对象,parent = f1,绑到 f1 的 outer 上。(def inner 这一行还没执行——它在 outer 的函数体里。)
3 f1 执行 return outer,返回那个函数对象。至此 multiply_triple(2) 求值完毕。
4 第二对括号:把它施加到 3 上。新建 f2,parent = f1,绑定 y = 3。
5 f2 里执行 def inner(z)::这时才造出 inner 函数对象,parent = f2。f2 返回它。
6 第三对括号:施加到 4 上。新建 f3,parent = f2,绑定 z = 4。
7 求值 x * y * z:x → f3 没有 → f2 没有 → f1 有,2。y → f3 没有 → f2 有,3。z → f3 有,4。
8 2 * 3 * 4 = 24 ✓
Global frame
multiply_triplefunc multiply_triple(x)
f1: multiply_triple [parent=Global]
x2
outerfunc outer(y) [parent=f1]
返回值func outer(y)
f2: outer [parent=f1]
y3
innerfunc inner(z) [parent=f2]
返回值func inner(z)
f3: inner [parent=f2]
z4
返回值24

四个帧串成一条链:f3 → f2 → f1 → Global。这条链就是 f3 的环境。x * y * z 里三个名字分别在链上的三个不同位置被找到,而且是从近到远依次搜索的。

常见误区

误区一:写成三参数函数。

def multiply_triple(x, y, z):
    return x * y * z
>>> multiply_triple(1)(2)(3)
TypeError: multiply_triple() missing 2 required positional arguments: 'y' and 'z'

题目明说「Using only functions with a single argument」,而且 doctest 的调用形状 f(1)(2)(3) 也不允许这么写。

误区二:内层函数重复用同一个参数名。

def multiply_triple(x):
    def outer(x):        # ← 把外层的 x 遮住了
        def inner(x):    # ← 又遮一次
            return x * x * x
        return inner
    return outer
>>> multiply_triple(2)(3)(4)
64

不报错,结果是 4 ** 3 = 64。因为 inner 帧里有自己的 x = 4,查名字第一步就命中了,根本不会往父帧走。这叫遮蔽(shadowing):内层的同名绑定会挡住外层的。

误区三:中间层忘了 return,或者提前算乘积。

def multiply_triple(x):
    def outer(y):
        return x * y       # ← 只乘了两个就返回
    return outer
>>> multiply_triple(2)(3)(4)
TypeError: 'int' object is not callable

multiply_triple(2)(3) 得到整数 6,然后 6(4) 就炸了。只要还没收齐所有参数,就必须继续返回函数。

本讲小结

三条判断题,答对就算过关

问题答案
square 和 square(3) 的区别前者求值成函数对象,后者是调用表达式。括号是「调用」这个动作,不是名字的一部分。
一个函数的父帧怎么定看它在哪里被 def / 被 lambda 求值出来,不看在哪里被调用。
看到 f(a)(b) 说明什么f(a) 的结果必须是一个函数。括号对数 = 函数嵌套层数。

语法速查

写法它是求值成
def f(x): ...语句(无值)造出函数并绑到名字 f
lambda x: x * x表达式一个函数对象,__name__ 是 '<lambda>'
f名字函数对象
f(3)调用表达式f 的返回值
f(3)(4)两次调用先调 f(3) 得到函数,再调它
return g返回函数把 g 这个函数对象交出去
return g(x)返回调用结果先调用 g,把结果交出去

报错速查

报错八成是因为
TypeError: 'int' object is not callable该传函数的地方传了 f(x) 而不是 f;或者中间层提前算出了数值
TypeError: 'NoneType' object is not callable工厂函数忘了 return 内层函数
TypeError: <lambda>() takes 2 positional arguments but 3 were given柯里化函数的参数一次性全塞进了一对括号
TypeError: unsupported operand type(s) for +: 'int' and 'function'拿 term 当数用了,忘了写 term(i)
NameError: name 'price' is not definedreturn discount(price) 多写了括号,而这一层没有 price
UnboundLocalError: local variable 'x' referenced before assignment在函数里写了 x = x + 1,而 x 是外层的名字
不报错但结果是 None某一层漏了 return

写 HOF 的四步法

1 数括号。看 doctest 里怎么调用的,几对括号就写几层函数。
2 标类型。每个参数是「数」还是「函数」?是函数的话,它吃几个参数、返回什么?docstring 通常已经写明。
3 先写死再抽象。不会写就先写一个只处理具体情况的版本,跑通了再把写死的部分换成参数。
4 查 return。逐层确认:还没收齐参数的层返回函数(不带括号),收齐了的那层返回结果。

与前后讲的关系

往回看:本讲的 summation、combination 里的循环骨架,是上一讲 while 的直接产物;没有上一讲的「初始化 / 条件 / 更新」三要素,这一讲的抽象无从谈起。

往前看:下一讲会把本讲末尾的环境规则展开成一整套环境图画法,包括怎么画函数对象、怎么标 parent、返回值写在哪。本讲第 5、6、9 节手画的那些帧,就是下一讲的预演。再往后的递归,本质是「函数在自己的帧里调用自己」,靠的还是同一套帧和环境机制。

作业上:Lab 02 整场都在练本讲的内容(lambda、HOF、柯里化、环境图);HW 01 的后几题会要求你用高阶函数化简重复代码。

动手练习

练习 1:WWPD

下面每一行的输出是什么?(>>> 后面是输入,请自己先写出结果再看答案。)

>>> def f(x):
...     return x + 1
...
>>> g = f
>>> h = f(1)
>>> g(1)
______
>>> h
______
>>> h(1)
______
>>> (lambda x: x)(f)(1)
______
看答案

g(1) → 2。g = f 没有括号,所以 g 和 f 是同一个函数对象的两个名字。

h → 2。h = f(1) 有括号,先调用,把返回值 2 绑给 h。h 是整数,不是函数。

h(1) → TypeError: 'int' object is not callable。h 是 2,2(1) 没有意义。

(lambda x: x)(f)(1) → 2。分两步:(lambda x: x)(f) 是把恒等函数施加到 f 上,返回 f 本身;然后 f(1) = 2。函数可以作为实参被传给 lambda,也可以作为 lambda 的返回值——这一行两件事都干了。

练习 2:环境推演

result 是多少?先画出所有帧和它们的 parent,再回答。

n = 10

def f(n):
    def g(k):
        return n + k
    return g

n = 100
result = f(1)(2)
看答案

result 是 3。

1 全局帧里 n 先被绑成 10,再被重新绑成 100。执行到最后一行时,全局的 n 是 100。
2 f(1):新建 f1,parent = Global,绑定 n = 1。这个 n 是 f1 里的局部 n,它遮蔽了全局的 n。
3 f1 里 def g(k) 造出函数对象,parent = f1。f1 返回它。
4 (2):新建 f2,parent = f1,绑定 k = 2。
5 求值 n + k:k 在 f2 找到 = 2;n 在 f2 没有 → 去 parent f1 找到 = 1(不是 100,也不是 10,因为查找一命中就停,不会继续往 Global 走)。
6 1 + 2 = 3。

这道题的陷阱有两层:一是「全局的 n 被改成 100 了」这个干扰项,二是「n = 10 那行看起来像是初始值」这个干扰项。只要 f1 里有 n,链上更远处的 n 就永远轮不到。

练习 3:写代码 —— compose

实现 compose(f, g),返回一个新函数,这个新函数对输入 x 先用 g 再用 f:

>>> add_one = lambda x: x + 1
>>> square = lambda x: x * x
>>> compose(square, add_one)(3)   # square(add_one(3)) = square(4)
16
>>> compose(add_one, square)(3)   # add_one(square(3)) = add_one(9)
10
看答案
def compose(f, g):
    def h(x):
        return f(g(x))
    return h

或者:return lambda x: f(g(x))。

compose 是「既吃函数又吐函数」的典型:两个参数都是函数,返回值也是函数。三个 return 位置都不能搞混——return f(g(x)) 在最内层(这时 x 已经有了,该算出结果了),return h 在外层(这时只有 f 和 g,还不能算)。

验证 compose(square, add_one)(3):compose 的帧里 f = square、g = add_one,返回 h(parent 指向这个帧)。调用 h(3):新帧绑定 x = 3,求值 f(g(x))——先算算子数 g(x) = add_one(3) = 4,再算 f(4) = square(4) = 16。

顺序容易记反:f(g(x)) 里 g 在里面,所以 g 先执行。写完对着 doctest 的注释核一遍最保险。

练习 4:写代码 —— 柯里化的逆操作

multiply_triple(2)(3)(4) 能算出 24。现在反过来:写一个 uncurry3(f),把这种「三层单参数」的函数变回普通的三参数函数。

>>> mul3 = uncurry3(multiply_triple)
>>> mul3(2, 3, 4)
24
看答案
def uncurry3(f):
    def g(x, y, z):
        return f(x)(y)(z)
    return g

关键是 f(x)(y)(z) 这一串:f(x) 得到第二层函数,再 (y) 得到第三层,再 (z) 才是数。三对括号必须一个不少。

常见的错法是写成 return f(x, y, z):multiply_triple 只收一个参数,会报 TypeError: multiply_triple() takes 1 positional argument but 3 were given。

uncurry3 本身也是高阶函数(吃函数、吐函数)。注意它返回的 g 不是单参数函数——这不违反什么,因为限制「只用单参数函数」是上一题的题目要求,不是 Python 的规则。

练习 5:找 bug

下面这个 make_counter 想造一个「每次调用就返回累计次数」的计数器,但它跑不起来。指出报错、说明原因,并给出一个不用 nonlocal 的修法。

def make_counter():
    count = 0
    def counter():
        count = count + 1
        return count
    return counter
看答案
>>> c = make_counter()
>>> c()
Traceback (most recent call last):
  File "<stdin>", line 4, in counter
UnboundLocalError: local variable 'count' referenced before assignment

原因就是第 7 节的规则 4:counter 的函数体里有 count = ...,Python 于是把 count 判定为 counter 的局部变量;执行 count + 1 时它只在局部帧里找,而局部帧里还没有 count。能读外层的名字,不能改。

不用 nonlocal 的修法是把「当前计数」变成参数,让调用方自己拿着状态:

def make_counter():
    def counter(count):
        return count + 1
    return counter
>>> c = make_counter()
>>> c(0)
1
>>> c(c(0))
2

这确实不如原来的想法方便——但它没有可变状态,每次调用的结果只取决于参数。CS 61A 前半学期一直待在这个「纯函数」的世界里,直到后面正式讲可变数据时,才会给你真正能改状态的工具。到那时你会更清楚,「能改状态」带来的方便和麻烦各是什么。