Defining Functions

Function definition is a more powerful means of abstraction: binds names to expressions

format

1
2
def <name>(<formal parameters>):
return <return expression>

Q&A

Overview

这篇“文章”源于一场关于编程入门课程(61A)第二讲的问答(Q&A)。其核心论题与结论是:为了精确理解和预测 Python 代码(尤其是包含函数调用的复杂代码)的行为,你必须掌握一套机械化的心智模型,即“环境图” (Environment Diagrams)。这场讨论的核心在于辨析两个关键概念:“Frame”(帧),即一个独立的、用于追踪特定作用域内变量名绑定的“盒子”;以及 “Environment”(环境),即一个由当前帧及其所有父帧组成的、决定了变量查找顺序的“帧序列”。掌握这一模型,是从“猜测”代码意图转向“精确”理解代码执行的根本转变。

按照主题来梳理

主题一:“Frame”与“Scope”:解密 Python 的变量追踪机制

在 Python 编程中,最核心的概念之一是如何管理和追踪在不同时间、不同地点定义的变量。视频的第一个问题就切中了要害:“什么是 Frame(帧)?” [00:07]

Frame(帧)可以被理解为一块内存区域,或者一个“盒子”,Python 解释器用它来“记住”在特定上下文中,每个变量名 (name) 对应着哪个值 (value)。例如,x = 12 这行代码,就是在当前的 Frame 中记录下“x”这个名字指向“12”这个值。

Frame 的真正威力在于它如何处理**“上下文” (context)**。在不同的 Frame 中,同一个名字可以代表完全不同的东西。这是编程语言实现模块化和避免混乱的关键。视频中通过一个经典例子阐明了这一点 [01:02]:

  1. 全局 Frame (Global Frame)

    • 首先,代码在“顶层”(不在任何函数内部)执行 x = 12。这会在一个我们称之为“全局 Frame”的“大盒子”里,创建了一个绑定:x 指向 12

    • 接着,代码定义了一个函数 def f(y): ...。注意,定义 (define) 函数本身并不会立即创建新的 Frame,它只是创建了一个“函数对象”,并将其绑定到全局 Frame 中的名字 f 上。

  2. 局部 Frame (Local Frame)

    • 关键时刻在于调用 (call) 函数:f(7) [01:35]。

    • 在函数被 调用 的那一刻,Python 解释器会创建一个全新的、临时的、局部的 Frame,我们称之为 f 的“局部 Frame”。

    • 在这个新的 f 局部 Frame 中:

      • 首先,它将函数的参数 y 与传入的值 7 绑定。

      • 然后,它执行函数体内的代码 x = 3。这一步至关重要:这个赋值操作是在当前的、局部的 Frame 中创建了一个新的绑定 x 指向 3

      • 它完全不会触碰或修改全局 Frame 里的那个 x = 12 [01:47]

    • 因此,当函数体内部执行 print(x) 时 [01:53],解释器会首先在当前(局部)Frame 寻找 x。它找到了 x = 3,于是打印出 3

  3. Frame 的销毁与返回

    • 当函数 f 执行完毕后,它所对应的那个局部 Frame 就会被销毁(或至少是“失效”了)。

    • 执行流程返回到它被调用的地方(即全局 Frame 中)[02:02]。

    • 此时,代码执行下一行 print(x)。解释器在当前(全局)Frame 中寻找 x。它找到了 x = 12,于是打印出 12

这个机制就是“**Scope”(作用域)**的核心 [03:07]。全局 Frame 拥有“全局作用域”,而每个函数调用创建的局部 Frame 拥有“局部作用域”。

为什么这个机制如此重要?

