Lecture 5
Environments for High-Order Functions
Overview
本视频的核心论题在于阐明“高阶函数 (Higher-Order Functions)”是如何在现有的程序执行“环境 (Environments)”模型中工作的。视频得出的结论是:我们用以为普通函数绘制的环境图解 (Environment Diagrams) 规则,已经完全足够用来理解和处理高阶函数 [00:16] - [00:25]。换言之,我们学习环境模型的根本原因之一,就是为了能够精确解释高阶函数(即那些接受函数作为参数或返回一个函数的函数)的运行机制 [00:37]。视频通过一个具体的 apply_twice(应用两次)函数示例,详细分解了当一个函数(如 square,平方)作为参数被传递时,环境模型是如何一步步创建帧 (frame)、绑定参数 (binding) 并执行代码的。
按照主题来梳理
主题:apply_twice 高阶函数的环境图解实践
视频的核心内容围绕一个具体的编程示例展开,旨在通过环境图解(Environment Diagram)的可视化过程,展示高_阶函数_的执行细节。这个示例的主角是一个名为 apply_twice 的高阶函数。
1. 函数的定义
在深入探讨执行过程之前,视频首先介绍了两个关键的函数定义:
-
apply_twice函数: 这是一个高阶函数(Higher-Order Function)[00:11]。它的定义是接收两个参数:一个名为F的函数和一个名为X的参数 [00:58]。其核心功能是返回F(F(X))的执行结果,即它将参数F所代表的函数连续两次应用到参数X上 [01:00] - [01:07]。它之所以被归类为高阶函数,正是因为它接受了另一个函数(F)作为其参数 [01:11]。 -
square函数: 这是一个我们熟悉的辅助函数,它的功能是计算一个数的平方 [01:18]。在接下来的示例中,它将作为参数被传递给apply_twice。
在Python环境中执行这两个 def(定义)语句后 [03:32],我们只是在全局帧(Global Frame)中创建了这两个函数的名称(apply_twice 和 square),并将它们分别绑定(bound)到了相应的函数对象上 [03:39]。此时,没有任何函数被实际调用(call),也没有任何东西被计算或平方 [02:08], [03:39]。
2. 示例调用与预期
视频接着展示了一个调用示例,例如执行 apply_twice(square, 3) [01:30]。这里的语法非常关键:apply_twice 函数被调用,它传入了两个参数值(arguments):第一个是 square 函数本身的值,第二个是数字 3 [01:48] - [01:56]。
这个调用的预期结果是 81。其逻辑是:
-
apply_twice内部首先计算F(X),即square(3),得到 9。 -
然后它计算
F( F(X) ),即square(9),最终得到 81 [01:40]。
3. 环境图解的详细步骤(以 apply_twice(square, 2) 为例)
为了更清晰地展示环境模型的作用,视频转而使用 apply_twice(square, 2) 作为核心示例,一步步拆解其执行过程 [02:15]。
-
步骤一:调用 apply_twice 函数
当 apply_twice(square, 2) 这行代码被执行时 [03:48],Python解释器需要应用(apply)这个用户定义的函数。根据视频中提到的规则,应用一个用户定义的函数涉及三个步骤 [03:59]:
-
创建一个新的帧(Frame)。
-
将函数的形参(formal parameters)绑定到传入的实参(arguments)上。
-
执行该函数的函数体(body)。
-
-
步骤二:创建新帧 (F1) 并绑定参数
执行上述步骤的结果是,环境图解中出现了一个新的帧,视频中称之为 F1 [04:28]。这个 F1 帧是为执行 apply_twice 的函数体而创建的 [04:29]。
-
步骤三:执行函数体 return F(F(X))
现在开始执行 apply_twice 的函数体,即 return F(F(X)) [04:08]。这是一个嵌套调用。
-
步骤四:评估内部调用 F(X)
为了计算 F(F(X)),评估器(evaluator)必须首先评估内部的 F(X) [02:36]。
-
查找
F: 解释器需要知道F是什么。它会查找“当前环境”(即 F1 + 全局帧)。根据名称查找规则(look up a name),它首先在第一个帧(F1)中查找 [05:14]。 -
它在 F1 帧中立即找到了
F[05:20]。它发现F这个名称被绑定到了square函数对象 [05:27]。 -
查找
X: 同理,它在 F1 帧中找到了X,其值为2[02:55]。 -
执行
square(2): 因此,F(X)的评估变成了调用square(2)[02:55]。这个调用同样会创建一个新的帧(我们称之为 F2,尽管视频中没有明确命名,只是展示了过程),执行square的函数体(返回 2*2),并最终返回一个值:4[03:00]。
-
-
步骤五:评估外部调用 F( … )
内部的 F(X) 表达式现在评估为值 4 [03:09]。因此,原始的表达式 F(F(X)) 现在变成了 F(4)。
-
步骤六:返回最终结果
F(4) 的调用返回了 16。这个值 16 就是 apply_twice(square, 2) 的最终返回值 [03:17]。视频中提到,这个结果(16)最终被绑定到了一个名为 result 的变量上(如果调用是 result = apply_twice(square, 2)) [03:17] - [03:27]。这个结果 16 正是 2 被平方两次后(2 -> 4 -> 16)得到的值 [03:27]。
框架 & 心智模型 (Framework & Mindset)
框架:函数调用的环境模型 (Environment Model for Function Calls)
视频通过 apply_twice 示例,强化了一个关于“应用用户定义函数 (applying a user-defined function)”的通用执行框架 [03:59]。这个框架是理解任何(包括高阶)函数如何执行的基础。无论传递的是数字还是函数,规则都是一样的 [00:25]。该框架包含三个明确的步骤 [03:59]:
-
创建新帧 (Creating a new frame):
-
绑定形参 (Binding the formal parameters):
-
执行函数体 (Executing the body):
这个“创建帧 - 绑定参数 - 执行函数体(及查找规则)”的三步框架,就是环境模型的核心。
心智模型:函数作为一等公民 (Functions as First-Class Citizens)
要真正理解高阶函数,必须建立一个关键的“心智模型 (Mindset)”:函数是“一等公民 (First-Class Citizens)”。虽然视频没有使用这个术语,但它所展示的机制完全体现了这一概念。
-
函数是一种值 (Functions are values):
-
函数可以被名称绑定 (Functions can be bound to names):
-
对环境模型的影响 (Implication for Environment Model):
Environments for Nested Definitions
Overview
本视频的核心论题是:在 Python 中,一个嵌套定义的函数(内部函数)是如何访问其“出生”环境(外部函数)中的变量的,尤其是在外部函数已经执行完毕并返回之后?
视频通过一个具体的 make_adder 函数示例,并借助“环境图”(Environment Diagrams)这一工具,得出了一个清晰的结论:函数不仅仅是一段代码,它还是一个包含“指向其定义时所在环境(框架)的指针”的数据对象。这个被存储的“父框架”(Parent Frame)指针,使得内部函数无论在何时、何地被调用,都能回溯到其“出生地”去查找变量。这个机制正是“闭包”(Closures)的底层实现原理,它允许函数“封装”并“记住”其定义时的状态(数据)。
按照主题来梳理
为了完整理解这个机制,视频将我们带入了一个逐步执行的调试过程。
主题一:make_adder 示例所带来的“魔法”问题
视频首先展示了一段简短但精妙的代码 [00:00]:
1 | def make_adder(n): |
-
代码逻辑:
make_adder是一个函数,它接收一个参数n。在它内部,它 定义了 另一个函数,名为adder。adder函数接收一个参数k,并返回k + n[00:09, 00:19]。最关键的是,make_adder函数 返回 的不是一个计算结果,而是adder这个 函数本身 [00:29]。 -
执行演示:
-
核心问题:
make_adder(3)这个调用在第一步就已经执行完毕并返回了。按理说,它的本地变量(如n=3)应该已经随着函数调用的结束而消失了。那么,为什么我们在后面调用add_three(4)时,这个函数(它实际上就是adder函数)仍然“记得”n是3呢?[00:56]。这个add_three函数是如何将数据(3)包含在其内部的?[01:02]
主题二:环境图(上)—— make_adder(3) 的执行与返回
要回答这个问题,必须使用“环境图”(Environment Diagram)来追踪每一步。
-
定义
make_adder:- 当 Python 读到
def make_adder...时,它首先在“全局框架”(Global Frame)中创建了一个函数对象(function value),并把make_adder这个名字指向它 [01:10]。此时,函数体内的代码 并未 执行。
- 当 Python 读到
-
调用
make_adder(3): -
执行
make_adder的函数体: -
执行
return adder: -
执行赋值
add_three = ...:
此时,make_adder 的调用结束了。F1 框架从“调用栈”上消失了,但它并没有被“垃圾回收”销毁,因为它仍然被 add_three 函数对象(通过其父指针)引用着 [03:32]。
主题三:环境图(下)—— add_three(4) 的执行与解密
现在,我们来执行最关键的一步:add_three(4) [03:22]。
-
调用
add_three(4):-
add_three指向的其实是adder函数对象。 -
创建新框架:创建
一个新的本地框架,我们称之为 F2(它的标题是 adder)[03:22]。
-
绑定参数:
adder函数的形式参数k在F2框架中被绑定到实际参数4。
-
-
设置
F2的父框架: -
执行
adder的函数体k + n:-
现在,我们在
F2框架的环境中执行return k + n[03:58]。 -
建立环境链:此时的“当前环境”是一个链条:它从
F2开始,F2的父是F1,F1的父是“全局框架”[05:44]。 -
查找
k:Python 首先在F2中查找k。找到了!k绑定到4[06:09]。 -
查找
n:Python 首先在F2中查找n。没有找到 [06:14]。 -
沿着链条向上查找:Python 自动(通过父指针)跳转到
F2的父框架F1中继续查找n。 -
解密时刻:在
F1框架中,n被找到了!它被绑定到3[06:21]。 -
计算:表达式变为
4 + 3,结果是7。 -
返回:函数返回
7[04:12]。
-
这就是 add_three(4) 能够正确计算出 7 的完整过程。n 变量不是被“记住”的,而是通过一条清晰的“父框架”链条,在它最初被定义的 F1 框架中被 查找 到的。
框架 & 心智模型 (Framework & Mindset)
这个例子为我们揭示了 Python 中关于函数、作用域和环境的两个核心心智模型和一个关键框架。
框架一:绘制环境图的“三规则”
视频在 [07:39] 总结了绘制环境图的明确规则,这是理解这一切的底层框架:
规则 1:当一个函数被 定义 时 (def ...) [07:55]
-
创建一个新的“函数对象”(Function Value)。
-
设置父指针:将这个函数对象的“父”(
parent)指针,设置为 当前 所在的执行框架(current frame)。 -
绑定名称:在 当前 框架内,将函数名(例如
adder)绑定到这个新创建的函数对象上。
规则 2:当一个函数被 调用 时 (...(...)) [08:33]
-
创建一个新的“本地框架”(Local Frame)。
-
复制父指针:查看 被调用 的那个函数对象,读取它的“父”指针(在规则 1 中设置的那个),然后将这个指针 复制 给新创建的本地框架,作为这个新框架的“父” [08:42]。
-
绑定参数:在新框架中,将函数的形式参数(例如
k)绑定到调用时传入的实际参数(例如4)。
规则 3:当查找一个 变量名 时 (例如 k 或 n) [08:56]
-
从当前开始:始终从“当前框架”(例如
F2)开始查找。 -
查找成功:如果找到了(例如
k),则使用该值 [09:06]。 -
查找失败,向上“追溯”:如果
在当前框架中没有找到(例如 n),则 立即 通过“父”指针跳转到父框架(例如 F1)中去查找 [09:01]。
-
重复:如果
在父框架中仍未找到,则继续跳转到该框架的父框架(例如 Global),依此类推,直到找到该名称或到达全局框架(若全局框架也没有,则抛出 NameError)。
心智模型一:函数是“数据”,而不仅是“动作”
这个例子迫使我们建立一个心智模型:在 Python 中,函数是“一等公民”(First-Class Citizens)。这意味着函数 不仅仅是 一系列指令或一个动作,它更是一个 数据对象(或称“值”,Value)。
-
就像数字
3是一个值,字符串"hello"是一个值一样,adder函数也是一个值。 -
既然是值,它就可以被:
-
赋值:
add_three = ...(我们把adder赋值给了add_three)。 -
传递:可以作为参数传递给其他函数。
-
返回:可以作为另一个函数的返回值(
make_adder就 返回 了adder)。
-
但函数这种“数据”比较特殊,它由两部分组成:1. 它的代码(k + n) 和 2. 它的“出生地”指针(指向 F1 的父指针)。
心智模型二:函数“封装”了其“出生环境”(闭包)
这个心智模型是“三规则”框架的必然推论,也就是“闭包”(Closure)的本质。
-
当
make_adder定义adder时,adder就被“烙印”上了它的“出生环境”(即F1框架)的标记。 -
这个
F1框架包含了adder函数未来可能需要的所有“上下文”信息(在这个例子中,就是n=3)。 -
adder函数(现在叫add_three)就像是背着一个“背包”(Backpack)在旅行。make_adder(它的“家”)在它“出生”时,把n=3这个“必需品”放进了它的背包(即F1框架) [03:32]。 -
即使
make_adder这个“家”已经执行完毕(从调用栈上消失了),但只要add_three这个函数还“活着”(即还被引用),那个装着n=3的背包(F1框架)就会一直被它“背着”(即不会被垃圾回收),因为它未来执行时(k + n)随时可能需要打开背包,拿出n来使用。
因此,add_three 函数 封闭(Close Over)了它在定义时所需的自由变量(n),这就是“闭包”一词的由来。它是一种允许函数 封装数据 和 状态 的强大机制。
Local Names
Function Composition
Self-Reference
Overview
这篇“文章”(基于视频)的核心论题在于探讨 Python 函数如何能够在其函数体内引用自身的名称,即“自引用” (Self-Reference) [00:01]。视频通过两个逐步深入的代码示例 (print_all 和 print_sums),演示了这一机制的运作原理。其核心结论是,函数之所以能引用自身,是因为当函数_被调用_时,其名称(例如 print_all)已经在相应的环境帧(例如全局帧)中被绑定到了一个函数对象上 [00:30]。视频进一步展示了如何利用这种自引用,结合高阶函数(返回另一个函数)和闭包(inner function 访问 outer function 的变量)的特性,来创建能够“记忆”和累积状态的函数 [02:00]。
按照主题来梳理
主题一:简单的函数自引用——print_all 示例
视频的第一个例子围绕一个名为 print_all 的函数展开,旨在说明一个函数在其定义内部引用自己的名字是完全可行的 [00:11]。
代码定义与执行
print_all 函数的定义如下:
1 | def print_all(x): |
这个函数接受一个参数 x,首先打印这个参数 [00:20],然后返回 print_all 这个名称所指向的对象(即函数自身)[00:20]。
为什么这不是问题?
您可能会认为在函数定义内部使用函数自己的名字会立刻导致问题,但关键在于 Python 如何处理函数定义。当 Python 解释器执行 def print_all(...): 这段代码时,它所做的只是创建了一个函数对象,并将这个函数体(body)的内容“储存”起来,等待未来的调用 [00:30]。它并不会立即执行函数体内的代码。
只有当这个函数_被调用_时(例如,通过 print_all(1)),函数体内的代码才会被执行。而在那个时间点,print_all 这个名字已经在它被定义的环境中(在这个例子中是“全局帧” (Global Frame))[01:00] 被牢固地绑定到了这个函数对象上。因此,当执行到 return print_all 这一行时,Python 能够毫不费力地查找到 print_all 这个名字,并返回它所代表的函数。
执行流程拆解:print_all(1)(3)(5)
视频中演示了链式调用的过程:
-
print_all(1): -
... (3)(即[print_all 函数对象](3)): -
... (5)(即[print_all 函数对象](5)):
关键点
这个例子虽然看起来像递归,但它并不是传统意义上的递归(即函数_调用_自身导致更深层次的调用栈)。它只是_返回_了对自身的引用 [01:47]。实际的调用次数完全由外部的链式调用 ( ) ( ) ( ) 的数量决定,而不是由函数内部逻辑决定,因此它不会无限运行下去 [01:53]。
主题二:利用闭包和自引用累积状态——print_sums 示例
第二个例子更为精妙,它展示了如何利用环境和作用域的特性,让函数“记住”之前的计算状态。这个函数的目标是打印出“目前为止所有参数的总和” [02:00]。
代码定义与执行
print_sums 函数的定义如下:
1 | def print_sums(x): |
这个函数做了几件有意思的事:
-
它接受一个参数
x,它假定x是“目前为止的总和” [02:40]。 -
它首先打印这个总和
x。 -
它在_内部_定义了另一个函数,叫做
next_sum[02:25]。 -
next_sum函数接受一个新的数字y[02:30]。 -
next_sum的核心工作是计算x + y(即“之前的总和”加上“新的数字”),然后_调用_print_sums函数,并将这个新的总和x + y作为参数传进去 [02:30]。 -
print_sums函数最后_返回_的是next_sum这个内部函数 [02:40],而不是print_sums自己。
执行流程拆解:print_sums(1)(3)(5)
视频通过环境图(Environment Diagram)详细展示了这一过程:
-
print_sums(1): -
... (3)(即next_sum_A(3)): -
print_sums(4)(由next_sum_A(3)触发):-
print_sums再次被调用,这次参数x绑定为4。这代表了新的“总和” [03:30]。 -
函数体执行
print(4),控制台输出4[03:36]。 -
它_又_创建了_另一个_新的内部函数
next_sum(我们称之为next_sum_B)。 -
关键点:
next_sum_B知道它是在print_sums(4)这次调用的环境中(我们称为F3)创建的。因此,它“记住”了x = 4[03:49, 03:59]。 -
print_sums(4)返回了这个next_sum_B函数对象。 -
这个返回值(
next_sum_B)成为了next_sum_A(3)调用的返回值,也就是print_sums(1)(3)整个表达式的最终值。
-
-
... (5)(即next_sum_B(5)): -
print_sums(9)(由next_sum_B(5)触发):-
print_sums第三次被调用,参数x绑定为9。 -
函数体执行
print(9),控制台输出9[04:29]。 -
它_又_创建了_第三个_内部函数
next_sum(next_sum_C),这个函数记住了x = 9。 -
print_sums(9)返回next_sum_C。由于这是链式调用的最后一步,这个返回的函数没有被再次调用,程序结束。
-
总结
在这个过程中,print_sums 负责打印当前的“总和” [05:07],而 next_sum 负责接收下一个数字,并利用它从父帧中“继承”来的 x(上一个总和)来计算新的总和 [04:59],然后通过调用 print_sums 来推进整个流程。
框架 & 心智模型 (Framework & Mindset)
从这个视频中,我们可以抽象出 Python 编程中至关重要的框架和心智模型,即它如何管理变量和函数调用。
框架:词法作用域与环境模型 (Lexical Scoping & Environment Model)
视频所演示的一切,都建立在 Python 的“词法作用域”(Lexical Scoping)或“静态作用域”规则之上。这个框架的核心思想是:
-
帧 (Frame) 的概念:每次调用一个函数时,Python 都会创建一个新的“环境帧”(Environment Frame)[01:00]。这个帧用于存储该次调用的局部变量(例如函数的参数
x和y)。 -
父帧 (Parent Frame) 链接:当你在一个函数(Outer Function,如
print_sums)内部定义了另一个函数(Inner Function,如next_sum)时,这个内部函数(next_sum)在被创建时,会包含一个指向其_定义时_所在环境(即print_sums的执行帧)的链接 [03:18, 03:49]。这个链接就是它的“父帧”。 -
变量查找规则:当你试图访问一个变量(比如
x)时,Python 的查找顺序是:
print_sums 如何利用这个框架?
print_sums 示例完美地利用了这个框架:
-
变量
x(代表“当前总和”)被存储在print_sums的执行帧中(例如F1帧存储了x=1,F3帧存储了x=4)[03:59, 04:13]。 -
print_sums返回的next_sum函数对象,由于其父帧链接,“随身携带”了对这个x的访问权。 -
即使
print_sums的调用已经返回(return next_sum),但因为它创建的next_sum函数对象仍然存在(它被返回并准备下一次被调用),所以print_sums的执行帧(F1或F3)不会被销毁。它会继续存在,因为它需要为next_sum提供变量x[03:18]。 -
这种“一个函数和它所引用的、来自其父作用域的变量”的组合,就是编程中著名的概念——“闭包” (Closure)。
next_sum和它所引用的x共同构成了一个闭包。
通过这个框架,print_sums 巧妙地将“状态”(x 的值)[04:55] 隐藏在了 next_sum 函数的父帧中,实现了在没有外部变量的情况下,跨多次函数调用来“记忆”和“累积”数据。
心智模型:通过“环境图”理解代码执行
这个视频在讲解时(虽然我们只能听到描述)严重依赖一个核心的心智模型:将代码执行过程可视化为“环境图” (Environment Diagrams)。这是一种调试和理解复杂代码(尤其是涉及高阶函数、闭包和递归)的强大思维工具。
建立这个心智模型的步骤:
-
全局帧 (Global Frame):想象一个顶层的大框,所有在顶层(文件级别)定义的函数(如
print_all,print_sums)和变量都住在这里。print_sums这个名字指向一个函数对象 [01:00]。 -
函数调用 = 创建新帧:每当一个函数被_调用_(
(...)),就在它下面(或者旁边)画一个新的框,这就是它的局部执行帧 [01:06]。例如,print_sums(1)创建了F1帧。 -
参数与局部变量:在新帧中,为函数的所有参数(如
x=1)和在函数体内定义的任何新局部变量创建条目 [01:06]。 -
父帧指针:如果一个函数(如
print_sums)在执行时创建了新的函数(如next_sum),那么这个新创建的函数对象(next_sum_A)内部必须有一个“指针”,指向创建它的那个帧(F1)[03:18]。这是理解闭包的关键。 -
跟踪返回值:当一个函数
return时,它会返回一个值。如果这个值是一个函数(如next_sum_A),那么这个函数对象就被传递出去 [01:13, 03:00]。 -
链式调用:当执行
print_sums(1)(3)(5)时,不要试图一次性理解它。-
第一步:执行
print_sums(1)。它创建了F1帧(x=1),并返回了next_sum_A(它记住了F1)。 -
第二步:执行
next_sum_A(3)。它创建了F2帧(y=3),它的父帧是F1。它在F1中找到了x=1,在F2中找到了y=3。 -
第三步:
next_sum_A的任务是调用print_sums(1 + 3),即print_sums(4)。这个调用创建了F3帧(x=4)[03:30]。 -
第四步:
print_sums(4)返回了next_sum_B(它记住了F3,F3里有x=4)。 -
第五步:执行
next_sum_B(5)。它创建了F4帧(y=5),它的父帧是F3。它在F3中找到了x=4,在F4中找到了y=5。 -
第六步:
next_sum_B调用print_sums(4 + 5),即print_sums(9)。这个调用创建了F5帧(x=9)[04:26]。 -
第七步:
print_sums(9)打印9[04:29],并返回next_sum_C(它记住了F5)。 -
程序结束。
-
通过这种方式,print_sums 帧(F1, F3, F5)负责存储和打印总和(1, 4, 9)[05:36],而 next_sum 帧(F2, F4)负责存储单独的加数(3, 5)[05:44],并通过父帧链接来执行加法。这个心智模型将函数调用、作用域和状态管理这些抽象概念,转化为了具体的、可跟踪的“帧”和“指针”,使复杂的执行流变得清晰可见。
Function Currying
Overview
本视频的核心论题是介绍 函数柯里化 (Function Currying) 这一编程概念。柯里化是一种函数操作技术,它能将一个接收多个参数的函数,转变为一系列“链式”的、每个只接收一个参数的函数。视频通过一个经典的 make_adder (创建加法器) 示例引入,展示了如何通过一个函数来返回另一个函数,并最终实现一个接收两参数的 add (加法) 函数的等价效果。其结论是,柯里化是一个通用的转换关系,可以将一个多参数函数,转变为一个返回函数的单参数高阶函数 (higher-order function)。
按照主题来梳理
主题一:柯里化的核心思想——以 make_adder 为例
视频的核心是展示一种“操作”函数的方式,即“函数柯里化” (Function Currying) [00:02]。
为了理解这个概念,我们首先回顾一个在之前课程中反复出现[00:02]的例子:make_adder (创建加法器) 函数 [00:13]。
-
make_adder 的定义
这个函数的目标是“制造”一个加法器。它首先接收一个参数 n 00:22。
它返回的 不是 一个计算结果,而是 另一个函数 00:22。
这个被返回的“内部函数”,它自己也需要一个参数,我们称之为 k 00:22。
当这个内部函数被调用时,它会执行最终的计算,返回 n + k 的结果 00:22。
-
代码实现 (Lambda 版本)
视频中展示了 make_adder 的一种简洁实现,使用了 lambda 表达式 00:30:
1
2def make_adder(n):
return lambda k: n + k这里的
lambda k: n + k就定义了那个“内部函数”。它创建了一个匿名函数,该函数接收k作为参数,并将其与外部函数作用域中的n(在make_adder被调用时传入的) 相加 [00:30]。这种效果与在make_adder内部再用def定义一个新函数(比如叫adder)是完全相同的 [00:30]。 -
调用方式与效果
要使用 make_adder 得到一个最终的数字结果,需要进行 两次 函数调用 00:53。
-
第一次调用:我们执行
make_adder(2)[00:38]。这个调用的返回值是一个 函数,具体来说,是那个lambda k: 2 + k函数。这个新函数在功能上等同于“加 2 器” [00:47]。 -
第二次调用:我们拿到上一步返回的“加 2 器”函数,然后用参数
3来调用它 [00:47]。 -
最终结果:内部函数执行
2 + 3,返回5[00:53]。
整个过程可以用一个表达式来表示 [00:38]:
make_adder(2)(3),结果为5。 -
-
对比:普通的 add 函数
这种“分两步”调用的方式,与我们更熟悉的、一次性接收所有参数的普通 add (加法) 函数形成了对比 01:01。
一个普通的 add 函数会这样定义:
1
2def add(x, y):
return x + y它直接接收两个参数
x和y,并立即返回它们的和 [01:01]。 -
make_adder 和 add 之间的关系
make_adder 和 add 之间存在一种普遍的关系 01:07。
柯里化 (Currying) 正是用来描述和实现这种从
add(多参数) 到make_adder(单参数、返回函数) 的转换 [01:22]。
主题二:通用的柯里化函数 curry2
视频接着展示了如何将这种 add 到 make_adder 的转换关系,用代码“泛化” (generalize),使其适用于任何接收两个参数的函数。这个通用的转换函数被命名为 curry2 [01:29](这里的 2 特指它所处理的函数 f 是接收两个参数的)[01:29]。
-
curry2 的嵌套函数实现
curry2 函数的目标是:接收一个 双参数函数 f (例如 add) 作为输入,然后返回一个 单参数函数 g (类似 make_adder) 作为输出。
1
2
3
4
5
6def curry2(f):
def g(x):
def h(y):
return f(x, y)
return h
return g让我们来分解这个实现 [01:42]:
-
curry2 的应用
通过 curry2,我们就从一个双参数的 add 函数 02:16,制造出了一个高阶函数 m 02:25,这个 m 的行为与我们之前手写的 make_adder 完全一致 02:30。
1
2
3
4
5# 假设 add 函数已定义
# def add(x, y): return x + y
# 将 add 函数柯里化
m = curry2(add)现在,
m就是一个“加法器制造机”,用法和make_adder相同 [02:35]: -
curry2 的 Lambda 表达式实现
视频指出,curry2 这种层层嵌套返回函数的结构,非常适合用 lambda 表达式来书写,可以压缩成一行代码 02:53:
1
curry2 = lambda f: (lambda x: (lambda y: f(x, y)))
这个
lambda表达式在逻辑上与前面def嵌套的版本是等价的 [03:01]:使用这个
lambda版本的curry2,我们可以同样地将其应用于add函数,并以高阶函数的形式(分两次调用)来完成加法 [03:18, 03:27]。
主题三:柯里化的定义与历史
-
柯里化的泛化定义
视频最后给出了柯里化的通用定义:柯里化 (Currying) 是一种行为 (act),它将一个 多参数函数 (multi-argument function),转换为一个 单参数 的 高阶函数 (single-argument higher-order function) 03:32。
这个新的高阶函数在被调用后,会返回另一个函数,而这个返回的函数则负责接收“剩余的” (the rest of the) 参数 [03:40]。
-
历史溯源
视频提到了一个关于柯里化命名的有趣事实 03:40:
框架 & 心智模型 (Framework & Mindset)
从本视频中,我们可以抽象出一个核心的编程心智模型:“函数柯里化” (Function Currying) 作为一种参数“分期” (Staged) 收集的框架。
这个框架的核心思想是:不要一次性地接收所有你需要的参数,而是将一个需要 N 个参数的计算过程,拆解为 N 个“步骤”,每个步骤只接收一个参数,并返回一个新的“步骤”(即一个新函数),直到收集齐所有参数,才执行最终的计算。
我们可以将这个心智模型分解为以下几个关键步骤和认知转变:
-
1. 转变视角:从“多参数函数”到“单参数函数链”
-
传统心智模型 (如
add(x, y)):函数是一个“一步到位”的计算器。你必须在 调用时 就提供所有的“原材料”(参数x和y)。计算器立即启动,并给出最终答案x + y[01:01]。 -
柯里化心智模型 (如
curry2(add)(x)(y)):函数是一个“分步组装”的工厂。-
第一步
curry2(add):这是“配置工厂”。你告诉工厂:“你未来的任务是执行add操作”。工厂返回第一个工人g(或m)[02:16, 02:35]。 -
第二步
g(x):这是“提供第一个零件”。你把零件x(例如3)交给工人g[02:43]。工人g并不立即计算,而是返回第二个工人h,这个工人h“记住”了零件x=3(通过闭包)[01:42, 02:01]。 -
第三步
h(y):这是“提供第二个零件”。你把零件y(例如4)交给工人h[01:52]。工人h现在拥有了所有必需的零件(x=3和y=4)以及最初的指令(f = add),于是它执行最终的计算f(x, y)[01:52, 03:12]。
-
-
-
- 框架的实现:嵌套与闭包 (Closure)
这个框架在技术上严重依赖“闭包” (Closure) 这一特性(尽管视频没有明确说出“闭包”这个词,但 make_adder 和 curry2 的实现就是闭包的经典应用)。
-
在
curry2(f)的实现中 [01:42],内部的h(y)函数之所以能访问到x,是因为x是在它的“父级”函数g(x)的作用域中定义的。 -
同理,
h(y)也能访问到f,因为f是在g(x)的“父级”函数curry2(f)的作用域中定义的。 -
当
g(x)被调用并返回h时,即使g的执行已经结束,h依然“封闭并记住” (closes over) 了x和f的值。这就是为什么h在未来某个时刻被调用时,依然能正确地使用x和f来执行f(x, y)。
-
- 抽象的价值:函数的“部分应用” (Partial Application)
柯里化框架提供了一个强大的能力,即“部分应用” (Partial Application)。
-
在
m = curry2(add)之后,我们调用add_three = m(3)[02:43]。 -
add_three这个函数 [02:43],在概念上就是add函数在“第一个参数被固定为3”之后的一个“部分应用”版本。它是一个更“特化” (specialized) 的函数,一个“加 3 器”。 -
这种心智模型允许我们将一个通用的函数 (如
add),通过“部分应用”动态地、批量地创建出无数个特化的函数 (如add_three,add_five,add_ten等),这在需要将“配置” (如n=3) 和“执行” (如调用k=4) 相分离的场景中非常有用。例如,在函数式编程中,需要将一个“配置好”的函数(如add_three)作为回调函数或参数传递给其他高阶函数(如map,filter等)。
-
- 统一接口:一切皆为单参数函数
柯里化的终极心态是将所有函数都视为“单参数函数”。一个接收两个参数的函数 f(x, y),在柯里化模型中被视为一个接收 x 的函数,它返回一个接收 y 的函数。
Q&A
Overview
视频的核心论题是深入辨析 Python 中关于函数执行、作用域和环境图 (Environment Diagrams) 的关键且微妙的机制。它通过一系列学生提出的、关于高阶函数、嵌套函数、柯里化 (Currying) 和 Lambda 表达式的棘手案例,得出了一个核心结论:只有通过环境图,一步一步、精确地追踪帧 (frame) 的创建、变量的绑定以及父指针 (parent pointer) 的指向,才能真正理解代码的最终行为。仅仅依靠直觉或“读”代码,很可能会在面对这些复杂情况时得出错误结论。
按照主题来梳理
核心探究:print_sums 函数与环境图
这是视频中花费时间最长、最核心的案例,它完美地展示了嵌套函数、作用域和返回函数如何协同工作。
问题的起点:print_sums 函数
我们讨论的函数如下(为了清晰,我重构了视频中的代码):
1 | def print_sums(n): |
场景一:print_sums(1)
当我们执行 print_sums(1) 时 [00:32]:
-
创建一个
print_sums的帧,我们称之为f1。 -
在
f1中,n被绑定到1。 -
执行
print(n),打印出 1。 -
定义了内部函数
f(k)。关键在于,这个函数对象在“定义时”就获得了一个指向其“出生地” (f1) 的父指针。 -
print_sums返回了这个f函数对象。
场景二:g = print_sums(1) 和 h = g(3)
这部分是精髓所在 [01:40]:
-
g = print_sums(1):执行过程同场景一。g现在在全局帧 (Global Frame) 中被绑定到了那个内部f函数对象(其父指针指向f1,而f1中有n=1)。 -
h = g(3):这是在_调用_g,也就是在调用那个内部的f函数。 -
调用
print_sums(4): -
h = ...:h在全局帧中被绑定到了这个_新_的f函数对象(其父指针指向f3,f3中有n=4)。
场景三:w = h(5)
-
w = h(5):这是在_调用_h,也就是在调用场景二最后返回的那个_新_f函数。-
创建一个
f的帧,我们称之为f4。 -
f4的父指针被设置为f3(由h所带的函数对象决定)。 -
在
f4中,参数k被绑定到5。 -
执行
f的函数体:return print_sums(n + k)。 -
查找
n:在f4中找不到n,顺着父指针去f3中找。找到了!n是4[05:24]。 -
查找
k:在f4中找到了k,k是5。 -
计算结果为
4 + 5 = 9。 -
执行
return print_sums(9)。
-
-
调用
print_sums(9):-
创建一个
print_sums的帧,f5。n绑定到9。 -
执行
print(n),打印出 9 [05:31]。 -
定义了一个_更新_的
f函数(父指针指向f5)。 -
返回了这个_更新_的
f函数,并将其绑定到w。
-
结论:print_sums(1)(3)(5) 这一系列调用的输出是 1、4、9。这种链式调用 [05:38] 和上面分步赋值 (g, h, w) 的执行过程是完全一样的,唯一的区别是链式调用没有在全局帧中保留 g 和 h 这两个中间名字 [06:41]。这个例子说明,函数返回的不仅是代码,还“捆绑”了它定义时的环境(通过父指针),使其能够“记住”状态(比如 n 的值)。
“柯里化” (Currying) 详解:一种函数“半封装”技术
什么是柯里化? [11:13]
柯里化是一种函数式编程技术。通俗地说,它将一个接收_多个_参数的函数(例如 f(x, y))转换成一系列_各自只接收一个_参数的函数(例如 g(x) 返回 h(y),h(y) 再返回 f(x, y) 的结果) [11:32]。
视频中的 curry2(pow) 示例 [12:21]
-
pow(x, y)是一个接收两个参数的函数,计算x的y次方。 -
curry2是一个辅助函数,它接收pow,并返回一个新的函数h(x)。 -
调用
h(x)(例如h(2))会返回_另一个_新函数g(y)。 -
这个
g(y)函数“记住”了x=2。当你调用g(5)时,它最终执行pow(2, 5),得到32。
为什么柯里化有用?
-
参数的“分期交付” [13:18]:
-
这是最有用的场景。在程序中,你可能很早就知道第一个参数(例如
x=2),但第二个参数(例如y=5)要过很久才能确定。 -
柯里化允许你“中途”评估函数 [14:02],把
pow和2“捆绑” [12:47] 在一起,得到一个新函数two_to_the = curry2(pow)(2)。 -
这个
two_to_the函数(即上面的g(y))可以被传递到程序的其他地方,直到你获得5时再调用它:two_to_the(5)。 -
视频中提到了 Hog(猪)项目 [15:03]:你事先知道一个策略的参数(比如
cutoff值),但不知道游戏中的实际分数。你可以用柯里化“固定”cutoff值,生成一个只等待分数的“特化”策略函数。
-
-
代码组织与复用:
-
潜在的计算优势 [32:08]:
-
虽然不常见,但如果第一个参数
x本身需要非常复杂的计算才能得出,柯里化允许你只计算_一次_,然后“捆绑”这个结果。 -
如果你之后用不同的
y多次调用这个函数,就避免了重复计算x的开销。
-
尽管一位讲师表示在日常编码中很少_显式_使用 curry 函数 [30:08],但另一位讲师 (John DeNero) 指出,这种“将函数和其部分参数捆绑成一个新函数” [31:57] 的_概念_,在组织程序和使用通用库时非常有用。
Python 的“SOP”:函数调用的精确执行顺序
这个主题是 Python 运行的核心机制,也是最容易出错的地方。视频通过几个例子强调了评估参数 (evaluate arguments) 和执行函数体 (execute body) 之间的区别。
规则:调用表达式 (Call Expression) 的评估顺序 [16:24]
当你看到一个函数调用,如 f(arg1, arg2) 时,Python 的执行步骤是固定的:
-
评估操作符(Operator),即
f。 -
从左到右评估所有参数(Argument expressions),即
arg1,然后arg2,… -
等到_所有_参数的值都计算完毕后,才真正开始_调用_函数
f:-
创建一个
f的新帧。 -
将第 2 步得到的_值_绑定到
f的形参。 -
开始执行
f的函数体。
-
案例一:compose1(square, make_adder(2)) [16:05]
为什么 make_adder 的帧会先于 compose1 的帧出现?
-
Python 准备调用
compose1。 -
评估第一个参数
square:它是一个函数对象。 -
评估第二个参数
make_adder(2):注意!这本身也是一个调用表达式! -
Python 必须_暂停_对
compose1的评估,转而去执行make_adder(2)[17:19]。 -
此时,
make_adder的帧被创建,n绑定到2,它返回一个adder函数。 -
现在,
compose1的第二个参数的值(那个adder函数)被计算出来了。 -
直到这时 [17:08],Python 才集齐了调用
compose1所需的所有参数值。 -
现在,
compose1的帧才被创建,开始执行它的函数体。
案例二:Lambda 表达式——创建函数,而非调用 [32:30]
问题:为什么 coffee(lambda x: x) 这一行没有 lambda 帧?
-
lambda x: x是一个_表达式_。当 Python 评估它时,它所做的_仅仅_是创建一个函数对象 [33:09]。它_不会_调用这个函数。 -
coffee(...)是一个_调用_。 -
Python 将第 1 步创建的那个_函数对象_作为参数传递给
coffee。 -
coffee函数被调用,创建了coffee帧。在这个帧里,参数(比如叫grounds)被绑定到了那个lambda函数对象 [34:13]。 -
自始至终,
lambda函数本身都没有被调用(即没有()跟在它后面)。 -
只有当
coffee的函数体内部_执行_了grounds()这样的调用时,才会为这个lambda函数创建它自己的帧 [34:18]。
案例三:“冒犯性”的 Lambda 错误 [22:59]
这个例子 higher_order_lambda(2)(g)(其中 higher_order_lambda 是 lambda f: lambda x: f(x))完美地展示了这个顺序:
-
higher_order_lambda(2):调用_外层_lambda。f被绑定到_数字_2[23:44]。 -
此调用返回_内层_
lambda,但f已经被2替换了。返回的是一个等价于lambda x: 2(x)的新函数。 -
... (g):用参数g(一个函数)去_调用_第 2 步返回的那个新函数。 -
x被绑定到g。 -
执行函数体
2(x),这变成了2(g)。 -
程序崩溃 [24:52],因为你试图_调用_一个整数
2,提示int object is not callable[25:12]。
这个例子(虽然代码风格很“冒犯”[25:26])有力地说明了,Python 只是在机械地执行评估规则,它并不关心 f _应该_是函数。
作用域的边界:变量重绑定与“兄弟”函数
变量重绑定的影响 [09:31]
如果一个函数内部(f1 帧)有 me = 1,然后定义了一个使用 me 的内部函数 g,接着又执行了 me = 2,那么_在此之后_调用 g 时,me 的值是多少?
-
答案是 2 [09:50]。
-
原因:
g在定义时,其父指针指向f1帧。当g被调用时,它顺着指针去f1帧里找me。它找到的是f1帧中me_当前_的值,而这个值在g被调用前已经被改成了2。 -
结论:函数记住的是其父_帧_,而不是父帧在它定义_那一刻_的_快照_ (snapshot)。
“兄弟”函数的作用域 [20:59]
如果一个函数 f(x) 内部定义了_两个_内部函数 g(y) 和 h(z),它们是“兄弟”关系:
-
它们都可以访问
f帧中的x(因为f是它们的共同父帧)。 -
但是,
h_绝对不能_访问g的参数y[21:19]。 -
原因:
y存在于g的_局部帧_中,而z存在于h的_局部帧_中。这两个帧是平级的,h的查找路径(h帧 ->f帧 -> 全局帧)永远不会经过g帧 [22:36]。 -
结论:作用域的查找(即沿着父指针的查找)是严格_向上_的,不能“横向”访问兄弟帧。
框架 & 心智模型 (Framework & Mindset)
框架:环境图 (Environment Diagrams) 作为核心分析工具
视频中所有问题的最终答案都归结于这个框架。它不是一个算法,而是一个视觉化的心智模型,用于精确模拟 Python 的执行过程。要正确使用它,必须遵守以下组件和规则:
-
组件 1:帧 (Frames)
-
每当一个函数被_调用_时 [34:49],就会创建一个新帧。
-
帧是一个“盒子”,用于存储该次调用的_局部变量_(参数和函数体内赋的值)及其绑定。
-
全局 (Global) 帧是所有执行开始的地方。
-
-
组件 2:父指针 (Parent Pointers)
-
这是最关键、也最反直觉的组件。
-
当一个函数(如
f)在_另一个_函数(如print_sums)内部被_定义_时 [00:50],Python 会创建一个函数对象。 -
这个函数对象除了包含
f的代码外,还包含一个_指针_,指向它“出生”时所在的_帧_(即print_sums的当前帧,如f1)。 -
这个指针就是“父指针”。
-
重点:每次
print_sums被调用,都会创建一个_新_的print_sums帧(如f1、f3),因此在其中定义的_内部_f函数也会是_不同_的函数对象,它们各自拥有指向_不同_父帧(f1、f3…)的父指针 [03:24]。这就是print_sums能“累加”的秘密。
-
-
规则 1:变量查找 (Lookup Rule)
- 当代码需要一个变量(如
n)的值时,Python 严格按以下顺序查找:
-
在_当前_帧中查找。
-
如果找不到,则顺着当前帧的_父指针_,跳转到其父帧中查找 [02:49]。
-
如果还找不到,继续顺着父指针向上查找,直到全局帧。
-
如果在全局帧中也找不到,则抛出
NameError。
- 这个规则解释了为什么在
g(3)的f2帧中能找到n=1(在f1中),以及在h(5)的f4帧中能找到n=4(在f3中)。
- 当代码需要一个变量(如
-
规则 2:赋值 (Assignment Rule)
心智模型:严格区分“定义时”与“调用时”
这是理解高阶函数和 Lambda 表达式的必备心智模型。
-
“定义时” (Definition Time)
-
“调用时” (Call Time)
总结:视频中的所有“难题”都不是魔术。它们只是“环境图”和“定义/调用”这两个简单、机械的规则组合在一起时,产生的必然结果。掌握了这两个模型,就能像解释器一样思考,从而看清 Python 的“内功”。

