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_twicesquare),并将它们分别绑定(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。其逻辑是:

  1. apply_twice 内部首先计算 F(X),即 square(3),得到 9。

  2. 然后它计算 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]:

    1. 创建一个新的帧(Frame)。

    2. 将函数的形参(formal parameters)绑定到传入的实参(arguments)上。

    3. 执行该函数的函数体(body)。

  • 步骤二:创建新帧 (F1) 并绑定参数

    执行上述步骤的结果是,环境图解中出现了一个新的帧,视频中称之为 F1 [04:28]。这个 F1 帧是为执行 apply_twice 的函数体而创建的 [04:29]。

    • 参数绑定: 在 F1 帧内部,apply_twice 的两个形参 FX 被绑定到传入的实参上。

      • 形参 F 绑定到了 square 函数对象 [02:30], [04:47]。

      • 形参 X 绑定到了数字 2 [02:30], [04:47]。

    • 此时,我们的“当前环境(current environment)”由这个新的本地帧(F1)和随后的全局帧(Global Frame)组成 [05:00] - [05:07]。

  • 步骤三:执行函数体 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: 解释器再次查找 F。它仍然在 F1 帧的上下文中,所以 F 仍然是 square 函数 [03:09]。

    • 执行 square(4): 解释器现在调用 square(4) [03:17]。这又会创建另一个新帧(F3),执行计算(4*4),并返回 16 [03:17]。

  • 步骤六:返回最终结果

    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]:

  1. 创建新帧 (Creating a new frame):

    • 每当一个函数被调用时,系统都会创建一个全新的、本地的“帧 (frame)” [04:28]。这个帧是为本次特定的函数调用服务的,用于存储该调用所需的本地信息。

    • 例如,在调用 apply_twice(square, 2) 时,创建了 F1 帧 [04:29]。在 F1 内部执行 F(X)(即 square(2))时,又会创建另一个新帧(F2)。当 F2 返回 4 后,再执行 F(4)(即 square(4))时,还会创建再下一个新帧(F3)[03:00], [03:17]。

    • 这个帧是暂时的,一旦函数执行完毕并返回值,这个帧通常就会被销毁(尽管视频没有明确提及销毁,但这是该模型的标准部分)。

  2. 绑定形参 (Binding the formal parameters):

    • 在新创建的帧中,函数定义中的“形参 (formal parameters)”(例如 apply_twice 中的 FX)会与调用时传入的“实参 (arguments)”(例如 square 函数和数字 2)进行绑定 [04:08]。

    • 这种绑定是理解高阶函数的关键。在 F1 帧中,F 这个名字确实地指向了 square 函数对象,就像 X 指向了数字 2 一样 [04:39] - [04:47]。系统对函数和数字一视同仁,它们都是可以被名称绑定的值。

  3. 执行函数体 (Executing the body):

    • 在参数绑定完成后,解释器开始逐行执行该函数的“函数体 (body)”(例如 apply_twice 中的 return F(F(X)))[04:08]。

    • 变量查找规则 (Lookup Rule): 在执行函数体时,如果遇到一个变量名(如 FX),解释器会遵循一个严格的查找规则来确定它的值:

      • 首先,在“当前帧”(即为本次调用创建的本地帧,如 F1)中查找 [05:14]。

      • 如果在当前帧中找不到,就去其父环境中查找(视频中的例子是全局帧 Global Frame)[05:14]。

      • 这个查找过程会沿着环境链一直向上,直到找到该名称,或者最终在全局帧也找不到(此时会报错)。

    • apply_twice 的例子中,当执行 F(F(X)) 时,解释器在 F1 帧中查找 F,立即找到了它绑定的 square 函数 [05:20] - [05:27]。这就是为什么 F 能够被正确地作为函数来调用 [05:31]。

这个“创建帧 - 绑定参数 - 执行函数体(及查找规则)”的三步框架,就是环境模型的核心。

心智模型:函数作为一等公民 (Functions as First-Class Citizens)

要真正理解高阶函数,必须建立一个关键的“心智模型 (Mindset)”:函数是“一等公民 (First-Class Citizens)”。虽然视频没有使用这个术语,但它所展示的机制完全体现了这一概念。

  1. 函数是一种值 (Functions are values):

    • 高阶函数的核心定义是,它“接受另一个函数作为参数值 (argument value)”或“返回一个函数作为返回值 (return value)” [00:10]。

    • 这个心智模型的关键转变在于,不再将函数仅仅视为“要执行的动作”,而是要将其视为一种“数据值 (value)”,就像数字(如 23)或字符串一样 [00:30]。

    • apply_twice(square, 3) 的调用中 [01:30],square 不是在被调用时传入的,而是 square 这个值(即函数对象本身)被传入了 [01:48] - [01:56]。

  2. 函数可以被名称绑定 (Functions can be bound to names):

    • 在环境模型中,最能体现这一点的就是参数绑定步骤 [02:30]。

    • 在为 apply_twice 创建的 F1 帧中,形参 F 被绑定到了 square 函数值 [04:39] - [04:47]。

    • 这意味着在 F1 帧的范围内,F 就是 square 函数。这与 X 就是 数字 2 是完全对称的。

    • 当我们随后在函数体中执行 F(X) 时,解释器查找 F,找到了 square 函数 [05:27],然后才执行调用。如果 F 被绑定的是一个数字,那么 F(X) 的调用将会失败。

  3. 对环境模型的影响 (Implication for Environment Model):

    • 拥有这个心智模型后,我们就能理解为什么现有的环境图解规则不需要任何修改 [00:16]。

    • 环境模型的核心功能就是管理“名称 (names)”和“值 (values)”之间的绑定关系。

    • 既然函数只是一种值,那么环境模型自然有能力处理它:

      • 在全局帧中,名称 square 绑定到一个函数值。

      • 在 F1 帧中,名称 F 绑定到同一个函数值。

    • 视频的结论——“我们的环境图解已经可以处理高阶函数了” [00:16]——正是建立在这个“函数即价值”的心智模型之上的。我们不需要为“传递函数”发明新的规则,因为我们已有的“传递值”的规则(即参数绑定)已经覆盖了这种情况 [00:25] - [00:30]。

