Skip to content

1.5 控制

INFO

译者:mancuojclcs

来源:1.5 Control

对应:Lab 01

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

语句(Statements)与我们之前研究的表达式(Expressions)有着本质的区别。语句没有“值(value)”。执行控制语句的目的不是为了计算出某个结果,而是决定解释器接下来应该做什么

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
<头部>:
    <语句>
    <语句>
    ...
<分隔头部>:
    <语句>
    <语句>
    ...
...

我们可以用这些术语来理解我们之前介绍过的语句。

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

每种头部的特定求值规则决定了其语句体在何时执行,甚至是否执行。我们称之为头部控制(controls)其语句体。例如,在 def 语句中,我们看到返回表达式并不会立即求值,而是被存储起来,直到该函数最终被调用时才执行。

现在我们也可以理解多行程序了:要执行一个语句序列,首先执行第一个语句。如果该语句没有重定向控制流,则继续执行序列中剩余的语句(如果有)。

这个定义揭示了递归定义序列的核心结构:一个序列可以分解为它的第一个元素和其余元素。而一个语句序列的“其余部分”,本身也是一个语句序列!因此,我们可以递归地应用这条执行规则。这种将序列视为递归数据结构的视角,在后续章节中还会再次出现。

这条规则产生的一个重要结果是:尽管语句是按顺序执行的,但由于控制流的重定向,后面的语句可能永远不会被执行到。

实践指导:在对语句体进行缩进时,所有行必须缩进相同的幅度,并使用相同的方式(请使用空格,不要使用制表符 Tab)。缩进的任何细微差异都会导致语法错误。

1.5.3 定义函数 II:局部赋值

最初我们提到,用户自定义函数的主体仅由一个带有单一表达式的 return 语句组成。实际上,函数可以定义一系列操作,其范围远不止一个表达式。

每当调用用户自定义函数时,其定义中的语句序列(suite)会在一个局部环境中执行——这个环境始于由函数调用创建的局部帧(local frame)。return 语句会重定向控制流:一旦执行到第一个 return 语句,函数调用的过程就会终止,而 return 表达式的值就是该函数的返回值。

赋值语句可以出现在函数体内部。例如,下面这个函数通过两步计算,返回两个数值之差绝对值占第一个数的百分比:

赋值语句的作用是将一个名字绑定到当前环境第一个帧(first frame)中的值。因此,函数体内的赋值语句不会影响全局帧。函数只能操作其局部环境,这一特性对于构建模块化程序至关重要。在模块化程序中,纯函数仅通过它们接收的参数和返回的值进行交互。

当然,percent_difference 函数也可以写成单一表达式的形式(如下所示),但这样 return 表达式会变得更加复杂。

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 中的条件语句由一系列“头部”(header)和“语句体”(suite)组成:一个必需的 if 子句,若干可选的 elif 子句,以及最后可选的 else 子句:

py
if <表达式>:
    <语句体>
elif <表达式>:
    <语句体>
else:
    <语句体>

执行条件语句时,每个子句会按顺序被考虑。执行一个条件子句的过程如下:

  1. 计算头部表达式的值
  2. 如果结果为真值(true value),则执行该语句体。随后,跳过条件语句中所有后续的子句
  3. 如果到达了 else 子句(仅当所有 ifelif 表达式的计算结果为假值时才会发生),则执行其语句体

布尔上下文(Boolean contexts):上述执行流程中提到了“假值”和“真值”。条件块头部语句中的表达式处于布尔上下文中:它们的真假值决定了控制流,除此之外,它们的值既不会被赋值也不会被返回。Python 中包含若干“假值”,包括 0None 以及布尔值 False。所有其他数字均为真值。在第 2 章中,我们将看到 Python 中的每种内置数据类型都既有真值也有假值。

布尔值(Boolean values):Python 有两个布尔值,分别是 TrueFalse 。布尔值在逻辑表达式中代表真值。内置的比较操作 >, <, >=, <=, ==, != 都会返回这些值。

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

第二个例子读作“5 大于或等于 5”,对应于 operator 模块中的函数 ge

py
>>> 0 == -0
True

最后一个示例读作“0 等于 -0”,对应于 operator 模块中的 eq。请注意,Python 会区分赋值符号 = 与相等比较符号 ==,这也是许多编程语言共有的约定。

布尔运算符(Boolean operators):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 迭代

除了选择执行哪些语句外,控制语句还用于表达重复。如果我们编写的每一行代码只执行一次,那么编程将是一项非常低效的工作。只有通过重复执行语句,我们才能释放计算机的全部潜力。我们之前已经见过了一种重复形式:一个函数只需定义一次,就可以被多次调用。迭代控制结构是另一种多次执行相同语句的机制。

考虑斐波那契数列(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 测试

测试一个函数就是去验证函数的行为是否符合预期。我们现在的函数语言已经足够复杂,需要开始对实现进行测试了。

测试是一种系统地执行验证的机制。测试通常以另一个函数的形式存在,该函数包含对被测函数的一个或多个示例调用,然后根据预期结果验证返回值。与大多数旨在通用的函数不同,测试涉及选择并验证具有特定参数值的调用。测试还具有文档作用:它们演示了如何调用函数以及哪些参数值是合适的。

断言(Assertions):程序员使用 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 的相邻文件中。

文档测试(Doctests):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 模块 来验证交互:

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 许可发布

布局切换

调整 VitePress 的布局样式,以适配不同的阅读习惯和屏幕环境。

全部展开
使侧边栏和内容区域占据整个屏幕的全部宽度。
全部展开,但侧边栏宽度可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
全部展开,且侧边栏和内容区域宽度均可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
原始宽度
原始的 VitePress 默认布局宽度

页面最大宽度

调整 VitePress 布局中页面的宽度,以适配不同的阅读习惯和屏幕环境。

调整页面最大宽度
一个可调整的滑块,用于选择和自定义页面最大宽度。

内容最大宽度

调整 VitePress 布局中内容区域的宽度,以适配不同的阅读习惯和屏幕环境。

调整内容最大宽度
一个可调整的滑块,用于选择和自定义内容最大宽度。

聚光灯

支持在正文中高亮当前鼠标悬停的行和元素,以优化阅读和专注困难的用户的阅读体验。

ON开启
开启聚光灯。
OFF关闭
关闭聚光灯。