Skip to content

1.5 控制 ​

INFO

来源:1.5 Control

到目前为止,我们能定义的函数,表达能力还非常有限:还没有引入比较(comparison)机制,也无法根据比较结果执行不同的操作。控制语句(control statements)会给我们这种能力——它根据逻辑比较的结果,控制程序的执行流程。

语句(statements)和我们此前研究的表达式(expressions)有本质区别:语句没有值。执行控制语句不是为了算出某个结果,而是决定解释器接下来做什么。

1.5.1 语句 ​

此前,我们主要关注的是怎么对表达式求值。不过,有三种语句其实已经出现过了:赋值语句、def 语句和 return 语句。这些 Python 代码本身不是表达式,但都含有表达式,作为组成部分。

语句不是被求值,而是被执行。每条语句都描述解释器状态的某种改变,执行语句就是施加这种改变。前面在 return 和赋值语句里已经看到,执行语句时可能还要对其中包含的子表达式求值。

表达式也可以作为语句执行:这时它会被求值,但求出的值会被丢弃。执行纯函数(pure function)没有任何影响,执行非纯函数(non-pure function)却可能因为函数应用而产生效果。

例如:

py
>>> def square(x):
        mul(x, x) # 注意!这个调用没有任何返回值

这个例子是合法的 Python,但多半不是本意。函数体只由一个表达式组成。表达式本身是合法的语句,但这条语句的效果只是调用了 mul 函数,结果随即被丢弃。想对表达式的结果做点什么,就得明说:可以用赋值语句把它存起来,也可以用 return 语句把它返回:

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

有时,函数体调用的是 print 这类非纯函数,这时让函数体只由一个表达式组成,也是合理的:

py
>>> def print_square(x):
        print(square(x))

从最上层看,Python 解释器的任务就是执行由语句构成的程序。但计算中大部分有意思的工作,都来自对表达式的求值。语句管的是:程序中各个表达式之间的关系,以及表达式的结果会怎么样。

1.5.2 复合语句 ​

一般来说,Python 代码是一个语句序列。简单语句(simple statement)是不以冒号结尾的单行代码;复合语句(compound statement)之所以叫这个名字,是因为它由其他语句(简单的或复合的)组合而成。复合语句通常跨多行,以一个冒号结尾的单行头部(header)开头,头部指明语句的类型。头部加上其下缩进的语句体(suite),合称一个子句(clause)。一个复合语句由一个或多个子句组成:

py
<header>:
    <statement>
    <statement>
    ...
<separating header>:
    <statement>
    <statement>
    ...
...

用这些术语,就能理解前面已经介绍过的语句。

  • 表达式、return 语句和赋值语句都是简单语句。
  • def 语句是复合语句。def 头部之后的语句体,就定义了函数体。

每种头部都有专门的求值规则,规定它的语句体在什么时候执行、是否执行。我们说,头部控制(controls)它的语句体。比如 def 语句,前面已经看到,返回表达式不会立即求值,而是先存起来,等定义的函数真正被调用时才用。

现在,多行程序也能理解了:

  • 要执行一个语句序列,先执行第一条语句。如果这条语句没有重定向控制流,就继续执行序列中剩下的语句(如果还有的话)。

这个定义点出了序列(sequence)递归定义的核心结构:一个序列可以拆成第一个元素和其余元素。而语句序列的「其余部分」,本身还是一个语句序列!所以,这条执行规则可以递归地套用。把序列看作递归数据结构的这种视角,后面章节还会再出现。

这条规则有个重要结果:语句按顺序执行,但因为控制流可能被重定向,后面的语句也许永远执行不到。

实践指导:给语句体缩进时,所有行缩进的幅度和方式必须一致(用空格,不要用制表符 Tab)。缩进只要有一点不一致,就会报错。

1.5.3 定义函数 II:局部赋值 ​

最初我们说过,用户自定义函数的函数体只由一个 return 语句组成,里面只有一个返回表达式。其实,函数可以定义一连串操作,远不止一个表达式。

每当把一个用户自定义函数施加到参数上时,它定义中语句体的各个子句,会在一个局部环境中依次执行——这个环境从调用该函数所建的局部帧(local frame)开始。return 语句会重定向控制流:一旦执行到第一个 return 语句,函数应用的过程就终止,return 表达式的值就是该函数的返回值。

