Skip to content

1.3 定义新的函数

INFO

译者:mancuojclcs

来源:1.3 Defining New Functions

对应:Lab 01

我们在 Python 中已经看到,一个真正强大的编程语言必须具备的几个核心要素:

  1. 数字和算术运算属于原始的内置数据和函数
  2. 通过函数的嵌套调用,我们可以将各种操作组合起来
  3. 通过将名称绑定到值上,实现初步的抽象能力

现在,我们将学习一种更加重要的抽象手段——函数定义。通过它,我们可以把一个复合操作整体绑定到一个名称上,之后就可以把这个复合操作当作一个整体来反复使用。

我们先从一个最简单的例子入手:计算平方的操作。我们会说:“要把一个数平方,就把它自己乘以自己。”在 Python 中,这可以写成:

py
>>> def square(x):
        return mul(x, x)

这条语句定义了一个名叫 square 的新函数。这个函数不是解释器自带的,而是我们自己定义的。它描述的就是“把某个东西乘以自己”这个复合操作。这里的 x 叫做形式参数(formal parameter),它为“那个被乘的东西”提供了一个名称。

如何定义函数:函数定义由两部分组成:

  1. def 语句:指明函数的名称和一个以逗号分隔的形式参数列表
  2. 紧接着缩进的 return 语句(称为函数体),里面写的是返回表达式 —— 每当函数被调用时,这个表达式就会被求值。
py
def <函数名>(<形式参数列表>):
    return <返回表达式>

函数的第二行必须进行缩进,大多数程序员使用四个空格。返回表达式会作为新定义的函数的一部分存储,并且仅在最终调用该函数时才进行求值。

定义好 square 之后,我们就可以像使用内置函数一样来调用它:

py
>>> square(21)
441
>>> square(add(2, 5))
49
>>> square(square(3))
81

我们还可以把 square 当作一个积木,用来搭建更复杂的函数。例如,很轻松地就能定义一个函数 sum_squares,它接收两个数,返回这两个数的平方和:

py
>>> def sum_squares(x, y):
        return add(square(x), square(y))

>>> sum_squares(3, 4)
25

用户自己定义的函数和内置函数在使用上完全没有区别。实际上,光看 sum_squares 的定义,你根本看不出 square 是解释器自带的、从模块里导入的,还是用户自己写的。

无论是 def 语句还是赋值语句(=),都会把一个名称绑定到一个值上,如果这个名称之前已经绑定过别的东西,旧的绑定就会丢失。例如:

py
>>> def g():
        return 1
>>> g()
1
>>> g = 2
>>> g
2
>>> def g(h, i):
        return h + i
>>> g(1, 2)
3

可以看到,同一个名称 g 在不同时刻可以指向完全不同的东西,后面的绑定会彻底覆盖前面的绑定。这正是 Python 动态语言特性的体现。

1.3.1 环境

现在,我们这个 Python 子集已经复杂到程序的含义不再显而易见了。比如:

  • 如果形式参数的名称和某个内置函数重名,会发生什么?
  • 两个函数能不能用同一个名称而不引起混乱?

为了解答这类问题,我们必须更仔细地说明环境(environment)到底是怎么工作的。

在 Python 中,对一个表达式求值的环境由一系列(frame)组成,我们通常把帧画成一个个方框。每个帧里存放着一系列的绑定(binding):名称 → 对应的值。存在一个单独的全局帧(global frame)。赋值和导入语句都会往当前环境的第一个帧(也就是全局帧)里添加绑定。目前,我们的环境只包含全局帧。

环境图(environment diagram)显示了当前环境中的绑定,还有名称所绑定的值。本书中的环境图是交互式的:你可以逐步执行左侧程序的每一行,然后在右侧查看环境的变化。你还可以单击“Edit this code”以将示例加载到 Online Python Tutor 中,它是由 Philip Guo 创建的用于生成环境图的工具。希望你能够自己去创建示例,研究对应生成的环境图。

