Lecture 2
Defining Functions
Function definition is a more powerful means of abstraction: binds names to expressions
format
1 | def <name>(<formal parameters>): |
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]:
-
全局 Frame (Global Frame):
-
首先,代码在“顶层”(不在任何函数内部)执行
x = 12。这会在一个我们称之为“全局 Frame”的“大盒子”里,创建了一个绑定:x指向12。 -
接着,代码定义了一个函数
def f(y): ...。注意,定义 (define) 函数本身并不会立即创建新的Frame,它只是创建了一个“函数对象”,并将其绑定到全局 Frame 中的名字f上。
-
-
局部 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。
-
-
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”中会详细展开):
-
Python 永远首先在当前局部 Frame 中查找变量名 [06:26]。
-
如果找到了(比如
x),它就立刻使用这个值(3),并停止查找。这就是为什么本地的x会“遮蔽” (shadow)”全局的x[07:06]。 -
如果没找到(比如
z),它才会“向上”一级,去父级 Frame(在这里是全局 Frame)中查找。 -
如果在全局 Frame 中找到了
z(z=7),它就使用那个值。 -
如果一路找到全局 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)的执行体内部时:
-
你的当前 Frame 是
f1 (square)。 -
但你的当前 Environment 是一个有序列表:
[f1 (square) Frame, Global Frame][14:08]。
为什么“序列”和“顺序”如此重要?
因为这个“Environment”(环境)列表,精确地定义了上一主题中提到的“变量查找顺序”。当你查找一个变量(如 mol)时,Python 会:
-
查看 Environment 列表中的第一个 Frame(即
f1 (square))。mol在这里吗?不在。 -
移动到 Environment 列表中的下一个 Frame(即
Global Frame)。mol在这里吗?在。好,就用它。
视频给出了一个“你永远不该这么写” [15:22] 但却极具启发性的例子,来展示这个顺序的威力。
假设有如下(糟糕的)代码:
-
全局 Frame 中有:
mol = <一个乘法函数>,square = <一个平方函数> -
square函数的定义是def square(square): return mol(square, square) -
我们调用
square(4)
执行分析:
-
调用
square(4):-
创建一个新的局部 Frame,名为
f1 (square)。 -
在此
f1Frame 中,将参数名square绑定到传入的值4。 -
此时,当前 Environment 变为
[f1 (square) Frame, Global Frame]。
-
-
执行
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)。
-
-
返回:
mol(4, 4)返回16。square(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 的嵌套结构,通常被称为“调用栈”:
-
调用
f(7)。创建f的 Frame (Frame F)。当前 Environment 是[Frame F, Global]。 -
f在执行中调用g(8)[12:18]。 -
暂停
f的执行。 -
调用
g(8)。创建g的 Frame (Frame G)。 -
Frame G成为“当前 Frame”。当前 Environment 变为[Frame G, Global]。(注意:Frame F仍然存在于内存中,它在“等待”g返回)。 -
g执行完毕,return 11[12:44]。 -
Frame G被销毁。 -
执行权返回到
Frame F[12:56]。 -
Frame F重新成为“当前 Frame”。当前 Environment 变回[Frame F, Global]。 -
f拿到g返回的11,继续执行(例如11 + 2),然后return 13。 -
Frame F被销毁。 -
执行权返回
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 必须在你开始改变任何左侧变量之前,完全地、彻底地计算完右侧的所有表达式。
-
评估 (Evaluate) 右侧:
-
b的值是?2。 -
a + b的值是?1 + 2 = 3。
-
-
准备就绪:Python 现在在内存中准备好了两个值:
2和3。 -
赋值 (Assign) 左侧 [19:56]:
-
将第一个值
2赋给第一个名字a。 -
将第二个值
3赋给第二个名字b。
-
-
结果:
a是2,b是3。
-
-
这是一条你必须记忆的规则 [19:22]。它适用于所有赋值语句,包括单一赋值(
b = a + b也会先计算完a+b再更新b)。
规则二:函数调用的“参数优先”原则
当你调用一个函数,比如 print(2 + 3, a) 时,Python 是怎么做的?[20:23]
-
print函数并不知道你喂给它的是2 + 3。 -
Python 的规则是:在调用函数(如
print或min)之前,它必须首先评估完所有的参数表达式 [20:54]。-
评估第一个参数:
2 + 3-> 结果是5。 -
评估第二个参数:
a(假设a是12) -> 结果是12。 -
调用函数:现在 Python 准备好了所有的值,它调用
print(5, 12)。
-
-
print函数本身只接收到了5和12,它对这些值是如何来的(是字面量、变量还是复杂计算)一无所知 [21:19]。
print(副作用) vs min(返回值)
这引出了一个关于函数“目的”的核心区别。
-
min(5, 12):-
它的工作是计算一个值。
-
它返回 (return) 这个值:
5。 -
它没有“副作用” (Side Effect),即它不会在屏幕上打印任何东西 [21:57]。
-
-
print(5, 12):
为什么这个区别至关重要?
-
因为
min返回一个有用的值 (5),所以你可以在其他表达式中“组合” (compose) 它。-
x = min(5, 12) + 7 -
这会变成
x = 5 + 7,x最终等于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]。
-
纯函数:像
min或abs。它的唯一工作就是根据输入计算并返回一个值。它没有副作用。给定相同的输入,它永远返回相同的输出。这是最理想、最容易测试和推理的函数 [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 程序运行的“标准”方式。 -
它是一个 REPL:Read (读取), Eval (求值), Print (打印), Loop (循环) [24:12]。
-
它的设计初衷是为了方便 [24:04]。
-
它的工作流程是:
-
® 读取你输入的整行表达式(例如
2 + 3 + 4)。 -
(E) 完整地“求值”该表达式,得到一个最终结果(
9)。 -
§ 自动地“打印”这个最终结果 [24:27]。
-
(L) 循环,等待你的下一次输入。
-
-
这就是关键:§ 步骤是 REPL自动为你做的。
-
相比之下,当 Python 运行一个
.py文件时,它没有第 § 步。它只是 ® 读取文件和 (E) 求值(执行)文件。如果你想在屏幕上看到任何东西,你必须显式地使用print()函数。
REPL 自动打印的两个限制:
-
它只打印最终结果,不打印中间步骤 [25:23]。
-
如果你输入
f(3) + 1(假设f(3)返回5),REPL只会显示最终的6。 -
它不会显示
f(3)内部计算的中间值,也不会显示f(3)返回的5。 -
如果你想看中间步骤,你必须在你的函数
f内部显式地使用print()[26:12]。
-
-
它不打印
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) 它。环境图就是你用来模拟执行的草稿纸。
环境图框架的执行步骤:
-
初始化 (Initialization):
-
绘制第一个“盒子”:Global Frame (全局帧)。
-
将所有内置函数(如
min,abs)和导入的函数(如mol)作为名称添加到此帧中,它们指向“对象区”中的函数对象 [33:31]。
-
-
逐行执行代码(Global Frame):
-
遇到函数调用 (
f(7)):-
这是最关键的步骤。
-
步骤 3a (创建 Frame):在 Global Frame 下方绘制一个全新的“盒子”:Local Frame (局部帧)(例如,命名为
f1)。 -
步骤 3b (链接 Parent):查看
f的函数对象,它的 Parent 是谁?是 Global。于是在f1Frame 的右上角,写下 “Parent: Global”。 -
步骤 3c (绑定参数):在
f1Frame 内部,写入函数的参数名(y),并将其绑定到调用时传入的值(7)。 -
步骤 3d (切换上下文):现在,你的“当前 Frame”是
f1。你的“当前 Environment”是[f1, Global]。
-
-
在新的 Frame 中执行函数体:
-
遇到赋值语句 (
x = 3):- 在当前 Frame(即
f1)中,写入x并绑定值3。
- 在当前 Frame(即
-
遇到查找变量 (
print(x)):-
执行“变量查找算法” (Lookup Rule):
-
在当前 Frame (
f1) 中查找x。 -
找到了 (
x=3)。停止查找,使用这个值。
-
-
-
遇到查找另一个变量 (
print(z)):-
执行“变量查找算法”:
-
在当前 Frame (
f1) 中查找z。 -
未找到。
-
移动到
f1的 Parent Frame(即Global)。 -
在
Global中查找z。(假设z=7)找到了。停止查找,使用这个值。
-
-
-
-
遇到
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]。
这个框架将世界分为两部分:
-
用户 (User) / 调用者 (Caller):
f的作者,他想使用min函数。 -
实现者 (Implementer) / 作者 (Author):
min函数的作者。
“抽象屏障” [10:37] 就是隔在他们之间的那堵墙。
-
作为用户 (User):
-
你只关心
min函数的**“契约” (contract)**:它接受什么(两个数字),它返回什么(较小的那个数字)。 -
你故意不关心
min的内部实现。它内部是用if语句,还是用某种复杂的位运算,与你无关。 -
这种“故意无知”是好事,它能保护你免于陷入不必要的复杂性 [11:09]。
-
-
作为实现者 (Implementer):
-
你只关心你的函数内部逻辑的正确性。
-
你(理想情况下)不应该对“谁在调用你”或“他们会拿你的结果做什么”做出任何假设。
-
你还需要提供一个清晰的“契约”(文档),告诉用户你的函数是做什么的。
-
这个框架如何应用于环境图?
-
我们为什么要学环境图?
-
因为在本课程中,你同时扮演着“用户”和“实现者”的角色。
-
当你调用
f时,你是f的“实现者”。你必须知道f内部的每一处细节是如何工作的。因此,你必须为f绘制局部 Frame [10:54]。你必须“打破”f的抽象屏障,深入其内部。
-
-
我们为什么不为
min或print画 Frame?
这个“抽象屏障”的框架,是“环境图”框架得以实用 (practical) 的前提。它让你知道你的分析应该深入到哪一层(你写的函数),以及应该在哪里停止(你信任的、别人写的函数)。这也是为什么“纯函数”(只有输入和输出,没有副作用)[30:52] 如此受人推崇,因为它们提供了最干净、最可靠的“抽象屏障”。