想象一下,如果不存在局部 Frame,所有变量都共享同一个全局 Frame [02:37]。

  • 变量名枯竭:程序员们都喜欢用 x, y, i, j, k 这样的简单变量名 [02:42]。如果它们都是全局的,你很快就会“用完”安全的名字。

  • 代码冲突:当你使用一个大型库,或者与他人协作时,你根本无法知道别人是不是也用了 x 这个变量名 [02:57]。如果你们都修改同一个全局的 x,程序将立刻崩溃。

  • Frame 机制(即局部作用域)的美妙之处在于:它允许你“封装” (encapsulate) 你的逻辑。当你编写函数 f 时,你只需要关心 f 的局部 Frame 内部发生了什么 [04:15]。你(几乎)不需要担心外部世界,外部世界也不需要担心你的 x 会“污染”它们。

变量是如何被“查找”的?(Lookup Rule)

那么,当函数 f 内部既有自己的 x=3,又想访问全局的 z=7 时,Python 是如何知道该用哪个的?[04:52]

答案在于一个严格的查找顺序(这个顺序在“主题二”的“Environment”中会详细展开):

  1. Python 永远首先在当前局部 Frame 中查找变量名 [06:26]。

  2. 如果找到了(比如 x),它就立刻使用这个值(3),并停止查找。这就是为什么本地的 x 会“遮蔽” (shadow)”全局的 x [07:06]。

  3. 如果没找到(比如 z),它才会“向上”一级,去父级 Frame(在这里是全局 Frame)中查找。

  4. 如果在全局 Frame 中找到了 zz=7),它就使用那个值。

  5. 如果一路找到全局 Frame 都没找到,Python 才会抛出 NameError(名字未定义)[06:45]。

我如何与全局 Frame 交互?

  • 读取 (Read):你永远可以读取全局变量(只要它没被局部变量遮蔽)。

  • 使用 (Use):如果你想使用全局 x 的值(12)来进行本地计算,而不是修改它,你可以这样做:a = x [07:23]。这会查找全局 x (12),然后在本地 Frame 中创建一个新变量 a,并将其赋值为 12。此后对 a 的任何操作(如 b = a + 1)都只发生在局部,与全局 x 无关。

  • 传递 (Pass Out):你不能从全局 Frame “伸”一个手到局部 Frame 里去“抓”一个变量 [28:25]。当函数结束,局部 Frame 消失,x=3 也就消失了。从局部 Frame 向全局 Frame 传递数据的唯一(或者说“正确”)的方式是 return 语句 [29:05]。你 return x,然后在全局 Frame 中 global_x = f(7),这样就把局部的值(经过计算的)安全地传递回了全局 Frame。

Frame 的生命周期

  • def f(): ... (定义):创建 Frame。只创建函数对象 [16:19]。

  • f() (调用):创建一个新的局部 Frame。

  • f() 再次调用:又创建一个全新的局部 Frame [35:31]。每次调用都是一次新生,都有一个干净的、独立的 Frame。


主题二:“Environment”:解开“帧”的序列之谜

在深入理解了“Frame”(帧)之后,视频引入了一个更精确、更强大的概念:“Environment”(环境) [13:38]。

这两个词经常被混用,但它们在技术上是不同的,理解其差异是掌握 Python 作用域的关键:

  • Frame (帧):是一个单一的数据结构,一个“盒子”,存储着“名字 -> 值”的绑定(例如“Global Frame”或“f1 Frame”)[13:53]。

  • Environment (环境):是 Python 在任何特定时刻进行变量查找时所依据的**“帧的序列” (a sequence of frames)** [14:02]。

换句话说,当你处于某个函数(比如 square)的执行体内部时:

  • 你的当前 Framef1 (square)

  • 但你的当前 Environment 是一个有序列表[f1 (square) Frame, Global Frame] [14:08]。

为什么“序列”和“顺序”如此重要?

因为这个“Environment”(环境)列表,精确地定义了上一主题中提到的“变量查找顺序”。当你查找一个变量(如 mol)时,Python 会:

  1. 查看 Environment 列表中的第一个 Frame(即 f1 (square))。mol 在这里吗?不在。

  2. 移动到 Environment 列表中的下一个 Frame(即 Global Frame)。mol 在这里吗?在。好,就用它。