Environments for Nested Definitions

Overview

本视频的核心论题是:在 Python 中,一个嵌套定义的函数(内部函数)是如何访问其“出生”环境(外部函数)中的变量的,尤其是在外部函数已经执行完毕并返回之后?

视频通过一个具体的 make_adder 函数示例,并借助“环境图”(Environment Diagrams)这一工具,得出了一个清晰的结论:函数不仅仅是一段代码,它还是一个包含“指向其定义时所在环境(框架)的指针”的数据对象。这个被存储的“父框架”(Parent Frame)指针,使得内部函数无论在何时、何地被调用,都能回溯到其“出生地”去查找变量。这个机制正是“闭包”(Closures)的底层实现原理,它允许函数“封装”并“记住”其定义时的状态(数据)。

按照主题来梳理

为了完整理解这个机制,视频将我们带入了一个逐步执行的调试过程。

主题一:make_adder 示例所带来的“魔法”问题

视频首先展示了一段简短但精妙的代码 [00:00]:

1
2
3
4
def make_adder(n):
def adder(k):
return k + n
return adder
  • 代码逻辑make_adder 是一个函数,它接收一个参数 n。在它内部,它 定义了 另一个函数,名为 adderadder 函数接收一个参数 k,并返回 k + n [00:09, 00:19]。最关键的是,make_adder 函数 返回 的不是一个计算结果,而是 adder 这个 函数本身 [00:29]。

  • 执行演示

    1. 我们调用 add_three = make_adder(3) [00:43]。

      • make_adder 被调用,参数 n3

      • 它在内部定义了 adder 函数。

      • 它返回了这个 adder 函数。

      • 这个返回的函数(adder)被赋值给了 add_three 这个变量。

    2. 现在,add_three 变量指向一个函数。我们来调用它:

      • add_three(4) 返回 7 [00:51]。

      • add_three(5) 返回 8

      • add_three(6) 返回 9

  • 核心问题make_adder(3) 这个调用在第一步就已经执行完毕并返回了。按理说,它的本地变量(如 n=3)应该已经随着函数调用的结束而消失了。那么,为什么我们在后面调用 add_three(4) 时,这个函数(它实际上就是 adder 函数)仍然“记得” n3 呢?[00:56]。这个 add_three 函数是如何将数据(3)包含在其内部的?[01:02]

主题二:环境图(上)—— make_adder(3) 的执行与返回

要回答这个问题,必须使用“环境图”(Environment Diagram)来追踪每一步。

  1. 定义 make_adder

    • 当 Python 读到 def make_adder... 时,它首先在“全局框架”(Global Frame)中创建了一个函数对象(function value),并把 make_adder 这个名字指向它 [01:10]。此时,函数体内的代码 并未 执行。
  2. 调用 make_adder(3)

    • 这是执行 add_three = make_adder(3) 的过程 [01:19]。

    • 创建新框架:一次函数调用会创建一个新的“本地框架”(Local Frame)。我们称这个框架为 F1

    • 绑定参数make_adder 的形式参数 n 在这个 F1 框架中被绑定到实际参数 3 [01:27]。

  3. 执行 make_adder 的函数体

    • 进入 F1 框架内部,执行第一行代码:def adder(k): ... [01:27]。

    • 创建内部函数:这是一个 定义 语句。Python 在此时会创建一个 新的 函数对象(adder 函数)[01:35]。

    • 关键时刻:设置父框架:在创建 adder 这个函数对象时,Python 会做一件至关重要的事:它会把这个新函数对象的“父”(Parent)指针,指向 当前所在的框架 [04:37]。在这一刻,当前框架 正是 F1(即 make_adder 的执行框架)。

    • 本地绑定:然后,Python 在 当前框架F1)中,将名字 adder 绑定到这个刚刚创建的新函数对象上 [01:43]。

  4. 执行 return adder

    • 执行 make_adder 的第二行代码 return adder [01:58]。

    • 查找 adder:Python 在当前环境(F1)中查找 adder 这个名字,找到了我们上一步创建的那个 adder 函数对象。

    • 返回函数make_adder 函数执行完毕,将这个 adder 函数对象作为 返回值 [02:07],返回给调用者(全局框架)。

  5. 执行赋值 add_three = ...

    • 回到全局框架 [02:50]。

    • make_adder(3) 的返回值(即那个 adder 函数对象)被赋值给 add_three 这个名字 [02:59]。

    • 结果:现在,全局框架中的 add_threeF1 框架中的 adder 都指向 同一个 函数对象。这个函数对象“携带”着一个秘密:它的父框架是 F1

