Skip to content

Lec 2 函数的乐趣

本章关注函数,函数是组织和构建复杂程序的最强大的工具之一。

1 抽象的力量

课程中的主要目标之一是帮助你们发展管理程序复杂性的新的方法。随着我们开始编写更大、更复杂的程序(这些程序可能无法完全展示在一个屏幕上,也可能无法一口气在脑海中理解),我们需要新的工具和策略来帮助管理这些复杂性,以便有效地处理这些类型的程序。

在设计更大规模和复杂度的系统时, 重要的是需要有一种将这些复杂系统分解成更简单、更易理解的组件的方法。为此,思考复杂系统的一个有用框架包括:

  • 基本构件:系统由哪些最小和最简单的构建块组成?
  • 组合方式:我们如何将这些构建块组合起来以构建更复杂的复合结构?
  • 抽象方式:我们有什么机制可以将这些复杂的复合结构视为构建块本身?

第三点(“抽象”)非常关键,这个概念是,一旦我们使用基本构件和组合方式构建了一些新的复杂结构,我们可以为这个结构命名,并将其视为基本构件(包括将其与其他部分组合以构建更复杂的结构,然后给这些结构命名,并用它们来构建更复杂的结构,等等...)。因此,构建大而复杂的系统是分层进行的,每一层都设计、实现和测试由下层元素组成的新结构,使每一层可以独立理解。然后,当我们推理最上层(最复杂)时,我们不再需要考虑所有基础构件,而只是考虑下层的构件。

我们可以将这个框架应用于 Python。特别地,考虑我们在 Python 中可以执行的操作:

  • Python 提供了几个基本操作,包括算术运算(+、* 等)、比较运算(==、!= 等)、布尔运算符(and、or 等)、内置函数(abs、len 等)等。
  • 我们还有将这些元素组合在一起的方式,比如使用条件语句(if、elif、else)、循环结构(for 和 while)以及函数组合(f(g(x)))。
  • 但真正的力量在于,当我们将一些元素组合在一起以表示新的操作时,我们可以抽象掉这些新操作的细节,并将其视为 Python 从一开始就内置的。我们的主要抽象手段之一就是使用 deflambda 关键字定义函数。

2 函数与环境模型

定义函数

python的def关键字做了两件事

  • 在堆中创建了新的函数对象,这个对象包括
    • 形式参数(即我们在函数内部用来引用函数参数的名称)
    • 函数主体中的代码
    • 一个指向我们遇到def语句时正在运行的帧的引用(我们称为这个函数的”闭包帧“, enclosing frame)
  • 它在我们遇到 def 语句时正在运行的帧中,将该函数对象与一个名称关联起来。

下面看一个例子

python
def quad_eval(a, b, c, v):
  term1 = a * v ** 2
  term2 = b * v
  return term1 + term2 + c

这个函数可能不是我们提倡的良好风格的典范,但它可以作为一个示例,向我们展示函数在我们的环境模型中的行为.

image-20240821180904924

关键的是,我们此时并没有运行函数主体中的代码;相反,我们只是将这些信息存储在一个对象中,稍后可以调用它来实际运行代码。在函数对象内部

  • 盒子的顶部区域包含了函数的参数(即,我们分别称它们为 abcv)。
  • 盒子的底部区域包含了表示函数主体的代码。同样,我们此时并未运行这段代码,只是将其存储以备后用。
  • 右上角的小框显示了一个指向我们遇到 def 语句时正在运行的帧的引用(在本例中是全局帧)。在这里,我们将这个引用标记为“闭包帧”,但一旦我们对这些图表更熟悉了,我们可能会省略这个标签。还要注意,这个箭头指向的是全局帧本身,而不是全局帧中的任何个别名称或引用。

我们还将该对象与全局帧中的名称 quad_eval 相关联。如果我们查找名称 quad_eval,我们会沿着箭头查找并找到堆中的函数对象。如果我们想将该对象绑定到另一个变量,或将其用作列表中的一个元素,或将其作为参数传递给另一个函数,我们都可以这样做。

调用函数

