1.5 控制
到目前为止,我们定义的函数表达能力还非常有限,因为我们还没有引入比较(comparison)机制,也无法根据比较结果执行不同的操作。控制语句(Control statements)将赋予我们这种能力。这类语句能够根据逻辑比较的结果来控制程序的执行流程。
语句(Statements)与我们之前研究的表达式(Expressions)有着本质的区别。语句没有“值(value)”。执行控制语句的目的不是为了计算出某个结果,而是决定解释器接下来应该做什么。
1.5.1 语句
此前,我们主要关注的是如何对表达式求值。不过,我们其实已经见过三种语句了:赋值语句、def 语句和 return 语句。这些 Python 代码行本身并不是表达式,尽管它们都包含表达式作为组成部分。
语句不是被“求值”,而是被执行。每条语句都描述了对解释器状态的某种改变,执行语句就是应用这种改变。正如我们在 return 和赋值语句中看到的,执行语句可能涉及对其内部包含的子表达式进行求值。
表达式也可以作为语句执行,在这种情况下,它们会被求值,但其求出的值会被丢弃。执行一个纯函数(pure function)不会产生任何影响,但执行一个非纯函数(non-pure function)则可能因为函数的应用而产生副作用。
例如:
>>> def square(x):
mul(x, x) # 注意!这个调用没有任何返回值这个例子在 Python 中是合法的,但可能不符合原意。该函数体由一个表达式组成。表达式本身是一个合法的语句,但该语句的效果仅仅是调用了 mul 函数,然后结果就被丢弃了。如果你想对表达式的结果做点什么,必须明确表达:你可以通过赋值语句将其存储起来,或者通过 return 语句将其返回:
>>> def square(x):
return mul(x, x)有时,当调用像 print 这样的非纯函数时,函数体只有一个表达式是有意义的:
>>> def print_square(x):
print(square(x))从最高层面来说,Python 解释器的工作就是执行由语句构成的程序。然而,计算过程中大部分有趣的工作都来自对表达式的求值。语句的作用是管理程序中不同表达式之间的关系,以及处理这些表达式的结果。
1.5.2 复合语句
通常,Python 代码是一个语句序列。简单语句(simple statement)是不以冒号结尾的单行代码。而复合语句(compound statement)是由其他语句(简单的或复合的)组合而成。复合语句通常跨越两行或多行,并以一个以冒号结尾的单行头部(header)开始,该头部标识了语句的类型。一个头部及其下方缩进的“语句体”(suite)合称为一个子句(clause)。一个复合语句可以由一个或多个子句组成:
<头部>:
<语句>
<语句>
...
<分隔头部>:
<语句>
<语句>
...
...我们可以用这些术语来理解我们之前介绍过的语句。
- 表达式、
return语句和赋值语句都是简单语句 def语句是一个复合语句。紧跟在def头部之后的语句体定义了函数体
每种头部的特定求值规则决定了其语句体在何时执行,甚至是否执行。我们称之为头部控制(controls)其语句体。例如,在 def 语句中,我们看到返回表达式并不会立即求值,而是被存储起来,直到该函数最终被调用时才执行。
现在我们也可以理解多行程序了:要执行一个语句序列,首先执行第一个语句。如果该语句没有重定向控制流,则继续执行序列中剩余的语句(如果有)。
这个定义揭示了递归定义序列的核心结构:一个序列可以分解为它的第一个元素和其余元素。而一个语句序列的“其余部分”,本身也是一个语句序列!因此,我们可以递归地应用这条执行规则。这种将序列视为递归数据结构的视角,在后续章节中还会再次出现。
这条规则产生的一个重要结果是:尽管语句是按顺序执行的,但由于控制流的重定向,后面的语句可能永远不会被执行到。
实践指导:在对语句体进行缩进时,所有行必须缩进相同的幅度,并使用相同的方式(请使用空格,不要使用制表符 Tab)。缩进的任何细微差异都会导致语法错误。
1.5.3 定义函数 II:局部赋值
最初我们提到,用户自定义函数的主体仅由一个带有单一表达式的 return 语句组成。实际上,函数可以定义一系列操作,其范围远不止一个表达式。
每当调用用户自定义函数时,其定义中的语句序列(suite)会在一个局部环境中执行——这个环境始于由函数调用创建的局部帧(local frame)。return 语句会重定向控制流:一旦执行到第一个 return 语句,函数调用的过程就会终止,而 return 表达式的值就是该函数的返回值。
赋值语句可以出现在函数体内部。例如,下面这个函数通过两步计算,返回两个数值之差绝对值占第一个数的百分比:
赋值语句的作用是将一个名字绑定到当前环境第一个帧(first frame)中的值。因此,函数体内的赋值语句不会影响全局帧。函数只能操作其局部环境,这一特性对于构建模块化程序至关重要。在模块化程序中,纯函数仅通过它们接收的参数和返回的值进行交互。
当然,percent_difference 函数也可以写成单一表达式的形式(如下所示),但这样 return 表达式会变得更加复杂。
>>> def percent_difference(x, y):
return 100 * abs(x-y) / x
>>> percent_difference(40, 50)
25.0到目前为止,局部赋值还没有增加函数定义的表达能力。但当它与其他控制语句结合时,其威力就会显现。此外,局部赋值通过为中间量命名,在理清复杂表达式的含义方面也起着关键作用。
1.5.4 条件语句
Python 有一个用于计算绝对值的内置函数:
>>> abs(-2)
2我们希望能够自己实现这样一个函数,但是没有清晰的方法来定义一个包含“比较”和“选择”的函数。我们想表达的是,如果 x 为正,则 abs(x) 返回 x ;此外,如果 x 为 0,则 abs(x) 返回 0;否则,abs(x) 返回 -x。在 Python 中,我们可以用条件语句(conditional statement)来表达这种选择。
这个 absolute_value 的实现引出了几个重要概念:
条件语句:Python 中的条件语句由一系列“头部”(header)和“语句体”(suite)组成:一个必需的 if 子句,若干可选的 elif 子句,以及最后可选的 else 子句:
if <表达式>:
<语句体>
elif <表达式>:
<语句体>
else:
<语句体>执行条件语句时,每个子句会按顺序被考虑。执行一个条件子句的过程如下:
- 计算头部表达式的值
- 如果结果为真值(true value),则执行该语句体。随后,跳过条件语句中所有后续的子句
- 如果到达了
else子句(仅当所有if和elif表达式的计算结果为假值时才会发生),则执行其语句体
布尔上下文(Boolean contexts):上述执行流程中提到了“假值”和“真值”。条件块头部语句中的表达式处于布尔上下文中:它们的真假值决定了控制流,除此之外,它们的值既不会被赋值也不会被返回。Python 中包含若干“假值”,包括 0、None 以及布尔值 False。所有其他数字均为真值。在第 2 章中,我们将看到 Python 中的每种内置数据类型都既有真值也有假值。
布尔值(Boolean values):Python 有两个布尔值,分别是 True 和 False 。布尔值在逻辑表达式中代表真值。内置的比较操作 >, <, >=, <=, ==, != 都会返回这些值。
>>> 4 < 2
False
>>> 5 >= 5
True第二个例子读作“5 大于或等于 5”,对应于 operator 模块中的函数 ge。
>>> 0 == -0
True最后一个示例读作“0 等于 -0”,对应于 operator 模块中的 eq。请注意,Python 会区分赋值符号 = 与相等比较符号 ==,这也是许多编程语言共有的约定。
布尔运算符(Boolean operators):Python 还内置了三个基础逻辑运算符:
>>> True and False
False
>>> True or False
True
>>> not False
True逻辑表达式有相应的求值程序。这些程序利用了这样一个特性:逻辑表达式的真假值有时无需计算所有子表达式即可确定,这种特性被称为短路(short-circuiting)。
计算 <left> and <right>:
- 计算子表达式
<left> - 如果结果是假值 v,则整个表达式的值就是 v
- 否则,表达式的计算结果为子表达式
<right>的值
计算 <left> or <right>:
- 计算子表达式
<left> - 如果结果为真值 v,则表达式的计算结果就是 v
- 否则,表达式的计算结果为子表达式
<right>的值。
计算 not <exp>:
- 计算
<exp>,如果结果为假值,则值为True;否则为False
这些值、规则和运算符为我们提供了组合比较结果的方法。执行比较并返回布尔值的函数通常以 is 开头且不加下划线(例如 isfinite, isdigit, isinstance 等)。
1.5.5 迭代
除了选择执行哪些语句外,控制语句还用于表达重复。如果我们编写的每一行代码只执行一次,那么编程将是一项非常低效的工作。只有通过重复执行语句,我们才能释放计算机的全部潜力。我们之前已经见过了一种重复形式:一个函数只需定义一次,就可以被多次调用。迭代控制结构是另一种多次执行相同语句的机制。
考虑斐波那契数列(Fibonacci numbers),其中每个数字都是前两个数字之和:
每个值都是通过重复应用“前两项求和”的规则构造的,第一个和第二个值固定为 0 和 1。例如,第八个斐波那契数是 13。
我们可以使用 while 语句来列举 n 个斐波那契数。我们需要跟踪已经创建了多少个值(k),和第 k 个值(curr)及其前驱(pred)。
单步执行此函数并观察斐波那契数如何逐一生成,并绑定到 curr:
请记住,单行赋值语句可以用逗号分隔多个名称和值同时赋值。这行代码:
pred, curr = curr, pred + curr
将名称 pred 重新绑定到 curr 的值,同时将 curr 重新绑定到 pred + curr 的值。在执行任何绑定操作之前,= 右侧的所有表达式都会先求值。这种先后顺序——在更新左侧任何绑定之前计算右侧的所有内容——对于该函数的正确性至关重要。
while 子句包含一个头部表达式,后跟一个语句体:
while <表达式>:
<语句体>执行 while 子句的步骤:
- 计算头部表达式的值
- 如果是真值,执行语句体,然后返回步骤1
在步骤 2 中,在再次计算头部表达式之前,会执行 while 子句的整个语句体。为了防止 while 语句体无限期地执行,语句体应该在每次循环中改变某些绑定。不终止的 while 语句被称为死循环(infinite loop)。按下 <Control>-C 可以强制 Python 停止循环。
1.5.6 测试
测试一个函数就是去验证函数的行为是否符合预期。我们现在的函数语言已经足够复杂,需要开始对实现进行测试了。
测试是一种系统地执行验证的机制。测试通常以另一个函数的形式存在,该函数包含对被测函数的一个或多个示例调用,然后根据预期结果验证返回值。与大多数旨在通用的函数不同,测试涉及选择并验证具有特定参数值的调用。测试还具有文档作用:它们演示了如何调用函数以及哪些参数值是合适的。
断言(Assertions):程序员使用 assert 语句来验证是否符合预期,例如验证被测试函数的输出。assert 语句包含一个处于布尔上下文中的表达式,后跟一行引用的文本(单引号或双引号均可),如果表达式求值为假,则会显示该文本。
>>> assert fib(8) == 13, '第八个斐波那契数应该是 13'当断言的表达式为真时,执行 assert 语句没有任何效果。当它为假时,assert 会引发一个错误并停止执行。
fib 的测试函数应该测试几个参数,包括 n 的极限值。
>>> def fib_test():
assert fib(2) == 1, '第二个斐波那契数应该是 1'
assert fib(3) == 1, '第三个斐波那契数应该是 1'
assert fib(50) == 7778742049, '在第五十个斐波那契数发生 Error'当在文件中编写 Python 代码(而不是直接在解释器中)时,测试通常写在同一个文件中,或者后缀为 _test.py 的相邻文件中。
文档测试(Doctests):Python 提供了一种便捷的方法,可以将简单的测试直接放在函数的文档字符串(docstring)中。文档字符串的第一行应包含函数的简要描述,后跟一个空行,之后可以是对参数和行为的详细描述。此外,文档字符串还可以包含调用该函数的交互式示例:
>>> def sum_naturals(n):
"""返回前 n 个自然数的和。
>>> sum_naturals(10)
55
>>> sum_naturals(100)
5050
"""
total, k = 0, 1
while k <= n:
total, k = total + k, k + 1
return total然后,可以通过 doctest 模块 来验证交互:
>>> from doctest import testmod
>>> testmod()
TestResults(failed=0, attempted=2)如果只想验证单个函数的文档测试,我们可以使用 doctest 模块中名为 run_docstring_examples 的函数。这个函数的调用方式稍微有点复杂:第一个参数是要测试的函数;第二个参数应始终是 globals()(这是一个返回全局环境的内置函数);第三个参数是 True,表示我们希望看到“详细”输出(即所有运行测试的目录)。
>>> from doctest import run_docstring_examples
>>> run_docstring_examples(sum_naturals, globals(), True)
Finding tests in NoName
Trying:
sum_naturals(10)
Expecting:
55
ok
Trying:
sum_naturals(100)
Expecting:
5050
ok当函数的返回值与预期结果不符时,run_docstring_examples 函数会将该问题报告为一次测试失败。
在文件中编写 Python 代码时,可以通过在启动 Python 时添加 doctest 命令行选项来运行文件中的所有文档测试:
python3 -m doctest <python_source_file>有效测试的关键是在实现新函数后立即编写(并运行)测试。在实现之前编写一些测试甚至是一个好习惯,这样可以让你心中有明确的输入和输出示例。仅针对单个函数的测试称为单元测试(unit test)。详尽的单元测试是优秀程序设计的标志。