1.3 定义新的函数
INFO
我们在 Python 里已经看到,任何强大的编程语言都必须具备几样要素:
- 数字和算术运算,是最基本的内置数据值和函数
- 函数的嵌套调用,提供了组合各种操作的手段
- 把名称绑定到值,提供了有限的抽象手段
现在,我们学一种强大得多的抽象手段——函数定义。有了它,就能把一个复合操作绑定到一个名称上,之后把它当成一个整体来用。
我们先看最简单的例子:怎么表达「平方」。可以说:「要把一个数平方,就把它乘以自己。」用 Python 写出来就是:
>>> def square(x):
return mul(x, x)这条语句定义了一个叫 square 的新函数。它不是解释器自带的,而是我们自己定义的,描述的就是「把某个东西乘以自己」这个复合操作。定义里的 x 叫形式参数(formal parameter),给「要乘的那个东西」起了个名字。这个定义会创建出函数,并把它关联到名称 square。
怎么定义函数。函数定义分两部分:
def语句:指明函数名和逗号分隔的形式参数列表;- 紧跟其后、缩进的
return语句(也就是函数体):写明函数的返回表达式,函数每次被调用时才对它求值。
def <函数名>(<形式参数列表>):
return <返回表达式>第二行必须缩进,多数程序员缩进四个空格。返回表达式不会立刻求值,而是先存进新定义的函数里,等函数真正被调用时才求值。
定义好 square 之后,我们就能像用内置函数一样调用它:
>>> square(21)
441
>>> square(add(2, 5))
49
>>> square(square(3))
81我们还能把 square 当成积木,去搭别的函数。比如,很容易定义一个 sum_squares:给它两个数,返回这两个数的平方和:
>>> def sum_squares(x, y):
return add(square(x), square(y))
>>> sum_squares(3, 4)
25用户自定义函数和内置函数,用法完全一样。实际上,光看 sum_squares 的定义,你根本看不出 square 是解释器自带的、从模块导入的,还是用户自己定义的。
def 语句和赋值语句都会把名称绑定到值上;名称原有的绑定会就此丢失。比如下面的 g,先是一个无参函数,接着变成数字,最后又变成一个双参数函数:
>>> def g():
return 1
>>> g()
1
>>> g = 2
>>> g
2
>>> def g(h, i):
return h + i
>>> g(1, 2)
31.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),这个帧只有该函数能访问。把函数作用到参数上的步骤是:
- 在一个新的局部帧里,把参数绑定到函数的形式参数名上;
- 在以这个帧为起点的环境里,执行函数体。
求值函数体所在的环境由两个帧组成:先是有形式参数绑定的局部帧,再是装着其余一切的全局帧。每次调用函数,都会有一个独立的局部帧。
下面用环境图把这个例子一步步演示出来。执行完第一条导入语句后,全局帧里只绑定了名称 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 局部名称
函数实现里有一个细节不该影响它的行为,那就是实现者给形式参数取的名字。所以,下面两个函数的行为应当完全一致:
>>> 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 程序员的行动指南。一套共同约定,能让开发者社区里的沟通更顺畅。照着做,你还会发现自己的代码内部也更一致了。
- 函数名:用小写字母,单词之间用下划线分隔。鼓励用描述性的名称
- 函数名的含义:通常体现对参数施加的操作(如
print、add、square),或结果那个量的名字(如max、abs、sum) - 参数名:用小写字母,单词之间用下划线分隔。优先用单个单词的名称
- 参数名的含义:应体现参数在函数里的作用,而不只是允许传入的参数类型
- 单字母参数:作用显而易见时可以用,但要避开
l(小写 L)、O(大写 O)、I(大写 I),免得和数字混淆
就算是 Python 标准库,也常有违反这些准则的地方。就像英语的词汇一样,Python 从各方贡献者那里继承了各种代码,结果并不总是统一。
1.3.6 函数作为抽象
sum_squares 虽然很简单,却体现了用户自定义函数最强大的特性:函数抽象(functional abstraction)。它基于 square 定义,但只依赖 square 在输入参数和输出值之间建立的关系。
写 sum_squares 时,我们不用操心一个数的平方怎么算。计算平方的细节可以先按下不表,留到以后再说。事实上,对 sum_squares 来说,square 不是某一段具体的函数体,而是函数的抽象——也就是所谓的函数抽象。在这个抽象层面上,只要能把平方算对,用哪个函数都一样好。
所以,只看返回值的话,下面两个算平方的函数应当没有区别。它们都接收一个数,返回它的平方。
>>> def square(x):
return mul(x, x)
>>> def square(x):
return mul(x, x-1) + x换句话说,函数定义应该能藏起细节。用函数的人,未必亲手写过它,可能只是从别人那里拿到一个黑盒。用的时候,不需要知道它是怎么实现的。Python 标准库就是这样:很多开发者天天调用里面的函数,却很少有人去看它们的实现。
函数抽象的三个要素:想用好函数抽象,通常值得先看清它的三个核心属性:
- 定义域(Domain):函数可以接受的参数集合
- 值域(Range):函数可以返回的值的集合
- 意图(Intent):函数在输入和输出之间算出的关系(以及它可能产生的任何副作用)
在复杂程序里,从定义域、值域和意图三方面理解函数抽象,是正确使用它们的关键。比如,用来实现 sum_squares 的任何 square 函数,都应当具备这些属性:
- 定义域:任何单个实数
- 值域:任何非负实数
- 意图:输出是输入的平方
这些属性并不规定意图怎么实现,那些细节已经被抽象掉了。
1.3.7 运算符
数学运算符(比如 +、-)是我们见到的第一种组合方法,但带这些运算符的表达式,我们还没定义求值流程。
Python 里带中缀运算符的表达式各有自己的求值流程,但你通常可以把它们看成调用表达式的简写。看到:
>>> 2 + 3
5把它看成下面这种简写就行:
>>> add(2, 3)
5中缀表示法也能像调用表达式一样嵌套。Python 遵循通常的数学运算符优先级规则,它决定了一个含多个运算符的复合表达式该怎么理解。
>>> 2 + 3 * 4 + 5
19它的求值结果和下式相同:
>>> add(add(2, mul(3, 4)), 5)
19调用表达式的嵌套比运算符版本更明确,但也更难读。Python 还允许用括号给子表达式分组,来覆盖默认的优先级规则,或让表达式的嵌套结构更清楚。
>>> (2 + 3) * (4 + 5)
45等同于:
>>> mul(add(2, 3), add(4, 5))
45说到除法,Python 有两个中缀运算符:/ 和 //。/ 是普通除法,即使除数能整除被除数,结果也是浮点数(小数):
>>> 5 / 4
1.25
>>> 8 / 4
2.0而 // 会把结果向下取整到整数:
>>> 5 // 4
1
>>> -5 // 4
-2这两个运算符分别是 truediv 和 floordiv 函数的简写。
>>> from operator import truediv, floordiv
>>> truediv(5, 4)
1.25
>>> floordiv(5, 4)
1写程序时,中缀运算符和括号你尽管用。简单的数学运算,地道的 Python 代码(idiomatic Python)更爱用运算符,而不是调用表达式。