此时,make_adder 的调用结束了。F1 框架从“调用栈”上消失了,但它并没有被“垃圾回收”销毁,因为它仍然被 add_three 函数对象(通过其父指针)引用着 [03:32]。

主题三:环境图(下)—— add_three(4) 的执行与解密

现在,我们来执行最关键的一步:add_three(4) [03:22]。

  1. 调用 add_three(4)

    • add_three 指向的其实是 adder 函数对象。

    • 创建新框架:创建

      一个新的本地框架,我们称之为 F2(它的标题是 adder)[03:22]。

    • 绑定参数adder 函数的形式参数 kF2 框架中被绑定到实际参数 4

  2. 设置 F2 的父框架

    • F2 这个新框架的“父”框架是谁?

    • Python 会查看 被调用 的函数对象(即 add_three 指向的那个对象),读取它在“出生”时被烙印上的“父”指针(我们在上一步知道,这个指针指向 F1)。

    • Python 复制 这个指针,将 F2 框架的父框架也设置为 F1 [03:32, 05:13]。

  3. 执行 adder 的函数体 k + n

    • 现在,我们在 F2 框架的环境中执行 return k + n [03:58]。

    • 建立环境链:此时的“当前环境”是一个链条:它从 F2 开始,F2 的父是 F1F1 的父是“全局框架”[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]

  1. 创建一个新的“函数对象”(Function Value)。

  2. 设置父指针:将这个函数对象的“父”(parent)指针,设置为 当前 所在的执行框架(current frame)。

  3. 绑定名称:在 当前 框架内,将函数名(例如 adder)绑定到这个新创建的函数对象上。

规则 2:当一个函数被 调用 时 (...(...)) [08:33]

  1. 创建一个新的“本地框架”(Local Frame)。

  2. 复制父指针:查看 被调用 的那个函数对象,读取它的“父”指针(在规则 1 中设置的那个),然后将这个指针 复制 给新创建的本地框架,作为这个新框架的“父” [08:42]。

  3. 绑定参数:在新框架中,将函数的形式参数(例如 k)绑定到调用时传入的实际参数(例如 4)。

规则 3:当查找一个 变量名 时 (例如 kn) [08:56]

  1. 从当前开始:始终从“当前框架”(例如 F2)开始查找。

  2. 查找成功:如果找到了(例如 k),则使用该值 [09:06]。

  3. 查找失败,向上“追溯”:如果

    在当前框架中没有找到(例如 n),则 立即 通过“父”指针跳转到父框架(例如 F1)中去查找 [09:01]。

  4. 重复:如果

    在父框架中仍未找到,则继续跳转到该框架的父框架(例如 Global),依此类推,直到找到该名称或到达全局框架(若全局框架也没有,则抛出 NameError)。

心智模型一:函数是“数据”,而不仅是“动作”

这个例子迫使我们建立一个心智模型:在 Python 中,函数是“一等公民”(First-Class Citizens)。这意味着函数 不仅仅是 一系列指令或一个动作,它更是一个 数据对象(或称“值”,Value)。

  • 就像数字 3 是一个值,字符串 "hello" 是一个值一样,adder 函数也是一个值。

  • 既然是值,它就可以被:

    • 赋值add_three = ...(我们把 adder 赋值给了 add_three)。

    • 传递:可以作为参数传递给其他函数。

    • 返回:可以作为另一个函数的返回值(make_adder返回adder)。

但函数这种“数据”比较特殊,它由两部分组成:1. 它的代码(k + n2. 它的“出生地”指针(指向 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_allprint_sums),演示了这一机制的运作原理。其核心结论是,函数之所以能引用自身,是因为当函数_被调用_时,其名称(例如 print_all)已经在相应的环境帧(例如全局帧)中被绑定到了一个函数对象上 [00:30]。视频进一步展示了如何利用这种自引用,结合高阶函数(返回另一个函数)和闭包(inner function 访问 outer function 的变量)的特性,来创建能够“记忆”和累积状态的函数 [02:00]。

按照主题来梳理

主题一:简单的函数自引用——print_all 示例

视频的第一个例子围绕一个名为 print_all 的函数展开,旨在说明一个函数在其定义内部引用自己的名字是完全可行的 [00:11]。

代码定义与执行

print_all 函数的定义如下:

1
2
3
def print_all(x):
print(x)
return print_all

这个函数接受一个参数 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)

视频中演示了链式调用的过程:

  1. print_all(1):

    • print_all 函数被调用,参数 x 绑定为 1

    • 函数体执行,首先 print(1),于是控制台输出 1 [01:06]。

    • 接着,函数执行 return print_all。它查找 print_all 这个名字,找到了全局帧中的函数对象,并将其返回 [01:13]。

    • 此时,print_all(1) 这个表达式的_值_,就是 print_all 函数对象本身 [01:20]。

  2. ... (3) (即 [print_all 函数对象](3)):

    • 上一步返回的 print_all 函数对象立刻被再次调用,这次的参数是 3 [01:27]。

    • 函数体执行,print(3),控制台输出 3 [01:30]。

    • 函数再次执行 return print_all,返回 print_all 函数对象。

  3. ... (5) (即 [print_all 函数对象](5)):

    • 上一步返回的函数对象又被调用,参数是 5 [01:35]。

    • 函数体执行,print(5),控制台输出 5

    • 函数返回 print_all 函数对象。由于这次返回后没有后续的 (...) 调用,整个表达式执行完毕 [01:35]。