函数对象作为特定计算过程的抽象表示而存在,但它们只有在被调用时才真正有用。而当我们调用一个函数时,Python 会执行以下四个步骤:

  1. 他会在进行的调用的帧中,依次计算要调用的函数及其参数
  2. 然后,他会为函数调用创建一个新的帧,该帧用于存储该函数调用的局部变量。新帧有个父指针,指向函数的闭包帧。
  3. 接着,他会在我们创建的新帧中,将函数的形式参数名称绑定到传入的实参
  4. 最后,他会在这个新帧中执行函数主体。重要的是,如果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行的函数定义后,状态图如下

image-20240821183005006

  1. 跟随关联箭头来解析qual_eval,在那里我们找到了之前创建的函数对象。
  2. 依次解析函数的参数。
    • 思考解析参数时,会创建哪些新的对象(创建几个)?
    • 4个整数对象,分别是7,8,9,3
  3. 已经知道要调用的函数和传入的参数是什么了,下面就要正式开始调用函数了。

image-20240821183833196

  1. 创建新的帧,为了便于引用,我们给帧起了一个标签F1。他还有一个父指针,指向全局帧(意味着如果在这里查找一个名称没有找到绑定,则可以继续在全局帧查找)。
  2. 将函数的参数绑定到传入参数上。在 F1 内部,我们现在有了每个参数的新变量

image-20240821184531827

  1. 执行函数主体

image-20240821184640494

  1. 我们将标记这个刚刚创建的96对象是我们从函数调用中返回的值。这里我们在帧旁边添加了小标签来标记返回值。重要的是,我们不会将这个值放在帧中(这会意味着我们创建了一个新的名为 return 的局部变量!);而是会记下这个结果
    • 接下来我们需要对结果做什么?

截屏2024-08-21 18.50.08

  1. 通过全局帧中的名称 n 来引用这个函数调用的结果(我们的新 96 对象)。

image-20240821185144422

  1. 此时,我们还可以进行一些清理工作。由于我们已经完成了在帧 F1 中的操作,并且没有其他对象指向 F1,我们可以删除它。

image-20240821185201884

参数和实参

  • 参数,即在函数内部使用的变量,
  • 实参,即在调用函数时传递的值,这些值会变成参数在函数体开始执行时的值。

保持这一区别非常重要。当调用 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]

image-20240821190348213

lambda函数

Python 还有另一种定义函数的方式,这在某些情况下可能会很有用:lambda 关键字。这个名称可能听起来有点奇怪,但它来自于一种数学系统,用于表达计算,称为 Lambda 演算。另一种创建一个平方其输入的函数的方法是:

python
lambda x: x * x

这将创建一个与 square 函数几乎完全相同的函数,只不过它没有名称

4 解决上节课遗留难题

python
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

解释一下原因。

第一步

截屏2024-06-03 09.42.27

这是刚开始的状态,运行完第一行。接着,将直接跳到第一次进入循环体,此时全局帧(GF)中,i绑定到0,绑定后结果如图

截屏2024-06-04 01.36.16

现在我们进入了 for 循环的主体,接下来我们看到的是第 3-4 行的函数定义 (def)。执行这一语句后,我们将在堆中生成一个新的函数对象,并将其绑定到一个名称。

截屏2024-06-04 01.38.17

我们创建了函数对象并存储了相关信息。因为我们仍在全局帧中运行,所以这个新函数的封闭帧(enclosing frame)是全局帧,并且名称 func 也绑定在全局帧。

截屏2024-06-04 01.50.55

到此为止,我们已经到达了 for 循环体的底部,因此我们准备继续下一次迭代。在下次循环时,我们的循环变量 i 将绑定到我们正在循环的 range 对象中的下一个元素

截屏2024-06-04 01.53.03

在第二次循环中,i 已经在全局帧中重新绑定为 1。而且由于之前的 0 没有引用指向它,它被垃圾回收了因为我们创建的函数从未访问过 i ;它只是存储了一个指令,当被调用时,它应该查找 i 并将其相关值添加到其参数中(因为我们从未解析过 i,所以它不知道在定义时 i 是 0)

