Skip to content

1.3 定义新的函数 ​

INFO

来源:1.3 Defining New Functions

我们在 Python 里已经看到,任何强大的编程语言都必须具备几样要素:

  1. 数字和算术运算,是最基本的内置数据值和函数
  2. 函数的嵌套调用,提供了组合各种操作的手段
  3. 把名称绑定到值,提供了有限的抽象手段

现在,我们学一种强大得多的抽象手段——函数定义。有了它,就能把一个复合操作绑定到一个名称上,之后把它当成一个整体来用。

我们先看最简单的例子:怎么表达「平方」。可以说:「要把一个数平方,就把它乘以自己。」用 Python 写出来就是:

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

这条语句定义了一个叫 square 的新函数。它不是解释器自带的,而是我们自己定义的,描述的就是「把某个东西乘以自己」这个复合操作。定义里的 x 叫形式参数(formal parameter),给「要乘的那个东西」起了个名字。这个定义会创建出函数,并把它关联到名称 square。

怎么定义函数。函数定义分两部分:

  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 语句和赋值语句都会把名称绑定到值上;名称原有的绑定会就此丢失。比如下面的 g,先是一个无参函数,接着变成数字,最后又变成一个双参数函数:

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

1.3.1 环境 ​

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

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

要解答这类问题,我们得把环境(environment)是怎么工作的讲得更细一些。

求一个表达式的值时,所在的环境由一系列帧(frame)组成,通常画成一个个方框。每个帧里存着一组绑定(binding):把名称和它对应的值关联起来。全局只有一个全局帧(global frame)。赋值语句和导入语句,都会往当前环境的第一个帧(也就是全局帧)里加绑定。目前我们的环境里只有全局帧。

环境图(environment diagram)会画出当前环境里的绑定,以及名称绑定的值。本书的环境图是可交互的:你可以逐步执行左边程序的每一行,在右边看着环境随之变化。也可以点「Edit this code」,把示例载入 Online Python Tutor——这个工具由 Philip Guo 开发,专门用来生成环境图。也建议你自己动手建些例子,研究生成的环境图。

函数也会出现在环境图里。import 语句把名称绑定到内置函数,def 语句把名称绑定到用户自定义函数。导入 mul 并定义 square 后,环境是这样的:

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

函数名会出现两次:一次在环境帧里,一次在函数本身上。函数里的那个名字叫内在名称(intrinsic name),帧里的叫绑定名称(bound name)。两者不一样:不同的名称可以指向同一个函数,但函数本身只有一个内在名称。

求值时用的是帧里绑定的名称,函数的内在名称不起作用。用 Forward 按钮逐步执行下面的例子:一旦名称 max 绑定到数字 3,它就不能再当函数用了。

报错信息 TypeError: 'int' object is not callable 说的是:名称 max(现在绑定到数字 3)是个整数,不是函数,所以不能当调用表达式里的运算符。

函数签名(function signature):不同函数允许接收的参数个数不一样。为了在环境图里体现这点,我们会把函数名 + 形式参数一起画出来,这就是函数签名。

  • 用户自定义函数 square 只需要 x 一个参数,多给或少给都会报错,所以写成:func square(x)
  • 内置函数 max 可以接受任意数量的参数,所以写成 max(...)
  • 其实不管收多少参数,所有内置函数都画成 <name>(...),因为这些基本函数从来没有被显式定义过。

1.3.2 调用用户自定义函数 ​

如果调用表达式的运算符指向一个用户自定义函数,Python 解释器求值时,会走一套特定的流程。和所有调用表达式一样,它先求值运算符(operator)和操作数(operand)表达式,再把得到的函数作用到对应的参数上。

调用用户自定义函数时,会引入第二个局部帧(local frame),这个帧只有该函数能访问。把函数作用到参数上的步骤是:

  1. 在一个新的局部帧里,把参数绑定到函数的形式参数名上;
  2. 在以这个帧为起点的环境里,执行函数体。

求值函数体所在的环境由两个帧组成:先是有形式参数绑定的局部帧,再是装着其余一切的全局帧。每次调用函数,都会有一个独立的局部帧。

下面用环境图把这个例子一步步演示出来。执行完第一条导入语句后,全局帧里只绑定了名称 mul。

首先执行 square 的定义语句。注意,整个 def 语句是一步处理完的。函数体要到函数被调用时才执行,定义的时候不会运行。

接着,用参数 -2 调用 square,于是新建一个帧,把形式参数 x 绑定到值 -2。

然后,在当前环境里查找名称 x。当前环境由图中那两个帧组成。两处 x 都求值为 -2,所以 square 返回 4。

注意,square() 帧里的「Return value(返回值)」不是名称绑定,它只表示创建这个帧的那次调用返回了什么。

就算这么简单的例子,也用到了两个不同的环境:顶层表达式 square(-2) 在全局环境里求值,而返回表达式 mul(x, x) 在调用 square 时创建的局部环境里求值。x 和 mul 在这个环境里都有绑定,但分处不同的帧。

环境里帧的顺序,决定了名称查找的结果。前面我们说过,名称会求值为当前环境里跟它关联的值。现在可以说得更精确:

名称求值(name evaluation):名称求值为当前环境中最早含有该名称的那个帧里绑定的值。

环境、名称和函数这一套概念框架,构成了一个完整的求值模型。虽然有些底层细节(比如绑定到底怎么实现)还没细说,但这个模型已经能精确、正确地描述解释器如何处理调用表达式。第三章会讲到,怎么用它当蓝图,亲手实现一个能跑的编程语言解释器。

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

我们再来看这两个简单的函数定义,演示一下用户自定义函数的调用表达式是怎么求值的。

Python 先求值名称 sum_squares,它在全局帧里绑定到一个用户自定义函数。基本数字表达式 5 和 12 各自求值为它们表示的数。

接着,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。

最后,把加法作用到参数 25 和 144 上,得到 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)。它基于 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

这两个运算符分别是 truediv 和 floordiv 函数的简写。

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

写程序时,中缀运算符和括号你尽管用。简单的数学运算,地道的 Python 代码(idiomatic Python)更爱用运算符,而不是调用表达式。

基于 MIT 许可发布