关键点

这个例子虽然看起来像递归,但它并不是传统意义上的递归(即函数_调用_自身导致更深层次的调用栈)。它只是_返回_了对自身的引用 [01:47]。实际的调用次数完全由外部的链式调用 ( ) ( ) ( ) 的数量决定,而不是由函数内部逻辑决定,因此它不会无限运行下去 [01:53]。

主题二:利用闭包和自引用累积状态——print_sums 示例

第二个例子更为精妙,它展示了如何利用环境和作用域的特性,让函数“记住”之前的计算状态。这个函数的目标是打印出“目前为止所有参数的总和” [02:00]。

代码定义与执行

print_sums 函数的定义如下:

1
2
3
4
5
def print_sums(x):
print(x)
def next_sum(y):
return print_sums(x + y)
return next_sum

这个函数做了几件有意思的事:

  1. 它接受一个参数 x,它假定 x 是“目前为止的总和” [02:40]。

  2. 它首先打印这个总和 x

  3. 它在_内部_定义了另一个函数,叫做 next_sum [02:25]。

  4. next_sum 函数接受一个新的数字 y [02:30]。

  5. next_sum 的核心工作是计算 x + y(即“之前的总和”加上“新的数字”),然后_调用_ print_sums 函数,并将这个新的总和 x + y 作为参数传进去 [02:30]。

  6. print_sums 函数最后_返回_的是 next_sum 这个内部函数 [02:40],而不是 print_sums 自己。

执行流程拆解:print_sums(1)(3)(5)

视频通过环境图(Environment Diagram)详细展示了这一过程:

  1. print_sums(1):

    • print_sums 被调用,参数 x 绑定为 1。这被视为“到目前为止的总和” [02:54]。

    • 函数体执行 print(1),控制台输出 1

    • 创建了一个新的内部函数 next_sum

    • 关键点:这个新创建的 next_sum 函数对象(我们称之为 next_sum_A)知道它是在 print_sums(1) 这次调用的环境中创建的。因此,它“记住”了 x = 1。这个 x 存在于它的父帧 (parent frame) F1 中 [03:49]。

    • print_sums 返回了这个 next_sum_A 函数对象 [03:00]。

  2. ... (3) (即 next_sum_A(3)):

    • 上一步返回的 next_sum_A 函数对象被调用,参数 y 绑定为 3 [03:06]。

    • next_sum_A 的函数体开始执行。它需要执行 return print_sums(x + y)

    • 它查找 y,在自己的局部环境中找到 y = 3

    • 它查找 x,在自己的局部环境中找不到,于是它去查找它的父帧 F1(即 print_sums(1) 的执行环境)[03:18]。在 F1 中,它找到了 x = 1

    • 它计算 x + y,即 1 + 3 = 4 [03:18]。

    • 然后它调用 print_sums,并将 4 作为参数传递进去,即 print_sums(4) [03:26]。

  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) 整个表达式的最终值。

  4. ... (5) (即 next_sum_B(5)):

    • 上一步返回的 next_sum_B 函数对象被调用,参数 y 绑定为 5 [04:05]。

    • next_sum_B 的函数体执行 return print_sums(x + y)

    • 它查找 y,在自己的局部环境中找到 y = 5

    • 它查找 x,在自己的局部环境中找不到,于是去查找它的父帧 F3 [04:13]。在 F3 中,它找到了 x = 4

    • 它计算 x + y,即 4 + 5 = 9 [04:19]。

    • 然后它调用 print_sums,并将 9 作为参数传递进去,即 print_sums(9) [04:26]。

  5. print_sums(9) (由 next_sum_B(5) 触发):

    • print_sums 第三次被调用,参数 x 绑定为 9

    • 函数体执行 print(9),控制台输出 9 [04:29]。

    • 它_又_创建了_第三个_内部函数 next_sumnext_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)或“静态作用域”规则之上。这个框架的核心思想是:

  1. 帧 (Frame) 的概念:每次调用一个函数时,Python 都会创建一个新的“环境帧”(Environment Frame)[01:00]。这个帧用于存储该次调用的局部变量(例如函数的参数 xy)。

  2. 父帧 (Parent Frame) 链接:当你在一个函数(Outer Function,如 print_sums)内部定义了另一个函数(Inner Function,如 next_sum)时,这个内部函数(next_sum)在被创建时,会包含一个指向其_定义时_所在环境(即 print_sums 的执行帧)的链接 [03:18, 03:49]。这个链接就是它的“父帧”。

  3. 变量查找规则:当你试图访问一个变量(比如 x)时,Python 的查找顺序是:

    • a. 当前局部帧:首先在当前的局部帧中查找。例如,next_sum 查找 y 时,在自己的局部帧中立刻就找到了 [04:13]。

    • b. 父帧(递归):如果在当前帧中找不到(例如 next_sum 查找 x),它会通过父帧链接,到创建它的那个环境中去查找 [03:18]。如果在那里也找不到,它会继续查找父帧的父帧,以此类推。

    • c. 全局帧:如果一路找到了最顶层,就会查找“全局帧”(Global Frame),print_allprint_sums 的函数定义本身就存储在这里 [01:00]。

    • d. 内建函数:如果全局帧也没有,最后会查找内建函数(如 print)。