函数也会出现在环境图中。import 语句将名称与内置函数绑定。def 语句将名称与用户自定义的函数绑定。导入 mul 并定义 square 后的结果环境如下所示:

每个函数都是一行以 func 开头的文本,后面紧跟函数名和形式参数。内置函数(如 mul)没有明确的形式参数名,所以统一写成 ...

函数名称重复出现了两次:一次在环境中,另一次是作为函数定义的一部分。出现在函数定义中的名称叫做内在名称(intrinsic name),帧中的名称叫做绑定名称(bound name)。两者之间是有区别的:不同的名称可能引用的是同一个函数,但该函数本身只有一个内在名称。

真正参与求值的是帧里绑定的名称,函数的内在名称在求值过程中不起作用。使用 Next 按钮逐步执行下面的示例,可以看到一旦名称 max 与数字值 3 绑定,它就不能再作为函数使用了。

错误信息 TypeError: 'int' object is not callable 报告了名称 max(当前绑定到数字 3)是一个整数而不是函数,所以它不能用作调用表达式中的运算符。

函数签名(Function signatures):不同的函数允许接收的参数个数不同。为了在环境图里清楚地表达这一点,我们会把函数名 + 形式参数一起显示,这就是所谓的函数签名。

  • 用户定义的函数 square 只需要 x 一个参数,多给或少给都会报错,所以写成:func square(x)
  • 内置函数 max 可以接受任意数量的参数,所以统一写成 max(...)
  • 实际上,无论接受多少参数,所有的内置函数都将显示为 <name>(...),因为这些原始函数从未被显式定义过。

1.3.2 调用用户自定义函数

当 Python 解释器对一个指向用户自定义函数的调用表达式进行求值时,会遵循一套特定的计算流程。与处理任何调用表达式一样,解释器会先对运算符(operator)和操作数(operand)表达式进行求值,然后将得到的函数应用于相应的参数上。

调用用户自定义函数会引入第二个局部帧(local frame),该帧仅供该函数访问。将函数应用于参数的具体步骤如下:

  1. 在一个新的局部帧中,将参数值绑定到函数的形参名上
  2. 在以该局部帧为起点的环境中,执行函数体内容

用于计算函数体的环境由两个帧组成:首先是包含形参绑定的局部帧,其次是包含其他所有内容的全局帧。每一次函数调用都会拥有一个彼此独立的局部帧。

为了详细展示这一过程,下面通过环境图演示该示例的执行步骤。在执行完第一行导入语句后,全局帧中只有名称 mul 完成了绑定。

首先,执行 square 函数的定义语句。请注意,整个 def 语句是在一个步骤内处理完成的。函数体只有在函数被调用时才会执行,在定义阶段并不会运行。

接下来,调用 square 函数并传入参数 -2。此时,系统会创建一个新帧,并将形参 x 绑定到数值 -2

随后,程序会在当前环境中查找名称 x。当前环境由图中所示的两个帧组成。在这两处位置,x 的求值结果均为 -2,因此 square 函数返回结果 4。

需要注意的是,square() 帧中的“返回值(Return value)”并不是一种名称绑定,它仅表示创建该帧的函数调用所返回的结果。

即便在这个简单的例子中,也涉及了两个不同的环境。顶级表达式 square(-2) 是在全局环境中求值的,而返回表达式 mul(x, x) 则是在调用 square 时创建的局部环境中求值的。虽然 xmul 在该环境中都有绑定,但它们存在于不同的帧中。

环境中帧的排列顺序决定了表达式中名称查找的结果。此前我们提到,名称会求值为当前环境中与其关联的那个值。现在我们可以表述得更精准:

名称求值(Name Evaluation):名称会求值为当前环境中最早包含该名称的帧内所绑定的值。

我们关于环境、名称和函数的这一套概念框架构成了一个完整的求值模型。虽然一些底层细节(例如绑定的具体实现方式)尚未详述,但该模型已能精确、正确地描述解释器如何处理调用表达式。在第三章中,我们将看到这一模型如何作为蓝图,帮助我们亲手实现一个可运行的编程语言解释器。