视频给出了一个“你永远不该这么写” [15:22] 但却极具启发性的例子,来展示这个顺序的威力。

假设有如下(糟糕的)代码:

  • 全局 Frame 中有:mol = <一个乘法函数>, square = <一个平方函数>

  • square 函数的定义是 def square(square): return mol(square, square)

  • 我们调用 square(4)

执行分析

  1. 调用 square(4)

    • 创建一个新的局部 Frame,名为 f1 (square)

    • 在此 f1 Frame 中,将参数名 square 绑定到传入的值 4

    • 此时,当前 Environment 变为 [f1 (square) Frame, Global Frame]

  2. 执行 return mol(square, square)

    • Python 需要解析这个表达式。

    • 查找 mol

      • f1 中查找 mol -> 未找到。

      • Global 中查找 mol -> 找到 <乘法函数>

    • 查找第一个 square(作为 mol 的参数):

      • f1 中查找 square -> 找到了! 它被绑定为 4 [14:51]。

      • 查找停止。Python 永远不会 再去 Global Frame 中寻找 square 函数。本地的 square(值为4)“遮蔽” (shadows) 了全局的 square(那个函数)。

    • 查找第二个 square

      • 同样,在 f1 中查找 square -> 找到 4
    • 执行调用:Python 最终执行的是 mol(4, 4)

  3. 返回mol(4, 4) 返回 16square(4) 最终返回 16 [15:22]。

这个(令人费解的)结果 16,而不是一个错误,完美地证明了 Environment 是一个有序的帧序列。变量查找会立即在它找到的第一个匹配项处停止。

“创建新 Frame” vs “创建新 Environment”

有学生提问:这两个说法有什么区别吗?[26:43]

答案是:它们是同一事件的两个方面 [27:02]。

  • 当你调用一个函数时,你创建了一个新的局部 Frame

  • 这个新 Frame 被放置在“环境”序列的最前端,从而形成了一个新的(当前)Environment

  • [新创建的 Frame, 它的父 Frame, ...]

  • 所以,“创建一个新 Frame”和“拥有一个新的(当前)Environment”是同时发生的。

函数调用栈 (Call Stack)

如果一个函数 f 调用了另一个函数 g,会发生什么?[11:22] 这是否意味着同时存在多个局部 Frame?

是的。这揭示了 Environment 的嵌套结构,通常被称为“调用栈”:

  1. 调用 f(7)。创建 f 的 Frame (Frame F)。当前 Environment 是 [Frame F, Global]

  2. f 在执行中调用 g(8) [12:18]。

  3. 暂停 f 的执行

  4. 调用 g(8)。创建 g 的 Frame (Frame G)。

  5. Frame G 成为“当前 Frame”。当前 Environment 变为 [Frame G, Global]。(注意:Frame F 仍然存在于内存中,它在“等待” g 返回)。

  6. g 执行完毕,return 11 [12:44]。

  7. Frame G 被销毁。

  8. 执行权返回Frame F [12:56]。

  9. Frame F 重新成为“当前 Frame”。当前 Environment 变回 [Frame F, Global]

  10. f 拿到 g 返回的 11,继续执行(例如 11 + 2),然后 return 13

  11. Frame F 被销毁。

  12. 执行权返回 Global

所以在任何一个瞬间,只有一个“当前” Frame,但可能有一整个“链”或“栈”的 Frame 在等待返回。


主题三:函数的求值、副作用与返回值

除了“在哪里”存储变量(Frames)之外,另一个关键问题是“在何时”以及“如何”计算它们。

规则一:多重赋值的“右侧优先”原则