print_sums 如何利用这个框架?

print_sums 示例完美地利用了这个框架:

  • 变量 x(代表“当前总和”)被存储在 print_sums 的执行帧中(例如 F1 帧存储了 x=1F3 帧存储了 x=4)[03:59, 04:13]。

  • print_sums 返回的 next_sum 函数对象,由于其父帧链接,“随身携带”了对这个 x 的访问权。

  • 即使 print_sums 的调用已经返回(return next_sum),但因为它创建的 next_sum 函数对象仍然存在(它被返回并准备下一次被调用),所以 print_sums 的执行帧(F1F3)不会被销毁。它会继续存在,因为它需要为 next_sum 提供变量 x [03:18]。

  • 这种“一个函数和它所引用的、来自其父作用域的变量”的组合,就是编程中著名的概念——“闭包” (Closure)。next_sum 和它所引用的 x 共同构成了一个闭包。

通过这个框架,print_sums 巧妙地将“状态”(x 的值)[04:55] 隐藏在了 next_sum 函数的父帧中,实现了在没有外部变量的情况下,跨多次函数调用来“记忆”和“累积”数据。

心智模型:通过“环境图”理解代码执行

这个视频在讲解时(虽然我们只能听到描述)严重依赖一个核心的心智模型:将代码执行过程可视化为“环境图” (Environment Diagrams)。这是一种调试和理解复杂代码(尤其是涉及高阶函数、闭包和递归)的强大思维工具。

建立这个心智模型的步骤:

  1. 全局帧 (Global Frame):想象一个顶层的大框,所有在顶层(文件级别)定义的函数(如 print_all, print_sums)和变量都住在这里。print_sums 这个名字指向一个函数对象 [01:00]。

  2. 函数调用 = 创建新帧:每当一个函数被_调用_((...)),就在它下面(或者旁边)画一个新的框,这就是它的局部执行帧 [01:06]。例如,print_sums(1) 创建了 F1 帧。

  3. 参数与局部变量:在新帧中,为函数的所有参数(如 x=1)和在函数体内定义的任何新局部变量创建条目 [01:06]。

  4. 父帧指针:如果一个函数(如 print_sums)在执行时创建了新的函数(如 next_sum),那么这个新创建的函数对象(next_sum_A)内部必须有一个“指针”,指向创建它的那个帧(F1)[03:18]。这是理解闭包的关键

  5. 跟踪返回值:当一个函数 return 时,它会返回一个值。如果这个值是一个函数(如 next_sum_A),那么这个函数对象就被传递出去 [01:13, 03:00]。

  6. 链式调用:当执行 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(它记住了 F3F3 里有 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
    2
    def 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

    1. 第一次调用:我们执行 make_adder(2) [00:38]。这个调用的返回值是一个 函数,具体来说,是那个 lambda k: 2 + k 函数。这个新函数在功能上等同于“加 2 器” [00:47]。

    2. 第二次调用:我们拿到上一步返回的“加 2 器”函数,然后用参数 3 来调用它 [00:47]。

    3. 最终结果:内部函数执行 2 + 3,返回 5 [00:53]。

    整个过程可以用一个表达式来表示 [00:38]:make_adder(2)(3),结果为 5

  • 对比:普通的 add 函数

    这种“分两步”调用的方式,与我们更熟悉的、一次性接收所有参数的普通 add (加法) 函数形成了对比 01:01

    一个普通的 add 函数会这样定义:

    1
    2
    def add(x, y):
    return x + y

    它直接接收两个参数 xy,并立即返回它们的和 [01:01]。

  • make_adder 和 add 之间的关系

    make_adder 和 add 之间存在一种普遍的关系 01:07

    • make_adder 代表了这样一类函数:它只接收 一个 参数,然后返回 另一个函数 [01:15]。

    • add 代表了另一类函数:它接收 多个 参数,并直接返回 最终的答案 [01:15]。

    柯里化 (Currying) 正是用来描述和实现这种从 add (多参数) 到 make_adder (单参数、返回函数) 的转换 [01:22]。

主题二:通用的柯里化函数 curry2

视频接着展示了如何将这种 addmake_adder 的转换关系,用代码“泛化” (generalize),使其适用于任何接收两个参数的函数。这个通用的转换函数被命名为 curry2 [01:29](这里的 2 特指它所处理的函数 f 是接收两个参数的)[01:29]。

  • curry2 的嵌套函数实现

    curry2 函数的目标是:接收一个 双参数函数 f (例如 add) 作为输入,然后返回一个 单参数函数 g (类似 make_adder) 作为输出。

    1
    2
    3
    4
    5
    6
    def curry2(f):
    def g(x):
    def h(y):
    return f(x, y)
    return h
    return g

    让我们来分解这个实现 [01:42]:

    1. curry2(f) 接收一个函数 f(比如 add)。

    2. curry2 内部,定义了函数 gg 接收一个参数 x (这对应了 make_adder 中的 n) [01:42]。

    3. g 内部,又定义了函数 hh 接收一个参数 y (这对应了 make_adder 中的 k) [01:52]。

    4. 函数 h 的作用是执行最终的计算:它调用最初传入 curry2 的函数 f,并把 先后 收集到的两个参数 xy 同时传给它,即 f(x, y) [01:52]。

    5. g 函数执行时,返回 h 函数 [02:01]。

    6. curry2 函数执行时,返回 g 函数 [02:01]。

  • 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]:

    1. add_three = m(3) [02:43]。m(3) 调用返回了内部的 h 函数,此时 x 被固定为 3add_three 现在是一个等待接收 y 的函数。

    2. add_three(4) 02:53。这会执行 h(4),最终计算 f(3, 4) (即 add(3, 4)),返回 7。

      (注:视频中演示的是 add_three(5),但字幕和口述在 02:53 附近似乎有些不匹配,但调用的逻辑是清晰的。)

  • curry2 的 Lambda 表达式实现

    视频指出,curry2 这种层层嵌套返回函数的结构,非常适合用 lambda 表达式来书写,可以压缩成一行代码 02:53

    1
    curry2 = lambda f: (lambda x: (lambda y: f(x, y)))

    这个 lambda 表达式在逻辑上与前面 def 嵌套的版本是等价的 [03:01]:

    • 它是一个函数,接收 f

    • 返回一个函数,该函数接收 x [03:01]。

    • 再返回一个函数,该函数接收 y [03:01]。

    • 最终返回 f(x, y) 的调用结果 [03:12]。

    使用这个 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

    • “柯里化” (Currying) 这个名字,并非以其最初的发现者命名的。

    • 它最早是由 Moses Schönfinkel (摩西·舍恩芬克) 发现的 [03:48]。因此,有些人认为这个技术应该被称作 “Schönfinkeling” [03:56]。

    • 之后,Haskell Curry (哈斯凯尔·柯里) 重新发现了这一技术,并使其变得更广为人知 [03:48]。最终,这个概念便以 Haskell Curry 的名字被命名。

