Lecture 2. Functions
Textbook Ch. 1.1 Ch. 1.2
预录课程简介与安排
- 视频格式优势:
- 本课程采用预录视频,对于学习计算机科学而言,这种形式优于现场讲座。因为在现场讲座中,若错过了某部分内容,可能会在整个小时内感到困惑。而视频则允许你倒回查看、加快或放慢速度,或暂停后自行尝试例子,再继续观看。
- 即使在学生全部在校的学期中,视频讲座通常也比现场讲座更受欢迎。
- 视频的制作:
- 这些视频并不是每学期都重新制作,而是当视频讲解准确无误时,会持续使用,直到找到更好的呈现方式。部分视频自2013年起制作,虽然有些经典内容仍在使用,但我们会根据需要更新不准确或陈旧的视频。
- 在学期中,我们还会增加一些额外视频,特别是在那些学生容易感到困惑的部分,以帮助减少困惑。
- 课程观看建议:
- 你应该在每次讲座发布后当天或次日观看,以保持进度,并确保看到每个讲座中的重要公告。
- 每次讲座视频的第一部分通常会包含课程的公告信息,而后续部分才是正式课程内容。
实验室安排与帮助
- 实验室配置(Lab 0):
- Lab 0 的目的是配置你的计算机以完成课程作业。设置有时会很快完成,有时则需要时间,具体取决于你的设备和操作步骤。为此,我们提供了详细的自助指南,让你按照指示完成设置。
- 如果你在配置过程中遇到问题,可以参加办公室时间,与导师进行一对一的帮助。办公室时间会在今天和明天开放,建议你尽早完成配置,以便下周可以专注于实际问题的解决。
- 正式实验(Lab 1):
- Lab 1 是包含实际课程内容的第一个实验,将于周一(可能提前到周日晚上)发布,并需要在周一午夜前提交。
- 实验室的完成时间约为一个半小时,你可以选择独立完成,但推荐参加实验室介绍会。介绍会将在周一的2PM、6PM 和 9PM 分别举行,另有专为编程经验不足的学生设计的介绍会。
- 实验室介绍会:
- 介绍会将由不同的讲师负责讲授,学生可以根据个人需求选择参加其中的一个。2PM 场次的学生人数预计最多,建议参加以确保有足够时间完成实验。
- 作业与问答:
- Homework 1 将于周五晚上发布,并需在下周四之前提交。建议先完成实验再开始作业。每次讲座之后会有Q&A 环节,帮助解答课程中的疑问。
课程内容:表达式与函数调用
表达式概述
- 表达式:
- 表达式描述的是计算过程,并且会返回一个值。表达式不仅限于计算机科学领域,数学家们早就发明了许多方法来表示不同的计算,如加法、除法、平方根等。
- 每种计算都有对应的符号表示,比如加法用 “+”,除法用 “/”,平方根则是一个数字位于“房子”形状下。计算角度的正弦值用 “sin” 表示,这些都是表示值之间运算的方式。
- 函数调用:
- 计算机科学将这些复杂的运算统一成一种通用形式,即函数调用。函数调用可以表示所有的计算,无论是加法、乘法还是其他复杂的运算。
- 在 Python 中,我们将所有的计算表示为函数调用,如
f(x)
或f(x, y)
,这比使用多种不同的符号更直观和通用。
Python 中的表达式与函数调用
- Python 解释器:
- 当启动 Python 解释器时,它会提供一个提示符,等待用户输入表达式,然后显示其值。比如输入
2005 + 3
,Python 会计算并返回结果。
- 当启动 Python 解释器时,它会提供一个提示符,等待用户输入表达式,然后显示其值。比如输入
- 函数调用的基本形式:
- 调用表达式的形式为函数名加上参数。例如,
max(2, 4)
返回 4,min(-2, -405000)
返回 -405000。这些函数调用可以组合、嵌套,形成更复杂的表达式。
- 调用表达式的形式为函数名加上参数。例如,
- 导入函数:
- Python 内置了许多函数,但有些需要通过
import
导入,例如add
和mul
。使用add(2, 3)
表示加法,mul(2, 3)
表示乘法。
- Python 内置了许多函数,但有些需要通过
- 嵌套调用:
- 函数调用可以嵌套,如
add(2, mul(4, 6))
先计算mul(4, 6)
,然后将结果与 2 相加。函数调用的嵌套不需要记忆运算顺序,只需根据嵌套结构来理解表达式的计算过程。
- 函数调用可以嵌套,如
- 表达式树:
- 表达式树是计算机在执行嵌套函数调用时的图形化表示。每个调用表达式都是一个树的节点,子节点表示操作数或更深的嵌套调用。通过这种方式,计算机逐步评估每个子表达式,直到整个表达式得出结果。
调用表达式的结构与求值过程
- 调用表达式的结构:
- 每个调用表达式(call expression)都由操作符和操作数组成,通常以括号包含操作数。操作符是括号前面的部分,表示要执行的操作或函数,而操作数是在括号内用逗号分隔的表达式。
- 操作符和操作数本身也可以是表达式,称为子表达式。例如,在表达式
add(2, 3)
中,add
是操作符,而2
和3
是操作数。操作符add
代表一个函数,该函数接收两个操作数并将它们相加。
- 求值过程:
- 求值一个调用表达式的步骤如下:
- 评估操作符:首先,评估操作符。操作符通常是一个函数或操作(例如
add
或mul
)。 - 评估操作数:接下来,依次评估括号内的操作数。如果操作数本身是表达式(如
mul(4, 6)
),则递归地评估该表达式,直到得到基本值。 - 应用函数:当操作符和操作数都评估完毕后,将操作数作为参数传递给操作符(函数),执行相应的操作,并返回结果。
- 评估操作符:首先,评估操作符。操作符通常是一个函数或操作(例如
- 求值一个调用表达式的步骤如下:
- 嵌套调用表达式:
- 该求值过程可以递归地应用于嵌套的调用表达式。例如,
mul(add(2, 3), 4)
中,首先评估add(2, 3)
,得到5
,然后将结果5
作为参数传递给mul
,与4
相乘得到20
。 - 这种嵌套调用是通过重复调用相同的求值步骤来实现的,每次都先评估操作符,再评估操作数,最后执行操作。
- 该求值过程可以递归地应用于嵌套的调用表达式。例如,
表达式树与调用表达式的求值过程
表达式树的求值步骤
- 嵌套调用的求值:
- 在 Python 中,调用表达式(call expression)的求值遵循固定的步骤。以
mul(add(2, mul(4, 6)), add(3, 5))
为例,Python 按照以下顺序进行求值:- 评估操作符:首先,评估操作符,这里是
mul
函数,即乘法。 - 评估操作数:然后依次评估每个操作数,操作数可能是另一个调用表达式或基本值。
- 递归求值嵌套调用:如果某个操作数是调用表达式(如
add(2, mul(4, 6))
),则会递归评估这个嵌套表达式,直到所有操作数都是基本值。
- 评估操作符:首先,评估操作符,这里是
- 在 Python 中,调用表达式(call expression)的求值遵循固定的步骤。以
- 示例分析:
- Python 首先评估
mul(add(2, mul(4, 6)), add(3, 5))
的操作符mul
。 - 接着,评估第一个操作数
add(2, mul(4, 6))
:- 先评估
add
的操作符,得到加法函数。 - 然后,评估
add
的第一个操作数2
,接着评估第二个操作数mul(4, 6)
:mul(4, 6)
首先评估mul
操作符,然后依次评估操作数4
和6
,最后计算结果为24
。
- 得到
add(2, 24)
的结果为26
。
- 先评估
- 接着,评估第二个操作数
add(3, 5)
:- 先评估
add
操作符,接着评估操作数3
和5
,最后计算结果为8
。
- 先评估
- 最后,评估
mul(26, 8)
,结果为208
。
- Python 首先评估
- 表达式树的结构:
- 这个过程可以通过表达式树(expression tree)来可视化。表达式树的每个节点表示一个操作符或操作数,嵌套调用形成了子树。每次子表达式被评估时,其结果就被替换为该子树的值,直到整棵树被完全计算。
调用表达式的评估流程
- 评估操作符:首先确定调用的操作符是什么。操作符可以是函数、内置操作符(如
+
、*
)等。 - 评估操作数:依次评估每个操作数,若操作数本身是表达式,则递归评估。
- 应用函数:当所有操作数都被评估后,将操作符应用于这些操作数,得到最终结果。
名称绑定与导入
- 名称的求值顺序:
- 名称(如变量或函数)会在当前的环境中进行查找。当计算表达式时,Python 首先在局部帧中查找名称,如果局部帧中没有,则继续查找全局帧。
- 例如在计算
mul(add(2, mul(4, 6)), add(3, 5))
时,mul
和add
都是预定义的全局函数,因此会从全局环境中查找并应用这些函数。
- 名称绑定:
-
在 Python 中,可以使用赋值语句将某个名称绑定到一个值。例如,
radius = 10
将radius
绑定到数值 10。之后可以在其他表达式中使用这个名称,如2 * radius
会计算并返回 20。 -
赋值语句也可以绑定函数。例如,
f = max
将f
绑定到内置函数max
,这意味着我们可以使用f(1, 2, 3)
来调用max
函数。
-
赋值语句与函数定义
赋值语句中的函数重命名
- 重命名内置函数:
- 在 Python 中,函数也是对象,可以通过赋值语句为函数绑定新的名称。例如:
f = max # 将内置的 max 函数赋值给变量 f
- 现在,
f
和max
都指向相同的函数,因此调用f(1, 2, 3)
和调用max(1, 2, 3)
效果相同,都会返回3
。
- 在 Python 中,函数也是对象,可以通过赋值语句为函数绑定新的名称。例如:
- 更改名称绑定:
- 我们可以通过赋值语句将
max
重新绑定到不同的值。例如:max = 7 # 将 max 重新绑定为数值 7
- 此时,
max
不再表示原来的内置函数,而是数值7
。因此,调用f(1, 2, max)
会返回7
,因为f
仍然是max
函数,而max
已被重新定义为7
。
- 我们可以通过赋值语句将
- 恢复原函数:
- 即使我们改变了
max
的绑定,仍然可以通过变量f
访问最初的max
函数。如果我们想将max
重新绑定回函数,可以这样做:max = f # 将 max 重新绑定为原来的 max 函数
- 即使我们改变了
运算符的函数化
- 导入操作符模块:
- Python 提供了
operator
模块,其中包含了常用的中缀运算符(如加法、乘法)对应的函数名称。例如:from operator import add, mul
- 现在,
add(2, 3)
和mul(2, 3)
分别表示2 + 3
和2 * 3
。
- Python 提供了
定义函数
def
语句定义函数:- 通过
def
语句可以定义自己的函数。例如:def square(x): return x * x
- 这里定义了一个名为
square
的函数,它接收一个参数x
,并返回x
的平方。
- 通过
- 使用函数:
- 我们可以像使用内置函数一样调用我们定义的函数:
square(3) # 返回 9 square(3 + 4) # 先计算 3 + 4,然后计算 7 的平方,返回 49 square(square(3)) # 先计算 square(3),得到 9,然后计算 9 的平方,返回 81
- 我们可以像使用内置函数一样调用我们定义的函数:
定义新函数并调用其他函数
- 定义多个函数:
- 我们可以在一个函数中调用另一个函数。例如,定义一个函数来计算两个数的平方和:
def sum_squares(x, y): return square(x) + square(y)
- 当我们调用
sum_squares(3, 4)
时,square(3)
返回9
,square(4)
返回16
,所以最终结果是9 + 16 = 25
。
- 我们可以在一个函数中调用另一个函数。例如,定义一个函数来计算两个数的平方和:
- 使用
add
函数替代加法运算符:- 我们可以使用
add
函数替代加法运算符:def sum_squares(x, y): return add(square(x), square(y))
- 这与使用
+
号的效果相同,add
函数与加号运算符的功能相同。
- 我们可以使用
函数返回动态值
- 如果你希望某些计算能够自动更新,可以将其封装在函数中。例如:
def area(): return pi * radius * radius
每次调用
area()
时,Python 会重新计算当前radius
的值。如果radius
改变了,area()
的结果也会相应更新。
环境图(Environment Diagrams)
环境图用于可视化解释器在执行代码时的过程。在这张环境图中,我们通过两条语句的执行来展示如何跟踪变量的变化及它们的绑定关系。
代码执行流程
第一条语句是 from math import pi
,这是一条导入语句(Import Statement),从 Python 的数学库中导入了常数 pi
。在环境图的左侧,数字 1 表示这条语句刚刚被执行,解释器现在已经将 pi
绑定到了全局帧中(Global Frame)。在右侧的全局帧(Global Frame)中,我们可以看到 pi
的值被定义为 3.1416
。
接着执行第二条语句 tau = 2 * pi
,这是一条赋值语句(Assignment Statement)。此时,左侧的数字 2 表示这一行是下一个要执行的代码。这条语句的作用是将 tau
赋值为 2 * pi
的结果。环境图的流程展示了代码的执行顺序,使用箭头指示当前的计算顺序。
全局帧与变量绑定
在环境图的右侧,是一个全局帧。帧(Frames)用来存储变量的绑定关系。每个变量(即名称)都被绑定到一个对应的值。在上面的例子中,全局帧显示 pi
被绑定到值 3.1416
。需要注意的是,在同一个帧中,变量名不能重复出现。每个变量只能被绑定到一个值,且该值在执行过程中可能会改变。
赋值语句(Assignment Statements)
赋值语句是编程语言中非常常见的操作,主要用于将表达式的值绑定到特定的变量。在这张图中,我们通过三条赋值语句展示变量值如何变化,并使用环境图跟踪全局帧中变量的绑定情况。
代码执行流程
首先,执行 a = 1
,它将变量 a
绑定到整数 1
。环境图左侧显示这一行已经被执行,右侧的全局帧中显示变量 a
已经被绑定到 1
。接下来,执行 b = 2
,这将变量 b
绑定到 2
,此时全局帧中已经有两个变量,分别是 a = 1
和 b = 2
。
第三条语句 b, a = a + b, b
是一个多重赋值操作,它将在同一行中同时更新 a
和 b
的值。根据 Python 的赋值规则,首先计算右侧的表达式,再进行绑定。此处,a + b
计算为 1 + 2
,即 3
,所以新的 b
将被绑定到 3
,而原先的 b
的值 2
将被赋给 a
。执行完后,全局帧更新为 a = 2
和 b = 3
。
赋值语句执行规则
赋值语句的执行遵循以下两条规则:
- 从右到左:首先从等号右边开始,按照从左到右的顺序对表达式进行求值。
- 从左到右:将等号左侧的变量与右侧计算出的值进行绑定,更新变量在当前帧中的值。
讨论问题 1 解答(Discussion Question 1 Solution)
讨论中示例一个复杂函数调用的执行过程,利用环境图来可视化变量和函数的绑定关系以及表达式的求值顺序。通过这个例子,我们可以深入理解 Python 中的函数调用与赋值操作是如何进行的。
代码解析与变量绑定
-
f = min
这一行代码将 Python 内置的min
函数赋值给变量f
。此时,全局帧中的变量f
被绑定到min
函数。 -
f = max
这行代码将内置的max
函数赋值给f
,覆盖了之前绑定到min
函数的值。现在,变量f
被绑定到max
函数。 -
g, h = min, max
这一行通过多重赋值,将min
函数赋值给变量g
,将max
函数赋值给变量h
。在全局帧中,g
绑定到min
函数,h
绑定到max
函数。 -
max = g
这行代码将变量g
的值(即min
函数)赋值给max
。这意味着此时max
不再是内置的max
函数,而是min
函数。
函数调用过程
第5行代码调用了一个复杂的嵌套函数:max(f(2, g(h(1, 5), 3)), 4)
。我们将按执行顺序逐步解析这一函数调用。
-
h(1, 5)
首先,调用函数h(1, 5)
,此时h
被绑定到max
函数。因此,这个调用返回5
,因为max(1, 5)
的结果是5
。 -
g(h(1, 5), 3)
接下来,执行g(5, 3)
,由于g
被绑定到min
函数,所以返回3
,因为min(5, 3)
的结果是3
。 -
f(2, g(h(1, 5), 3))
然后,执行f(2, 3)
,此时f
被绑定到max
函数,因此返回3
,因为max(2, 3)
的结果是3
。 -
max(f(2, g(h(1, 5), 3)), 4)
最后,执行max(3, 4)
。由于之前的第4行代码将max
重新绑定到了min
函数,因此该调用实际上执行的是min(3, 4)
,结果为3
。
全局帧更新
在这个过程中,全局帧展示了每个变量的绑定情况:
f
最终绑定到max
函数。g
绑定到min
函数。h
绑定到max
函数。max
被重新绑定到min
函数。
每个函数调用的执行顺序在图中通过黄色框和箭头清晰展示,帮助我们理解嵌套函数是如何一步步求值的。
通过这个例子,我们看到 Python 允许灵活地将内置函数绑定到变量,并且可以通过重新赋值覆盖这些绑定。复杂的嵌套函数调用也遵循从内到外的求值顺序,先计算内层表达式的值,再逐步返回给外层的函数。在求值过程中,环境图直观地展示了每个函数调用的执行流程,帮助理解变量和函数的动态绑定关系。
定义函数(Defining Functions)
在编程中,赋值是一种简单的抽象方式,它将名称绑定到具体的值。而函数定义则是一种更强大的抽象工具,它将名称绑定到表达式,使得我们能够重复使用代码并处理复杂的逻辑。通过定义函数,程序员可以将一组操作打包为一个独立的单元,并通过调用函数来执行这些操作。
函数的定义结构
函数的定义由函数签名和函数体两部分构成:
- 函数签名(Function Signature):函数签名指明了函数的名称以及它所接受的参数数量。格式为:
def <函数名>(<形式参数>):
函数名后面的括号中是形式参数(formal parameters),这些参数表示函数在调用时需要传递的输入。
- 函数体(Function Body):函数体定义了当函数被调用时要执行的操作。在 Python 中,函数体是缩进的代码部分,通常以
return
语句结束,返回一个表达式的值:return <返回表达式>
函数体可以包含多行代码,执行与形式参数相关的计算。
函数定义的执行过程
当解释器遇到一个 def
语句时,会按以下步骤执行:
- 创建函数对象:解释器根据函数的签名创建一个函数对象,该对象包含了函数的名称和参数列表。
- 设置函数体:将函数体中的所有代码设置为函数执行时要运行的代码。
- 在当前帧中绑定函数名:将函数名绑定到这个函数对象,使得函数可以在后续的代码中通过该名称调用。
定义函数后,函数本身并不会立即执行,而是当代码调用该函数时,才会开始执行函数体中的代码。
调用用户自定义函数(Calling User-Defined Functions)
一旦函数被定义,我们就可以通过函数名和传递参数来调用它。调用函数时,Python 会为每次调用创建一个新的执行环境,称为局部帧(Local Frame),以便函数的局部变量和参数与其他调用隔离开来。
调用函数的过程
调用用户自定义函数的执行步骤如下:
- 添加局部帧:调用函数时,解释器会为该调用添加一个局部帧,这个局部帧相当于一个新环境,用于存储函数内部的变量和参数。
- 绑定形式参数到实参:将传入的实际参数(arguments)绑定到函数的形式参数上。这一操作会在局部帧中进行,确保函数的局部变量与全局变量分离。
- 执行函数体:在新的局部帧中执行函数体的代码。函数的执行逻辑与普通代码执行相同,直到遇到
return
语句或者到达函数体的末尾,函数返回计算结果。
示例解析
图中展示了如何定义并调用一个简单的用户自定义函数 square
。代码如下:
from operator import mul
def square(x):
return mul(x, x)
- 首先,
from operator import mul
导入了内置的mul
函数,它用于执行两个数的乘法操作。 - 接下来,定义了
square
函数,接受一个参数x
,返回x
与自身相乘的结果。
当调用 square(-2)
时,解释器会执行以下步骤:
- 添加局部帧:创建一个新的局部帧,函数
square
在该局部帧中执行。 - 绑定参数:将参数
x
绑定到传入的实参-2
,即x = -2
。 - 执行函数体:调用
mul(-2, -2)
,得到结果4
,并返回该值。值得注意的是,返回值4
并不会绑定到任何名称,而是作为函数的结果返回给调用方。
全局帧与局部帧
在整个过程中,全局帧(Global Frame)保存了函数的定义和内置函数的引用,如 mul
和 square
。当 square
被调用时,局部帧中记录了局部变量 x
的值和函数的返回值 4
。
函数定义和调用是 Python 编程中的核心概念。通过定义函数,我们可以实现代码复用,提升程序的结构化程度。当调用函数时,局部帧确保了每次函数调用的独立性,不会干扰其他函数的执行。
在环境中查找名称(Looking Up Names in Environments)
在 Python 中,每个表达式都会在特定的环境中进行求值。环境由帧组成,每个帧记录了当前作用域中变量的名称及其对应的值。
环境查找过程
当前代码的执行环境可能是:
-
全局帧:当程序开始执行时,默认的环境是全局帧。全局帧记录了全局变量、函数和内置对象的绑定。
-
局部帧:当调用函数时,解释器会创建一个局部帧。在函数体内,局部帧优先于全局帧进行名称查找。
名称查找规则
解释器在环境中查找名称的过程遵循以下原则:
-
从局部帧开始:如果某个名称在局部帧中有定义,解释器会直接使用该绑定。
-
查找全局帧:如果局部帧中没有该名称的绑定,解释器会继续在全局帧中查找。
例如,在 square
函数体内查找名称 mul
时,解释器首先在局部帧中查找 mul
,但未找到。接着会在全局帧中查找,找到 mul
绑定到的内置函数,并调用它来计算结果。
环境的概念
环境可以看作是一个帧的序列,每个帧记录了当前作用域中名称的绑定关系。一个名称会被求值为最早在某个帧中绑定的值。因此,程序中的变量查找始终遵循从局部到全局的顺序。这种机制确保了函数调用的独立性和局部性,同时又允许函数访问全局变量和内置函数。
每个表达式的求值都依赖于当前的环境。在函数调用中,局部帧和全局帧共同构成了完整的执行环境。名称查找首先从局部帧开始,然后在全局帧中查找,确保了不同作用域之间的相互独立和可访问性。
示例解析:递归定义与局部变量
- 使用相同名称作为参数:
- 在 Python 中,可以将函数参数命名为与函数同名的变量。例如:
def square(square): return square * square
这里
square
既是函数名,也是函数参数。调用square(-2)
时,Python 会首先在局部帧中找到square
,它指的是参数值-2
,因此会计算-2 * -2
,并返回结果4
。
- 在 Python 中,可以将函数参数命名为与函数同名的变量。例如:
- 避免全局变量冲突:
- 即使函数名
square
在全局帧中指向了该函数本身,但当函数执行时,局部帧中的square
会覆盖全局帧中的同名变量。因此,函数在其局部环境中执行时,优先查找局部变量。
- 即使函数名