赋值语句可以出现在函数体里。比如下面这个函数,用两步计算返回两个数之差的绝对值,并表示为第一个数的百分比:

赋值语句的作用,是把名称绑定到当前环境第一个帧里的值。因此,函数体里的赋值语句影响不了全局帧。函数只能操作自己的局部环境——这一点对编写模块化(modular)程序至关重要:在模块化程序里,纯函数之间的交互,只靠接收的参数和返回的值。

当然,percent_difference 也能写成单个表达式,如下所示,但返回表达式会更复杂。

py
>>> def percent_difference(x, y):
        return 100 * abs(x-y) / x
>>> percent_difference(40, 50)
25.0

到目前为止,局部赋值还没有增强函数定义的表达能力;等它和其他控制语句结合起来,就会显示威力。此外,局部赋值给中间量命名,也起着关键作用,能把复杂表达式的含义理清楚。

1.5.4 条件语句 ​

Python 有一个计算绝对值的内置函数:

py
>>> abs(-2)
2

我们想自己实现这样一个函数,却找不到明显的办法,来定义一个既有比较又有选择的函数。我们想表达的是:如果 x 为正,abs(x) 就返回 x;如果 x 为 0,abs(x) 就返回 0;否则,abs(x) 返回 -x。在 Python 里,这种选择可以用条件语句(conditional statement)表达。

absolute_value 的这个实现,引出了几个重要问题:

条件语句。Python 的条件语句由一系列头部和语句体组成:必需的 if 子句,可选的若干 elif 子句,以及最后可选的 else 子句:

py
if <expression>:
    <suite>
elif <expression>:
    <suite>
else:
    <suite>

执行条件语句时,会依次考虑每个子句。执行一个条件子句的步骤如下:

  1. 求值头部的表达式。
  2. 如果结果是真值,就执行该语句体,然后跳过条件语句里所有后续子句。

如果到了 else 子句(只有所有 if 和 elif 表达式都求值为假值时才会到),就执行它的语句体。

布尔上下文(boolean context)。上面的执行步骤提到了「假值」和「真值」。条件块头部语句里的表达式,就处在布尔上下文中:它们的真假决定控制流,除此之外,值既不会被赋值,也不会被返回。Python 里有若干假值,包括 0、None,以及布尔值 False。其他所有数字都是真值。第 2 章会讲到,Python 中每一种内置数据都有真值,也有假值。

布尔值(boolean value)。Python 有两个布尔值:True 和 False。布尔值在逻辑表达式中表示真值。内置的比较操作 >、<、>=、<=、==、!= 都返回布尔值。

py
>>> 4 < 2
False
>>> 5 >= 5
True

第二个例子读作「5 大于或等于 5」,对应 operator 模块里的函数 ge。

py
>>> 0 == -0
True

最后一个例子读作「0 等于 -0」,对应 operator 模块里的 eq。注意,Python 区分赋值符号 = 和相等比较 ==,这也是许多编程语言共有的约定。

布尔运算符(boolean operator)。Python 还内置了三个基本的逻辑运算符:

py
>>> True and False
False
>>> True or False
True
>>> not False
True

逻辑表达式也有对应的求值步骤。这些步骤利用了一个特点:逻辑表达式的真假,有时不必求值所有子表达式就能确定,这叫短路(short-circuiting)。


求值 <left> and <right>:

  1. 求值子表达式 <left>。
  2. 如果结果是假值 v,整个表达式就求值为 v。
  3. 否则,表达式求值为子表达式 <right> 的值。

求值 <left> or <right>:

  1. 求值子表达式 <left>。
  2. 如果结果是真值 v,整个表达式就求值为 v。
  3. 否则,表达式求值为子表达式 <right> 的值。

求值 not <exp>:

  1. 求值 <exp>;如果结果是假值,值就是 True,否则是 False。

这些值、规则和运算符,让我们能把比较的结果组合起来。做比较并返回布尔值的函数,通常以 is 开头,后面不加下划线(例如 isfinite、isdigit、isinstance 等)。

1.5.5 迭代 ​

控制语句除了决定执行哪些语句,还能表达重复。要是我们写的每一行代码都只执行一次,编程就成了一件效率极低的事。只有反复执行语句,才能发挥计算机的全部潜力。我们已经见过一种重复:函数只定义一次,却可以反复应用。迭代控制结构(iterative control structure)则是另一种多次执行同一批语句的机制。