框架 & 心智模型 (Framework & Mindset)

从本视频中,我们可以抽象出一个核心的编程心智模型:“函数柯里化” (Function Currying) 作为一种参数“分期” (Staged) 收集的框架

这个框架的核心思想是:不要一次性地接收所有你需要的参数,而是将一个需要 N 个参数的计算过程,拆解为 N 个“步骤”,每个步骤只接收一个参数,并返回一个新的“步骤”(即一个新函数),直到收集齐所有参数,才执行最终的计算。

我们可以将这个心智模型分解为以下几个关键步骤和认知转变:

  • 1. 转变视角:从“多参数函数”到“单参数函数链”

    • 传统心智模型 (如 add(x, y)):函数是一个“一步到位”的计算器。你必须在 调用时 就提供所有的“原材料”(参数 xy)。计算器立即启动,并给出最终答案 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=3y=4)以及最初的指令(f = add),于是它执行最终的计算 f(x, y) [01:52, 03:12]。

    1. 框架的实现:嵌套与闭包 (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) 了 xf 的值。这就是为什么 h 在未来某个时刻被调用时,依然能正确地使用 xf 来执行 f(x, y)

    1. 抽象的价值:函数的“部分应用” (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 等)。

    1. 统一接口:一切皆为单参数函数

    柯里化的终极心态是将所有函数都视为“单参数函数”。一个接收两个参数的函数 f(x, y),在柯里化模型中被视为一个接收 x 的函数,它返回一个接收 y 的函数。

    • 这种统一性(所有函数都只接收一个参数)在某些编程语言(尤其是函数式语言,如 Haskell,其名称即来源于 Haskell Curry [03:48])中具有重要的理论和实践意义,它简化了函数的组合和变换。

    • 如视频所定义的 [03:32],这是一个将“多参数函数”转换为“单参数高阶函数”[03:32]的通用模式,它返回的函数再接收“剩余的”参数 [03:40]。这个“剩余的”也可以是多个,但在严格的柯里化定义中,它总是返回 另一个 只接收 一个 参数的函数,直到所有参数都被消耗完毕。

Q&A

Overview

视频的核心论题是深入辨析 Python 中关于函数执行、作用域和环境图 (Environment Diagrams) 的关键且微妙的机制。它通过一系列学生提出的、关于高阶函数、嵌套函数、柯里化 (Currying) 和 Lambda 表达式的棘手案例,得出了一个核心结论:只有通过环境图,一步一步、精确地追踪帧 (frame) 的创建、变量的绑定以及父指针 (parent pointer) 的指向,才能真正理解代码的最终行为。仅仅依靠直觉或“读”代码,很可能会在面对这些复杂情况时得出错误结论。

按照主题来梳理

核心探究:print_sums 函数与环境图

这是视频中花费时间最长、最核心的案例,它完美地展示了嵌套函数、作用域和返回函数如何协同工作。

问题的起点:print_sums 函数

我们讨论的函数如下(为了清晰,我重构了视频中的代码):

1
2
3
4
5
6
7
8
9
10
11
def print_sums(n):
"""
返回一个新的函数 f(k),
这个新函数会打印 n+k,并返回一个新的 print_sums(n+k) 函数
"""
print(n) # 打印当前的 n

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

return f

场景一:print_sums(1)