1.3.3 示例:调用用户自定义函数

让我们再次通过这两个简单的函数定义,来演示用户自定义函数调用表达式的求值过程。

Python 首先对名称 sum_squares 进行求值,该名称在全局帧中被绑定到一个用户自定义函数上。同时,原始数值表达式 512 分别求值为它们所代表的数字。

接着,Python 调用 sum_squares。此时会引入一个局部帧,将 x 绑定为 5,将 y 绑定为 12

sum_squares 的函数体包含如下调用表达式:

  add     (  square(x)  ,  square(y)  )
________     _________     _________
 运算符        操作数 0       操作数 1

这三个子表达式都在当前环境中进行求值,该环境以标记为 sum_squares() 的帧为起点。运算符子表达式 add 是在全局帧中找到的名称,绑定了内置的加法函数。在执行加法之前,必须依次对两个操作数子表达式进行求值。这两个操作数也都在以 sum_squares 帧为起点的当前环境中求值。

在操作数 0 中,square 指向全局帧中的一个用户自定义函数,而 x 在局部帧中指向数字 5。Python 通过引入另一个局部帧x 绑定到 5,从而将 square 应用于 5

在该环境下,表达式 mul(x, x) 的求值结果为 25

随后,求值程序转向操作数 1。此时 y 指向数字 12。Python 再次执行 square 的函数体,这次又引入了一个新的局部帧,将 x 绑定到 12。因此,操作数 1 的求值结果为 144

最后,将加法应用于参数 25144,得出 sum_squares 的最终返回值:169

这个例子展示了我们目前所构建的许多核心概念。名称被绑定到值上,而这些绑定分布在许多独立的局部帧以及一个包含共享名称的全局帧中。每当函数被调用时,都会引入一个新的局部帧,即使是同一个函数被调用两次也不例外

所有这些机制的建立,都是为了确保在程序执行过程中,名称能够在正确的时间解析为正确的值。这个例子也说明了为什么我们的模型需要引入这种复杂性:虽然三个局部帧都包含名称 x 的绑定,但 x不同帧中绑定的是不同的值。局部帧有效地实现了这些名称的隔离

1.3.4 局部名称

函数实现中的一个细节不应影响其行为,那就是实现者为函数的形参所选取的名称。因此,以下两个函数的行为应当是完全一致的:

py
>>> def square(x):
        return mul(x, x)
>>> def square(y):
        return mul(y, y)

这一原则——即函数的含义应独立于作者选择的参数名称——对编程语言有着深远的影响。最直接的后果就是:函数的参数名称必须仅在该函数体内部局部有效

如果参数不是其对应函数体的局部变量,那么 square 中的参数 x 可能会与 sum_squares 中的参数 x 混淆。关键在于,这种情况并不会发生:不同局部帧中对 x 的绑定是互不干扰的。我们的计算模型经过精心设计,确保了这种独立性。

我们称局部名称的作用域(scope)仅限于定义它的用户自定义函数体。当一个名称不再可访问时,它就超出了作用域。这种作用域行为并不是我们模型中的新发现,而是环境运作方式的必然结果。

1.3.5 命名规范

名称的可互换性并不意味着形参名称无关紧要。恰恰相反,精心挑选的函数名和参数名对于人类理解函数定义至关重要。

