Lec 2 函数的乐趣
本章关注函数,函数是组织和构建复杂程序的最强大的工具之一。
1 抽象的力量
课程中的主要目标之一是帮助你们发展管理程序复杂性的新的方法。随着我们开始编写更大、更复杂的程序(这些程序可能无法完全展示在一个屏幕上,也可能无法一口气在脑海中理解),我们需要新的工具和策略来帮助管理这些复杂性,以便有效地处理这些类型的程序。
在设计更大规模和复杂度的系统时, 重要的是需要有一种将这些复杂系统分解成更简单、更易理解的组件的方法。为此,思考复杂系统的一个有用框架包括:
- 基本构件:系统由哪些最小和最简单的构建块组成?
- 组合方式:我们如何将这些构建块组合起来以构建更复杂的复合结构?
- 抽象方式:我们有什么机制可以将这些复杂的复合结构视为构建块本身?
第三点(“抽象”)非常关键,这个概念是,一旦我们使用基本构件和组合方式构建了一些新的复杂结构,我们可以为这个结构命名,并将其视为基本构件(包括将其与其他部分组合以构建更复杂的结构,然后给这些结构命名,并用它们来构建更复杂的结构,等等...)。因此,构建大而复杂的系统是分层进行的,每一层都设计、实现和测试由下层元素组成的新结构,使每一层可以独立理解。然后,当我们推理最上层(最复杂)时,我们不再需要考虑所有基础构件,而只是考虑下层的构件。
我们可以将这个框架应用于 Python。特别地,考虑我们在 Python 中可以执行的操作:
- Python 提供了几个基本操作,包括算术运算(+、* 等)、比较运算(==、!= 等)、布尔运算符(and、or 等)、内置函数(abs、len 等)等。
- 我们还有将这些元素组合在一起的方式,比如使用条件语句(if、elif、else)、循环结构(for 和 while)以及函数组合(f(g(x)))。
- 但真正的力量在于,当我们将一些元素组合在一起以表示新的操作时,我们可以抽象掉这些新操作的细节,并将其视为 Python 从一开始就内置的。我们的主要抽象手段之一就是使用
def
或lambda
关键字定义函数。
2 函数与环境模型
定义函数
python的def关键字做了两件事
- 在堆中创建了新的函数对象,这个对象包括
- 形式参数(即我们在函数内部用来引用函数参数的名称)
- 函数主体中的代码
- 一个指向我们遇到
def
语句时正在运行的帧的引用(我们称为这个函数的”闭包帧“, enclosing frame)
- 它在我们遇到
def
语句时正在运行的帧中,将该函数对象与一个名称关联起来。
下面看一个例子
def quad_eval(a, b, c, v):
term1 = a * v ** 2
term2 = b * v
return term1 + term2 + c
这个函数可能不是我们提倡的良好风格的典范,但它可以作为一个示例,向我们展示函数在我们的环境模型中的行为.
关键的是,我们此时并没有运行函数主体中的代码;相反,我们只是将这些信息存储在一个对象中,稍后可以调用它来实际运行代码。在函数对象内部
- 盒子的顶部区域包含了函数的参数(即,我们分别称它们为
a
、b
、c
和v
)。 - 盒子的底部区域包含了表示函数主体的代码。同样,我们此时并未运行这段代码,只是将其存储以备后用。
- 右上角的小框显示了一个指向我们遇到
def
语句时正在运行的帧的引用(在本例中是全局帧)。在这里,我们将这个引用标记为“闭包帧”,但一旦我们对这些图表更熟悉了,我们可能会省略这个标签。还要注意,这个箭头指向的是全局帧本身,而不是全局帧中的任何个别名称或引用。
我们还将该对象与全局帧中的名称 quad_eval
相关联。如果我们查找名称 quad_eval
,我们会沿着箭头查找并找到堆中的函数对象。如果我们想将该对象绑定到另一个变量,或将其用作列表中的一个元素,或将其作为参数传递给另一个函数,我们都可以这样做。
调用函数
函数对象作为特定计算过程的抽象表示而存在,但它们只有在被调用时才真正有用。而当我们调用一个函数时,Python 会执行以下四个步骤:
- 他会在进行的调用的帧中,依次计算要调用的函数及其参数
- 然后,他会为函数调用创建一个新的帧,该帧用于存储该函数调用的局部变量。新帧有个父指针,指向函数的闭包帧。
- 接着,他会在我们创建的新帧中,将函数的形式参数名称绑定到传入的实参
- 最后,他会在这个新帧中执行函数主体。重要的是,如果python尝试计算一个该帧中未绑定的函数名称,他会在该帧的“父帧”中查找
def quad_eval(a, b, c, v):
term1 = a * v ** 2
term2 = b * v
return term1 + term2 + c
n = qual_eval(7, 8, 9, 3)
print(n)
执行完1-4行的函数定义后,状态图如下
- 跟随关联箭头来解析qual_eval,在那里我们找到了之前创建的函数对象。
- 依次解析函数的参数。
- 思考解析参数时,会创建哪些新的对象(创建几个)?
- 4个整数对象,分别是7,8,9,3
- 已经知道要调用的函数和传入的参数是什么了,下面就要正式开始调用函数了。
- 创建新的帧,为了便于引用,我们给帧起了一个标签F1。他还有一个父指针,指向全局帧(意味着如果在这里查找一个名称没有找到绑定,则可以继续在全局帧查找)。
- 将函数的参数绑定到传入参数上。在 F1 内部,我们现在有了每个参数的新变量
- 执行函数主体
- 我们将标记这个刚刚创建的96对象是我们从函数调用中返回的值。这里我们在帧旁边添加了小标签来标记返回值。重要的是,我们不会将这个值放在帧中(这会意味着我们创建了一个新的名为
return
的局部变量!);而是会记下这个结果- 接下来我们需要对结果做什么?
- 通过全局帧中的名称
n
来引用这个函数调用的结果(我们的新 96 对象)。
- 此时,我们还可以进行一些清理工作。由于我们已经完成了在帧 F1 中的操作,并且没有其他对象指向 F1,我们可以删除它。
参数和实参
- 参数,即在函数内部使用的变量,
- 实参,即在调用函数时传递的值,这些值会变成参数在函数体开始执行时的值。
保持这一区别非常重要。当调用 quad_eval(7, 8, 9, 3)
时,调用的实参是 7、8、9 和 3,而它们最终被赋值给参数 a、b、c 和 v。
参数和实参有时也称为形式参数和实际参数,或简称为形式参数和实际参数。
3 函数是第一类对象
Python 的一个强大特性是它将函数视为第一类对象,这意味着函数在 Python 中可以像其他数据一样进行多种操作。在我们的环境图中,这种特性体现在函数像其他数据(整数、列表等)一样,被表示为右侧的对象!
思考下面代码的环境图
def square(x): return x * x foo = square x = [square, foo]
lambda函数
Python 还有另一种定义函数的方式,这在某些情况下可能会很有用:lambda
关键字。这个名称可能听起来有点奇怪,但它来自于一种数学系统,用于表达计算,称为 Lambda 演算。另一种创建一个平方其输入的函数的方法是:
lambda x: x * x
这将创建一个与 square
函数几乎完全相同的函数,只不过它没有名称
4 解决上节课遗留难题
functions = []
for i in range(5):
def func(x):
return x + i
functions.append(func)
for f in functions:
print(f(12))
##结果输出
16
16
16
16
16
解释一下原因。
第一步
这是刚开始的状态,运行完第一行。接着,将直接跳到第一次进入循环体,此时全局帧(GF)中,i绑定到0,绑定后结果如图
现在我们进入了 for 循环的主体,接下来我们看到的是第 3-4 行的函数定义 (def)。执行这一语句后,我们将在堆中生成一个新的函数对象,并将其绑定到一个名称。
我们创建了函数对象并存储了相关信息。因为我们仍在全局帧中运行,所以这个新函数的封闭帧(enclosing frame)是全局帧,并且名称 func 也绑定在全局帧。
到此为止,我们已经到达了 for 循环体的底部,因此我们准备继续下一次迭代。在下次循环时,我们的循环变量 i 将绑定到我们正在循环的 range 对象中的下一个元素
在第二次循环中,
i
已经在全局帧中重新绑定为 1。而且由于之前的 0 没有引用指向它,它被垃圾回收了。 因为我们创建的函数从未访问过i
;它只是存储了一个指令,当被调用时,它应该查找i
并将其相关值添加到其参数中(因为我们从未解析过i
,所以它不知道在定义时i
是 0)
我们现在有了第二个函数对象,它是在我们第二次循环时创建的。这个对象的所有内容(参数、主体和封闭帧)都与我们第一次创建的函数对象相同,但尽管有这些相似之处,它仍然是内存中一个独立的对象。我们还将名称
func
重新绑定到这个新的函数对象。因为我们原来的函数对象仍然有一个引用指向它(来自functions
列表的索引 0),所以它不会被垃圾回收。
所以这个图表代表了我们完成循环后的程序状态。这个图表有点复杂,但我们可以总结一些会影响我们输出的关键点:
- 名称
i
在全局帧中绑定到值 4(即使在我们退出循环后,这个绑定仍然存在)- 我们在
functions
列表中有五个不同的函数对象- 每个函数都有相同的函数体,执行该函数体涉及查找
i
在这里,我们将
f
绑定到了我们列表中索引 0 处的函数对象。一旦完成这个绑定,我们就准备好执行 for 循环的主体了。在这里,我们将要进行一个函数调用f(12)
,首先我们将访问表达式f
以确定要调用的函数,并evaluate 12 以确定要传递的值
现在我们知道了我们要调用哪个函数,以及需要传给这个函数什么值,下一步就是建立新的帧(以及设置他的父指针)。更新图示,包含一个新帧,并将其父指针指向我们要调用的函数的封闭帧
继续往下,我们有自己的新帧(打上F1标签),下一步就是将函数的参数(这个例子就是x)绑定到传入的参数值。
在F1中查找x,我们找到了局部绑定的12。但是i在局部没有绑定。那么我们该怎么办呢?我们遵循父指针,发现全局帧中有i这个名称。它引用了i的值为4,所以我们将使用它。
然后我们将这两个值相加,得到一个表示16的新的整数对象,我们将其返回。
我们将在这里停下来,但请注意,无论我们调用这些函数对象中的哪一个,结果都将是相同的。它们都不记得i在创建时的值;它们只是简单地说要查找当前i的值并将其添加到它们的输入中!因此,当我们继续循环并依次调用这些函数对象时,它们都会产生相同的输出!
5 闭包
可以想象,写下那段代码的人并不打算看到5个16,而是希望看到一些不断变化的数字。
函数对象“记住”了它被定义的帧(其封闭帧),因此,当函数被调用时,它可以访问在该帧中定义的变量,并可以从其自身的主体中引用它们。我们将一个函数和它的封闭帧的组合称为闭包,事实证明这是一个非常有用的结构
为了解释这个,我们举个例子
我们在这里定义了一个函数inner
,在另外一个函数outer
里面。
x = 0
def outer():
x = 1
def inner()
print('inner:', x)
inner()
print('outer: ', x)
print('global:', x)
outer()
inner()
print('global:', x)
- 因为
def
语句, 为此创建一个新的函数对象,新的函数对象将具有一个指向封闭帧的指针,该帧指针指向全局帧,因为我们在创建这个对象时是在全局帧中运行的。并将名称outer与该函数进行了关联
print('global': x)
: 我们需要解析并打印x。因为x=1还没执行,第一次输出结果是global: 0outer()
:通过解析outer,找到函数对象,这里例子没有参数需要,所以我们直接进入下一步。
调用outer函数并执行主体, 为此新建一个帧 F1。
x = 1
执行过程,新建一个整数对象1,并将其与 F1 中的 x 关联起来def inner()...
语句,创建函数对象inner,其封闭帧指向F1,并将名称inner指向新函数对象调用
inner()
,还是一样,解析函数名,和参数。现在我们要新建F2, 将其父指针指向F1。并执行函数主体,我们看到print("inner:", x)
,所以我们在 F2 中查找 x,我们在 F2 中找不到 x,所以我们追溯到其父帧 F1,并发现 x 绑定到 1我们完成了使用 F2 的对 inner() 的函数调用
所以我们可以对其进行垃圾回收,并回到 F1 中继续访问 outer 的主体。
现在我们访问 print('outer:', x)。查找 x 找到值 1,所以我们打印:outer: 1
现在我们已经完成了对 outer() 的调用,因此我们也可以清除 F1,以及我们创建的内部函数对象
- 我们继续执行下一行代码,其中要调用 inner()。但请注意,我们又回到了全局帧中——在这里,事实证明没有叫做 inner 的东西!这将导致一个错误,一个 NameError,表示 inner 未定义。请记住,inner 只存在于帧 F1 中,而不是我们现在所处的全局帧中。
小结:
因此,把这个 inner
函数对象不仅仅看作一个独立的对象,还可以将其视为函数与创建它的帧(即它的“封闭环境”)的组合体是很有用的,我们在这里用绿色圈了起来。为什么?因为该函数可以访问父帧中定义的所有这些变量。这个组合体就是我们所说的闭包——即函数对象与其封闭环境的组合体。
闭包的应用
pythondef add_n(n): def inner(x): return x + n return inner add1 = add_n(1) add2 = add_n(2) print(add2(3)) # 后面的自个儿尝试分析 print(add1(7)) print(add_n(8)(9))
当我们调用 add_n(1) 时,它最终会在一个新帧中将 n 绑定到 1,并创建一个指向该帧的函数对象。这个函数最终绑定到 add1。因此,add1 是个函数,函数的主体会将传入参数+1并返回。(记住 add_n 的返回值是一个函数,所以我们存储在 add1 中的是一个我们可以调用的函数。)
现在我们在
add_n
的函数体中最后要做的事情是返回inner
。我们将通过指向我们刚刚创建的函数对象来表示返回值。
将add1 绑定到全局帧,并且与刚刚的返回值做关联
当我们完成并返回一个函数调用时,比如
add_n
,这个函数调用及其相关的帧都会消失(并且一大堆东西会被垃圾回收)。但是在这里,我们不能这样做!我们不能摆脱 F1。为什么呢?因为,从全局帧来看,我们有一种方式可以访问这个函数对象(
add1
指向它),而这个函数对象依赖于帧 F1。它仍然需要使用帧中的值,所以帧不能被丢弃(而 Python 也不会丢弃它)。这个整体——函数对象及其封闭环境,被我们称为闭包(closure),我们可以把它看作一个单一的实体,一个可以依赖于在封闭帧中定义的变量的函数。
当你调用
add1
函数对象,因为全局帧中的add1
指向那个函数对象,它可以访问 n(因为我们创建函数时 n 就被绑定为 1)。所以每次我们调用这个函数并运行它的主体return x + n
时,它会将 n 的值视为 1。这就是为什么这个函数总是会将传入的值加 1
接着我们调用
add_n(2)
,因此新建F2帧,其n绑定到2,它的父指针指向GF。
现在我们已经在全局帧中将
add2
绑定到这个第二个函数对象。通过当前状态的图表,我们可以看到将一个函数与其封闭帧作为一个整体绑定是多么有用。我们现在称为
add1
和add2
的这些函数(在全局帧中)做了不同的事情。一个将传入的值加1,另一个加2。但如果仅仅查看它们的函数对象本身——参数和主体——它们看起来是一样的!区别它们的关键在于它们的封闭帧,它们绑定了不同的 n 值
记住我们的规则,新帧的父帧来自于函数对象的封闭帧——即定义该函数的帧。因为我们调用的函数是在 F2 中定义的,所以 F3 指向 F2。
这很重要!这就是它获取正确的 n 值的地方。因为现在我们执行函数对象的主体
return x + n
,它发现 x 在 F3 中绑定为 3,n 在 F2 中绑定为 2,并返回 5。
修复遗留难题
修复前
functions = []
for i in range(5):
def func(x):
return x + i
functions.append(func)
for f in functions:
print(f(12))
修复后
def make_addr(n):
return lambda x: x + n
functions = []
for i in range(5):
functions.append(make_addr(i))
for f in functions:
print(f(12))
或者
def make_addr(n):
def inner(x):
return n + x
return inner
functions = []
for i in range(5):
functions.append(make_addr(i))
for f in functions:
print(f(12))