当我们执行 print_sums(1) 时 [00:32]:

  1. 创建一个 print_sums 的帧,我们称之为 f1

  2. f1 中,n 被绑定到 1

  3. 执行 print(n)打印出 1

  4. 定义了内部函数 f(k)。关键在于,这个函数对象在“定义时”就获得了一个指向其“出生地” (f1) 的父指针

  5. print_sums 返回了这个 f 函数对象。

场景二:g = print_sums(1)h = g(3)

这部分是精髓所在 [01:40]:

  1. g = print_sums(1):执行过程同场景一。g 现在在全局帧 (Global Frame) 中被绑定到了那个内部 f 函数对象(其父指针指向 f1,而 f1 中有 n=1)。

  2. h = g(3):这是在_调用_ g,也就是在调用那个内部的 f 函数。

    • 创建一个 f 的帧,我们称之为 f2

    • f2 的父指针被设置为 f1(由 g 所带的函数对象决定)。

    • f2 中,参数 k 被绑定到 3

    • 执行 f 的函数体:return print_sums(n + k)

    • 这是一个_调用表达式_ [07:06],Python 必须先计算 n + k 的值。

    • 查找 n:在 f2 中找不到 n,于是顺着父指针去 f1 中找。找到了!n1 [02:49]。

    • 查找 k:在 f2 中找到了 kk3

    • 计算结果为 1 + 3 = 4

    • 执行 return print_sums(4)

  3. 调用 print_sums(4)

    • 创建一个 print_sums 的帧,我们称之为 f3

    • f3 中,n 被绑定到 4

    • 执行 print(n)打印出 4 [03:19]。

    • 又定义了一个_新的_内部函数 f(k) [03:24]。这个_新_ f 对象的父指针指向它_自己_的“出生地”,即 f3f3 中有 n=4)。

    • print_sums(4) 返回了这个_新_的 f 函数对象。

  4. h = ...h 在全局帧中被绑定到了这个_新_的 f 函数对象(其父指针指向 f3f3 中有 n=4)。

场景三:w = h(5)

  1. w = h(5):这是在_调用_ h,也就是在调用场景二最后返回的那个_新_ f 函数。

    • 创建一个 f 的帧,我们称之为 f4

    • f4 的父指针被设置为 f3(由 h 所带的函数对象决定)。

    • f4 中,参数 k 被绑定到 5

    • 执行 f 的函数体:return print_sums(n + k)

    • 查找 n:在 f4 中找不到 n,顺着父指针去 f3 中找。找到了!n4 [05:24]。

    • 查找 k:在 f4 中找到了 kk5

    • 计算结果为 4 + 5 = 9

    • 执行 return print_sums(9)

  2. 调用 print_sums(9)

    • 创建一个 print_sums 的帧,f5n 绑定到 9

    • 执行 print(n)打印出 9 [05:31]。

    • 定义了一个_更新_的 f 函数(父指针指向 f5)。

    • 返回了这个_更新_的 f 函数,并将其绑定到 w

结论print_sums(1)(3)(5) 这一系列调用的输出是 149。这种链式调用 [05:38] 和上面分步赋值 (g, h, w) 的执行过程是完全一样的,唯一的区别是链式调用没有在全局帧中保留 gh 这两个中间名字 [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) 是一个接收两个参数的函数,计算 xy 次方。

  • curry2 是一个辅助函数,它接收 pow,并返回一个新的函数 h(x)

  • 调用 h(x)(例如 h(2))会返回_另一个_新函数 g(y)

  • 这个 g(y) 函数“记住”了 x=2。当你调用 g(5) 时,它最终执行 pow(2, 5),得到 32