一个经典的 Python 陷阱:a=1, b=2,那么 a, b = b, a + b 执行后,a 和 b 是多少?[18:58]

  • 直觉(错误)的思路:a 先变成了 b(即 2),然后 b 变成了 a + b(即 2 + 2 = 4)。

  • 正确的规则 [19:27]:Python 必须在你开始改变任何左侧变量之前完全地、彻底地计算完右侧的所有表达式

    1. 评估 (Evaluate) 右侧

      • b 的值是?2

      • a + b 的值是?1 + 2 = 3

    2. 准备就绪:Python 现在在内存中准备好了两个值:23

    3. 赋值 (Assign) 左侧 [19:56]:

      • 将第一个值 2 赋给第一个名字 a

      • 将第二个值 3 赋给第二个名字 b

    4. 结果a2b3

  • 这是一条你必须记忆的规则 [19:22]。它适用于所有赋值语句,包括单一赋值(b = a + b 也会先计算完 a+b 再更新 b)。

规则二:函数调用的“参数优先”原则

当你调用一个函数,比如 print(2 + 3, a) 时,Python 是怎么做的?[20:23]

  • print 函数并不知道你喂给它的是 2 + 3

  • Python 的规则是:在调用函数(如 printmin之前,它必须首先评估完所有的参数表达式 [20:54]。

    1. 评估第一个参数:2 + 3 -> 结果是 5

    2. 评估第二个参数:a(假设 a12) -> 结果是 12

    3. 调用函数:现在 Python 准备好了所有的值,它调用 print(5, 12)

  • print 函数本身只接收到了 512,它对这些值是如何来的(是字面量、变量还是复杂计算)一无所知 [21:19]。

print(副作用) vs min(返回值)

这引出了一个关于函数“目的”的核心区别。

  • min(5, 12)

    • 它的工作是计算一个值。

    • 返回 (return) 这个值:5

    • 它没有“副作用” (Side Effect),即它不会在屏幕上打印任何东西 [21:57]。

  • print(5, 12)

    • 它的工作是产生“副作用”:在屏幕上显示 5 12 [21:31]。

    • 它也返回一个值吗?是的,所有函数都必须返回一个值。

    • 如果你不显式地 return 任何东西,Python 会默认隐式地返回一个特殊值:None [22:42]。

为什么这个区别至关重要?

  • 因为 min 返回一个有用的值 (5),所以你可以在其他表达式中“组合” (compose) 它

    • x = min(5, 12) + 7

    • 这会变成 x = 5 + 7x 最终等于 12 [22:38]。

  • 因为 print 返回 None,所以你不能用它来组合计算。

    • x = print(5, 12) + 7

    • 这会先执行 print(5, 12)(在屏幕上打印 5 12),然后返回 None

    • 表达式变成 x = None + 7

    • 这会导致一个错误,因为你不能把 None 和数字相加 [22:04]。

纯函数 (Pure Function) 与非纯函数 (Non-Pure Function)

这个区别引出了“纯度”的概念 [29:50]。

  • 纯函数:像 minabs。它的唯一工作就是根据输入计算并返回一个值。它没有副作用。给定相同的输入,它永远返回相同的输出。这是最理想、最容易测试和推理的函数 [30:52]。

  • 非纯函数:像 print。它不仅仅是返回一个值,它还做了其他事情(即副作用)[31:42]。

    • print 的副作用是与屏幕交互。

    • 其他副作用包括:与互联网通信、修改打印机状态、或者(在课程后期会学到)修改一个在函数之外的变量或对象 [30:26]。

  • 一个非纯函数是否可以返回 None 之外的值?可以 [31:11]。

    • 例如:def f(x): print(x); return 2

    • 这个函数是“非纯的”(因为它打印),但它同时也返回了一个有用的值 2


主题四:REPL (交互式解释器) 的特殊行为

许多初学者会注意到一个“奇怪”的现象:

  • 在 Python 交互式解释器(那个 >>> 提示符)中,你输入 24,然后按回车,它会显示 24

  • 但如果你创建一个 .py 文件,内容只有一行 24,然后运行这个文件,它什么也不显示 [24:47]。

为什么会这样?

  • >>> 解释器是一个特殊的工具,它不是 Python 程序运行的“标准”方式。

  • 它是一个 REPLRead (读取), Eval (求值), Print (打印), Loop (循环) [24:12]。

  • 它的设计初衷是为了方便 [24:04]。

  • 它的工作流程是:

    1. ® 读取你输入的整行表达式(例如 2 + 3 + 4)。

    2. (E) 完整地“求值”该表达式,得到一个最终结果(9)。

    3. § 自动地“打印”这个最终结果 [24:27]。

    4. (L) 循环,等待你的下一次输入。

  • 这就是关键:§ 步骤是 REPL自动为你做的。

  • 相比之下,当 Python 运行一个 .py 文件时,它没有第 § 步。它只是 ® 读取文件和 (E) 求值(执行)文件。如果你想在屏幕上看到任何东西,你必须显式地使用 print() 函数。

REPL 自动打印的两个限制

  1. 它只打印最终结果,不打印中间步骤 [25:23]。

    • 如果你输入 f(3) + 1(假设 f(3) 返回 5),REPL会显示最终的 6

    • 不会显示 f(3) 内部计算的中间值,也不会显示 f(3) 返回的 5

    • 如果你想看中间步骤,你必须在你的函数 f 内部显式地使用 print() [26:12]。

  2. 它不打印 None [24:36]。

    • 这是一个特殊的便利规则。如果你运行一个返回 None 的表达式(比如你只输入 print(5)),REPL 会执行 print(5)(显示 5),然后 print 函数返回 None

    • REPL 拿到了 None,但它选择不打印 None 这个词,因为它会显得很杂乱。

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

框架一:“环境图” (Environment Diagram) 作为核心心智模型

视频中所有问答的核心,都在试图构建一个单一的、强大的心智模型:环境图 (Environment Diagram)

这个框架之所以重要,是因为它提供了一个机械化的、可重复的、100% 准确的流程,用于替代人类的“直觉” [08:58]。当你面对复杂的、嵌套的函数调用时,你的直觉会失效。你不能“瞪着” (staring at) 代码来猜出结果;你必须像计算机一样“执行” (execute) 它。环境图就是你用来模拟执行的草稿纸。

环境图框架的执行步骤:

  1. 初始化 (Initialization)

    • 绘制第一个“盒子”:Global Frame (全局帧)

    • 将所有内置函数(如 min, abs)和导入的函数(如 mol)作为名称添加到此帧中,它们指向“对象区”中的函数对象 [33:31]。

  2. 逐行执行代码(Global Frame)

    • 遇到赋值语句 (x = 12)

      • 在 Global Frame 中,写入 x

      • 12(作为一个原始值)直接写入 x 旁边 [33:48]。

    • 遇到函数定义 (def f(y): ...) [16:46]:

      • 这是一个单一步骤

      • 在“对象区”创建一个“函数对象”。

      • 这个对象内部包含两样东西:函数的代码(...)和指向其定义时的 Frame 的指针(即“Parent: Global”)[23:21]。

      • 在 Global Frame 中,写入 f,并画一个箭头,指向这个新创建的函数对象。

      • 注意 绝对不要在此时创建新的 Frame。

  3. 遇到函数调用 (f(7))

    • 这是最关键的步骤。

    • 步骤 3a (创建 Frame):在 Global Frame 下方绘制一个全新的“盒子”:Local Frame (局部帧)(例如,命名为 f1)。

    • 步骤 3b (链接 Parent):查看 f 的函数对象,它的 Parent 是谁?是 Global。于是在 f1 Frame 的右上角,写下 “Parent: Global”。

    • 步骤 3c (绑定参数):在 f1 Frame 内部,写入函数的参数名(y),并将其绑定到调用时传入的值(7)。

    • 步骤 3d (切换上下文):现在,你的“当前 Frame”是 f1。你的“当前 Environment”是 [f1, Global]

  4. 在新的 Frame 中执行函数体

    • 遇到赋值语句 (x = 3)

      • 当前 Frame(即 f1)中,写入 x 并绑定值 3
    • 遇到查找变量 (print(x))

      • 执行“变量查找算法” (Lookup Rule)

        1. 当前 Frame (f1) 中查找 x

        2. 找到了 (x=3)。停止查找,使用这个值。

    • 遇到查找另一个变量 (print(z))

      • 执行“变量查找算法”

        1. 当前 Frame (f1) 中查找 z

        2. 未找到

        3. 移动到 f1 的 Parent Frame(即 Global)。

        4. Global 中查找 z。(假设 z=7找到了。停止查找,使用这个值。

  5. 遇到 return 语句或函数体结束

    • 函数执行结束。

    • 在图上,将这个 Frame(f1)标记为“已完成”(例如划掉或变灰)[35:53]。

    • “当前 Frame”返回到调用者(即 Global Frame)。

    • 如果有返回值(例如 return y + 1),该值(8)将“替换”掉原始的函数调用表达式 f(7),用于后续的计算。

这个流程(特别是步骤 3、4、5)就是你在课程中需要反复练习的核心“框架”。视频强调,虽然这个框架在后期会因“高阶函数” (Higher-Order Functions) [32:02] 而变得更复杂(例如,Parent 可能不是 Global),但规则本身的数量是有限的 [32:09]。你的目标就是内化 (internalize) 这几条规则,直到它们成为你的第二天性。


框架二:“抽象屏障” (Abstraction Barriers) 作为心智模型

视频中反复出现的另一个心智模型是**“抽象” (Abstraction)**。这是一个关于如何管理复杂性、如何组织代码以及如何进行团队协作的框架。

核心思想:你不需要知道一个东西是如何工作的,也能使用它 [10:37]。

这个框架将世界分为两部分:

  1. 用户 (User) / 调用者 (Caller)f 的作者,他想使用 min 函数。

  2. 实现者 (Implementer) / 作者 (Author)min 函数的作者。

“抽象屏障” [10:37] 就是隔在他们之间的那堵墙。

  • 作为用户 (User)

    • 关心 min 函数的**“契约” (contract)**:它接受什么(两个数字),它返回什么(较小的那个数字)。

    • 故意不关心 min 的内部实现。它内部是用 if 语句,还是用某种复杂的位运算,与你无关

    • 这种“故意无知”是好事,它能保护你免于陷入不必要的复杂性 [11:09]。

  • 作为实现者 (Implementer)

    • 关心你的函数内部逻辑的正确性。

    • 你(理想情况下)不应该对“谁在调用你”或“他们会拿你的结果做什么”做出任何假设。

    • 你还需要提供一个清晰的“契约”(文档),告诉用户你的函数是做什么的。

这个框架如何应用于环境图?

  • 我们为什么要学环境图?

    • 因为在本课程中,你同时扮演着“用户”和“实现者”的角色。

    • 当你调用 f 时,你是 f 的“实现者”。你必须知道 f 内部的每一处细节是如何工作的。因此,你必须为 f 绘制局部 Frame [10:54]。你必须“打破” f 的抽象屏障,深入其内部。

  • 我们为什么不为 minprint 画 Frame?

    • 因为在调用 min 时,我们扮演的是“用户”。

    • 我们信任 (trust) min 的抽象屏障 [10:37]。我们假设 Python 的实现者已经把它写对了。

    • 如果我们为 min 绘制 Frame,然后 min 内部可能又调用了其他函数… 我们的环境图会立刻变得无限复杂 [11:09]。

    • 因此,在这个心智模型中,我们战略性地选择在“内置函数”这一层停止深入,我们将其视为一个“黑盒” (black box),它只接受输入并提供输出。

这个“抽象屏障”的框架,是“环境图”框架得以实用 (practical) 的前提。它让你知道你的分析应该深入到哪一层(你写的函数),以及应该在哪里停止(你信任的、别人写的函数)。这也是为什么“纯函数”(只有输入和输出,没有副作用)[30:52] 如此受人推崇,因为它们提供了最干净、最可靠的“抽象屏障”。