来看斐波那契数列(Fibonacci numbers),其中每个数都是前两个数之和:

0,1,1,2,3,5,8,13,21,⋯

每个值都是反复套用「前两项求和」这条规则得到的,第一个和第二个固定为 0 和 1。比如第八个斐波那契数是 13。

我们可以用 while 语句列举出 n 个斐波那契数。需要跟踪已经造出了多少个数(k),以及第 k 个数(curr)和它的前一个数(pred)。单步执行这个函数,观察斐波那契数如何一个一个生成,并绑定到 curr:

记住,在赋值语句里,逗号用来分隔多个名称和值。这行代码:

pred, curr = curr, pred + curr

它的效果是:把名称 pred 重新绑定为 curr 的值,同时把 curr 重新绑定为 pred + curr 的值。= 右侧的所有表达式都会先求值,之后才重新绑定。这个先后顺序——先求值 = 右侧的一切,再更新左侧的任何绑定——对这个函数的正确性至关重要。

while 子句由一个头部表达式和一个语句体组成:

py
while <表达式>:
    <语句体>

执行 while 子句的步骤:

  1. 求值头部的表达式。
  2. 如果是真值,就执行语句体,然后回到第 1 步。

在第 2 步中,要先执行完 while 子句的整个语句体,才会再次求值头部表达式。为了让 while 子句的语句体不至于永远执行下去,语句体在每一轮循环里都应该改变某个绑定。不会终止的 while 语句叫死循环(infinite loop)。按 <Control>-C 可以强制 Python 停止循环。

1.5.6 测试 ​

测试(testing)一个函数,就是验证它的行为是否符合预期。我们的函数语言现在足够复杂,该开始测试自己的实现了。

测试(test)是一种系统地做这种验证的机制。测试通常写成另一个函数,里面包含一个或多个对被测函数的示例调用,再把返回值拿来和预期结果核对。大多数函数都力求通用,测试却不一样:它要挑选特定参数值的调用,并验证这些调用。测试还能当文档用:它演示了怎么调用函数,哪些参数值合适。

断言(assertion)。程序员用 assert 语句验证预期,比如被测函数的输出。assert 语句含一个处于布尔上下文中的表达式,后面跟一行引号括起来的文本(单引号或双引号都行,但要前后一致),如果表达式求值为假值,就会显示这行文本。

py
>>> assert fib(8) == 13, '第八个斐波那契数应该是 13'

断言的表达式为真值时,执行 assert 语句没有任何效果;为假值时,assert 会引发错误,中止执行。

fib 的测试函数应该测几组参数,包括 n 的极端值。

py
>>> def fib_test():
        assert fib(2) == 1, '第二个斐波那契数应该是 1'
        assert fib(3) == 1, '第三个斐波那契数应该是 1'
        assert fib(50) == 7778742049, '在第五十个斐波那契数发生 Error'

把 Python 写进文件(而不是直接敲进解释器)时,测试通常写在同一个文件里,或者写在旁边一个后缀为 _test.py 的文件里。

文档测试(doctest)。Python 提供了一种方便的做法:把简单的测试直接写进函数的文档字符串(docstring)里。文档字符串第一行应该是一句话描述这个函数,接着一个空行,之后可以详细描述参数和行为。此外,文档字符串里还可以放一段调用该函数的交互式示例:

py
>>> 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 模块 验证这段交互。下面用到的 globals 函数返回全局环境的一种表示,解释器求值表达式时正需要它。

py
>>> from doctest import testmod
>>> testmod()
TestResults(failed=0, attempted=2)

如果只想验证单个函数的文档测试,可以用 doctest 模块里名为 run_docstring_examples 的函数。这个函数调用起来(可惜)有点麻烦:第一个参数是要测试的函数;第二个参数应该始终是 globals() 的结果,这个内置函数返回全局环境;第三个参数是 True,表示想要「详细」输出,也就是列出所有跑过的测试。

py
>>> 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 命令行选项,就能运行文件里所有的文档测试:

sh
python3 -m doctest <python_source_file>

有效测试的关键,是新函数一实现完就立刻编写(并运行)测试。在实现之前先写一些测试,甚至是好习惯,这样心里就有了明确的输入和输出示例。只针对单个函数的测试叫单元测试(unit test)。把单元测试做得详尽,是优秀程序设计的标志。

基于 MIT 许可发布