为什么柯里化有用?

  1. 参数的“分期交付” [13:18]:

    • 这是最有用的场景。在程序中,你可能很早就知道第一个参数(例如 x=2),但第二个参数(例如 y=5)要过很久才能确定。

    • 柯里化允许你“中途”评估函数 [14:02],把 pow2 “捆绑” [12:47] 在一起,得到一个新函数 two_to_the = curry2(pow)(2)

    • 这个 two_to_the 函数(即上面的 g(y))可以被传递到程序的其他地方,直到你获得 5 时再调用它:two_to_the(5)

    • 视频中提到了 Hog(猪)项目 [15:03]:你事先知道一个策略的参数(比如 cutoff 值),但不知道游戏中的实际分数。你可以用柯里化“固定”cutoff 值,生成一个只等待分数的“特化”策略函数。

  2. 代码组织与复用

    • 当你使用别人写的库函数时,该函数可能有很多(比如 4 个)参数,但你只想改变后两个 [31:20]。

    • 你可以使用柯里化或类似技术(如 lambdafunctools.partial),“固定”前两个参数,创造一个“特化”版本的新函数 [31:37]。这使你的代码更简洁,可读性更强(比如 two_to_the 这个名字就比每次都写 pow(2, ...) 要清晰) [29:51]。

  3. 潜在的计算优势 [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 的执行步骤是固定的:

  1. 评估操作符(Operator),即 f

  2. 从左到右评估所有参数(Argument expressions),即 arg1,然后 arg2,…

  3. 等到_所有_参数的值都计算完毕后,才真正开始_调用_函数 f

    • 创建一个 f 的新帧。

    • 将第 2 步得到的_值_绑定到 f 的形参。

    • 开始执行 f 的函数体。

案例一:compose1(square, make_adder(2)) [16:05]

为什么 make_adder 的帧会先于 compose1 的帧出现?

  1. Python 准备调用 compose1

  2. 评估第一个参数 square:它是一个函数对象。

  3. 评估第二个参数 make_adder(2)注意!这本身也是一个调用表达式!

  4. Python 必须_暂停_对 compose1 的评估,转而去执行 make_adder(2) [17:19]。

  5. 此时make_adder 的帧被创建,n 绑定到 2,它返回一个 adder 函数。

  6. 现在,compose1 的第二个参数的值(那个 adder 函数)被计算出来了。

  7. 直到这时 [17:08],Python 才集齐了调用 compose1 所需的所有参数值。

  8. 现在compose1 的帧才被创建,开始执行它的函数体。

案例二:Lambda 表达式——创建函数,而非调用 [32:30]

问题:为什么 coffee(lambda x: x) 这一行没有 lambda 帧?

  1. lambda x: x 是一个_表达式_。当 Python 评估它时,它所做的_仅仅_是创建一个函数对象 [33:09]。它_不会_调用这个函数。

  2. coffee(...) 是一个_调用_。

  3. Python 将第 1 步创建的那个_函数对象_作为参数传递给 coffee

  4. coffee 函数被调用,创建了 coffee 帧。在这个帧里,参数(比如叫 grounds)被绑定到了那个 lambda 函数对象 [34:13]。

  5. 自始至终,lambda 函数本身都没有被调用(即没有 () 跟在它后面)。

  6. 只有coffee 的函数体内部_执行_了 grounds() 这样的调用时,才会为这个 lambda 函数创建它自己的帧 [34:18]。

案例三:“冒犯性”的 Lambda 错误 [22:59]

这个例子 higher_order_lambda(2)(g)(其中 higher_order_lambdalambda f: lambda x: f(x))完美地展示了这个顺序:

  1. higher_order_lambda(2):调用_外层_ lambdaf 被绑定到_数字_ 2 [23:44]。

  2. 此调用返回_内层_ lambda,但 f 已经被 2 替换了。返回的是一个等价于 lambda x: 2(x) 的新函数。

  3. ... (g):用参数 g(一个函数)去_调用_第 2 步返回的那个新函数。

  4. x 被绑定到 g

  5. 执行函数体 2(x),这变成了 2(g)

  6. 程序崩溃 [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 帧(如 f1f3),因此在其中定义的_内部_ f 函数也会是_不同_的函数对象,它们各自拥有指向_不同_父帧(f1f3…)的父指针 [03:24]。这就是 print_sums 能“累加”的秘密。

  • 规则 1:变量查找 (Lookup Rule)

    • 当代码需要一个变量(如 n)的值时,Python 严格按以下顺序查找:
    1. 在_当前_帧中查找。

    2. 如果找不到,则顺着当前帧的_父指针_,跳转到其父帧中查找 [02:49]。

    3. 如果还找不到,继续顺着父指针向上查找,直到全局帧。

    4. 如果在全局帧中也找不到,则抛出 NameError

    • 这个规则解释了为什么在 g(3)f2 帧中能找到 n=1(在 f1 中),以及在 h(5)f4 帧中能找到 n=4(在 f3 中)。
  • 规则 2:赋值 (Assignment Rule)

    • 赋值语句(如 me = 2)_只_在_当前_帧中操作。它在当前帧中绑定(或重绑定)一个名字 [10:18]。

    • 这解释了为什么 me = 2 改变了 f1 帧,并且所有_未来_(或_现在_)指向 f1 帧的查找都会看到这个新值 [09:50]。

心智模型:严格区分“定义时”与“调用时”

这是理解高阶函数和 Lambda 表达式的必备心智模型。

  • “定义时” (Definition Time)

    • 触发:当 Python 解释器读到 def 语句或 lambda 表达式时。

    • 发生什么

      1. 创建一个函数对象 [33:09]。

      2. 这个对象打包了:(a) 函数体的代码;(b) 一个指向_当前_帧的父指针。

    • 不发生什么

      • 函数体内的代码_完全不_执行 [09:03]。

      • _不_创建新帧。

      • def f(): return y 时,y 是否定义了无关紧要。

    • 例子lambda x: x 只是创造了一个东西,coffee(lambda x: x) 只是把这个东西传了进去。

  • “调用时” (Call Time)

    • 触发:当 Python 解释器看到函数名_后面跟着括号_ () 时 [34:49]。

    • 发生什么:(按照严格顺序)

      1. 评估括号内的所有_参数表达式_(这可能触发_其他_函数的“调用时”)[16:24]。

      2. 创建一个新帧

      3. 设置新帧的父指针(从函数对象中获取)。

      4. 将第 1 步得到的参数_值_绑定到新帧中的形参名。

      5. 开始执行函数体中的代码 [17:08]。

    • 例子make_adder(2) 是“调用时”,创建 make_adder 帧。g(3) 是“调用时”,创建 ff2 帧。2(g) 试图以 2 为函数进行“调用时”,因此失败 [25:12]。

总结:视频中的所有“难题”都不是魔术。它们只是“环境图”和“定义/调用”这两个简单、机械的规则组合在一起时,产生的必然结果。掌握了这两个模型,就能像解释器一样思考,从而看清 Python 的“内功”。