以下指南摘自 Python 代码风格指南 PEP8, 它是所有(非叛逆型)Python 程序员的行动指南。一套共同的约定能让开发者社区成员之间的沟通更加顺畅。遵循这些约定,你会发现你的代码内部逻辑也会变得更加一致。

  1. 函数名:使用小写字母,单词之间用下划线分隔。鼓励使用具有描述性的名称
  2. 函数名含义:通常反映解释器对参数执行的操作(如 print、add、square),或者是返回结果的含义(如 max、abs、sum
  3. 参数名:使用小写字母,单词之间用下划线分隔。优先使用单个单词名称
  4. 参数名含义:应反映参数在函数中的作用,而不仅仅是允许传入的参数类型
  5. 单字母参数:当其作用显而易见时可以接受,但要避免使用 l(小写 L)、O(大写 O)或 I(大写 I),以免与数字混淆

即便是在 Python 标准库中,违反这些准则的情况也屡见不鲜。就像英语词汇一样,Python 继承了来自各方贡献者的代码,结果并不总是完全统一。

1.3.6 函数作为抽象工具

尽管 sum_squares 非常简单,但它体现了用户自定义函数最强大的特性:函数抽象(functional abstraction)。sum_squares 是基于 square 定义的,但它仅依赖于 square 定义的输入参数与输出值之间的关系。

我们在编写 sum_squares 时,无需关注如何计算一个数的平方。计算平方的具体细节可以被隐匿起来,留到以后再考虑。事实上,对于 sum_squares 而言,square 并不是一段具体的函数体,而是一个函数的抽象,即所谓的函数抽象。在这个抽象层面上,任何能计算平方的函数都是一样好用的。

因此,如果仅考虑返回值,以下两个用于计算平方的函数应该是无差别的。它们都接受一个数值参数,并产出该数的平方。

py
>>> def square(x):
        return mul(x, x)
>>> def square(x):
        return mul(x, x-1) + x

换句话说,函数定义应该能够隐藏细节。函数的使用者可能并没有亲手编写该函数,而是从另一位程序员那里获得的一个黑盒。程序员在使用函数时,不应该需要了解其内部实现。Python 标准库就具备这一特性:许多开发者频繁调用其中的函数,但很少有人去查看它们的具体实现。

函数抽象的三个要素:要精通函数抽象的使用,考虑其三个核心属性通常大有裨益:

  1. 定义域(Domain):函数可以接受的参数集合
  2. 值域(Range):函数可以返回的值的集合
  3. 意图(Intent):函数在输入与输出之间计算出的关系(以及它可能产生的任何副作用)

在复杂程序中,通过定义域、值域和意图来理解函数抽象是正确使用它们的关键。例如,我们用于实现 sum_squares 的任何 square 函数都应具备以下属性:

  • 定义域:任何单个实数
  • 值域:任何非负实数
  • 意图:输出是输入的平方

这些属性并不规定意图是如何实现的,那些细节已被抽象掉了。

1.3.7 运算符

数学运算符(例如 +-)是我们接触的第一种组合方法示例,但我们尚未为包含这些运算符的表达式定义求值程序。

Python 中带有中缀运算符的表达式都有各自的求值程序,但你通常可以将它们视为调用表达式的简写。当你看到:

py
>>> 2 + 3
5

只需将其视为以下形式的简写

py
>>> add(2, 3)
5

中缀表示法可以像调用表达式一样嵌套。Python 遵循标准的数学运算符优先级规则,这决定了如何解释含有多个运算符的复合表达式。

py
>>> 2 + 3 * 4 + 5
19

其求值结果与下式相同:

py
>>> add(add(2, mul(3, 4)), 5)
19

调用表达式的嵌套比运算符版本更明确,但也更难阅读。Python 还允许使用括号对子表达式进行分组,以覆盖默认的优先级规则,或使表达式的嵌套结构更加清晰。

py
>>> (2 + 3) * (4 + 5)
45

等同于:

py
>>> mul(add(2, 3), add(4, 5))
45

在除法方面,Python 提供了两个中缀运算符:///。前者是普通除法,即使被除数能被整除,结果也是浮点数(小数):

py
>>> 5 / 4
1.25
>>> 8 / 4
2.0

// 运算符则会将结果向下取整到整数:

py
>>> 5 // 4
1
>>> -5 // 4
-2

这两个运算符分别是 truedivfloordiv 函数的简写。

py
>>> from operator import truediv, floordiv
>>> truediv(5, 4)
1.25
>>> floordiv(5, 4)
1

在编写程序时,你可以放心使用中缀运算符和括号。在处理简单的数学运算时,地道的 Python 代码(Idiomatic Python)更倾向于使用运算符而非调用表达式。

基于 MIT 许可发布