截屏2024-08-21 19.30.27

我们现在有了第二个函数对象,它是在我们第二次循环时创建的。这个对象的所有内容(参数、主体和封闭帧)都与我们第一次创建的函数对象相同,但尽管有这些相似之处,它仍然是内存中一个独立的对象。我们还将名称 func 重新绑定到这个新的函数对象。因为我们原来的函数对象仍然有一个引用指向它(来自 functions 列表的索引 0),所以它不会被垃圾回收。

截屏2024-06-04 01.57.12

所以这个图表代表了我们完成循环后的程序状态。这个图表有点复杂,但我们可以总结一些会影响我们输出的关键点:

  • 名称 i 在全局帧中绑定到值 4(即使在我们退出循环后,这个绑定仍然存在)
  • 我们在 functions 列表中有五个不同的函数对象
  • 每个函数都有相同的函数体,执行该函数体涉及查找 i

在这里,我们将 f 绑定到了我们列表中索引 0 处的函数对象。一旦完成这个绑定,我们就准备好执行 for 循环的主体了。在这里,我们将要进行一个函数调用 f(12),首先我们将访问表达式 f 以确定要调用的函数,并evaluate 12 以确定要传递的值

截屏2024-06-04 02.06.33

现在我们知道了我们要调用哪个函数,以及需要传给这个函数什么值,下一步就是建立新的帧(以及设置他的父指针)。更新图示,包含一个新帧,并将其父指针指向我们要调用的函数的封闭帧

截屏2024-06-04 02.01.25

继续往下,我们有自己的新帧(打上F1标签),下一步就是将函数的参数(这个例子就是x)绑定到传入的参数值。

截屏2024-06-04 02.03.29

在F1中查找x,我们找到了局部绑定的12。但是i在局部没有绑定。那么我们该怎么办呢?我们遵循父指针,发现全局帧中有i这个名称。它引用了i的值为4,所以我们将使用它。

然后我们将这两个值相加,得到一个表示16的新的整数对象,我们将其返回。

image-20240821193241001

我们将在这里停下来,但请注意,无论我们调用这些函数对象中的哪一个,结果都将是相同的。它们都不记得i在创建时的值;它们只是简单地说要查找当前i的值并将其添加到它们的输入中!因此,当我们继续循环并依次调用这些函数对象时,它们都会产生相同的输出!

5 闭包

可以想象,写下那段代码的人并不打算看到5个16,而是希望看到一些不断变化的数字。

函数对象“记住”了它被定义的帧(其封闭帧),因此,当函数被调用时,它可以访问在该帧中定义的变量,并可以从其自身的主体中引用它们。我们将一个函数和它的封闭帧的组合称为闭包,事实证明这是一个非常有用的结构

为了解释这个,我们举个例子

我们在这里定义了一个函数inner,在另外一个函数outer里面。

python
x = 0
def outer():
  x = 1
  def inner()
    print('inner:', x)
  inner()
  print('outer: ', x)
print('global:', x)
outer()
inner()
print('global:', x)
  1. 因为def语句, 为此创建一个新的函数对象,新的函数对象将具有一个指向封闭帧的指针,该帧指针指向全局帧,因为我们在创建这个对象时是在全局帧中运行的。并将名称outer与该函数进行了关联

截屏2024-06-04 04.23.28

  1. print('global': x) : 我们需要解析并打印x。因为x=1还没执行,第一次输出结果是global: 0

  2. outer():通过解析outer,找到函数对象,这里例子没有参数需要,所以我们直接进入下一步。

截屏2024-06-04 04.30.23

  1. 调用outer函数并执行主体, 为此新建一个帧 F1。

    1. x = 1执行过程,新建一个整数对象1,并将其与 F1 中的 x 关联起来

    2. def inner()...语句,创建函数对象inner,其封闭帧指向F1,并将名称inner指向新函数对象

      截屏2024-06-04 04.36.08

    3. 调用inner(),还是一样,解析函数名,和参数。现在我们要新建F2, 将其父指针指向F1。并执行函数主体,我们看到 print("inner:", x),所以我们在 F2 中查找 x,我们在 F2 中找不到 x,所以我们追溯到其父帧 F1,并发现 x 绑定到 1

      截屏2024-06-04 04.43.22

      我们完成了使用 F2 的对 inner() 的函数调用

      所以我们可以对其进行垃圾回收,并回到 F1 中继续访问 outer 的主体。

    4. 现在我们访问 print('outer:', x)。查找 x 找到值 1,所以我们打印:outer: 1

    截屏2024-06-04 04.48.31

    现在我们已经完成了对 outer() 的调用,因此我们也可以清除 F1,以及我们创建的内部函数对象

    1. 我们继续执行下一行代码,其中要调用 inner()。但请注意,我们又回到了全局帧中——在这里,事实证明没有叫做 inner 的东西!这将导致一个错误,一个 NameError,表示 inner 未定义。请记住,inner 只存在于帧 F1 中,而不是我们现在所处的全局帧中。

截屏2024-06-04 04.51.25

小结:

因此,把这个 inner 函数对象不仅仅看作一个独立的对象,还可以将其视为函数与创建它的帧(即它的“封闭环境”)的组合体是很有用的,我们在这里用绿色圈了起来。为什么?因为该函数可以访问父帧中定义的所有这些变量。这个组合体就是我们所说的闭包——即函数对象与其封闭环境的组合体。

闭包的应用

python
def 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 中的是一个我们可以调用的函数。)

截屏2024-06-04 06.53.03

现在我们在 add_n 的函数体中最后要做的事情是返回 inner。我们将通过指向我们刚刚创建的函数对象来表示返回值。

截屏2024-06-04 06.54.51

将add1 绑定到全局帧,并且与刚刚的返回值做关联

截屏2024-06-04 06.55.16

当我们完成并返回一个函数调用时,比如 add_n,这个函数调用及其相关的帧都会消失(并且一大堆东西会被垃圾回收)。

但是在这里,我们不能这样做!我们不能摆脱 F1。为什么呢?因为,从全局帧来看,我们有一种方式可以访问这个函数对象(add1 指向它),而这个函数对象依赖于帧 F1。它仍然需要使用帧中的值,所以帧不能被丢弃(而 Python 也不会丢弃它)

这个整体——函数对象及其封闭环境,被我们称为闭包(closure),我们可以把它看作一个单一的实体,一个可以依赖于在封闭帧中定义的变量的函数

当你调用 add1 函数对象,因为全局帧中的 add1 指向那个函数对象,它可以访问 n(因为我们创建函数时 n 就被绑定为 1)。所以每次我们调用这个函数并运行它的主体 return x + n 时,它会将 n 的值视为 1。这就是为什么这个函数总是会将传入的值加 1

截屏2024-06-04 07.04.56

接着我们调用add_n(2),因此新建F2帧,其n绑定到2,它的父指针指向GF。

截屏2024-06-04 08.58.14

现在我们已经在全局帧中将 add2 绑定到这个第二个函数对象。

通过当前状态的图表,我们可以看到将一个函数与其封闭帧作为一个整体绑定是多么有用。我们现在称为 add1add2 的这些函数(在全局帧中)做了不同的事情。一个将传入的值加1,另一个加2。但如果仅仅查看它们的函数对象本身——参数和主体——它们看起来是一样的!区别它们的关键在于它们的封闭帧,它们绑定了不同的 n 值

截屏2024-06-04 09.00.02

记住我们的规则,新帧的父帧来自于函数对象的封闭帧——即定义该函数的帧。因为我们调用的函数是在 F2 中定义的,所以 F3 指向 F2

这很重要!这就是它获取正确的 n 值的地方。因为现在我们执行函数对象的主体 return x + n,它发现 x 在 F3 中绑定为 3,n 在 F2 中绑定为 2,并返回 5。

截屏2024-06-04 09.13.52

修复遗留难题

修复前

python
functions = []
for i in range(5):
    def func(x):
        return x + i
    functions.append(func)

for f in functions:
    print(f(12))

修复后

python
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))

或者

python
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))