控制流程
引言
程序不只是一串从上到下顺序执行的语句。真实的程序需要重复做事——对班里的每个学生都做一遍——也需要选择做事——只在某个条件成立时才动作。决定哪些语句运行、运行多少次的工具,称为控制流程(control flow)。本页讲两种最常用的:循环(loop),负责重复;条件(conditional),负责选择。一路上我们都依赖 1.1–1.2:你所遍历、所检验的,正是你已经认识的那些对象与容器。
和之前一样,这里的代码可运行——按 Run(或 Ctrl/Cmd+Enter)执行、修改、再运行。
1. for 循环 课件
对容器最常做的事,就是逐个访问它的元素。for 循环正是如此:它依次取出容器的每个元素,把你的循环变量绑定到它,并对每个元素运行一次循环体。
下面的示例依次遍历一个列表、一个字符串、一个字典——你在 1.1–1.2 见过的每一种容器,都能用同样的方式遍历。
示例:遍历容器
课堂练习:遍历每一种容器
- 造一个列表,用
for循环打印它的元素。 - 对元组做同样的事。
- 造一部字典,用
for循环打印它的值。 - 用
for循环打印它的键。 - 用 f-string 把键值对格式化地打印出来,让输出读起来像
apple costs 3。
第 3、4 小题请查一下 .values() 和 .items()——另外注意,像上面示例那样直接
遍历一部字典,得到的是它的键。
易错点:绝不要在遍历一个容器的同时改动它
for 循环会在容器里记住一个内部位置。中途增删元素,这个位置就不再是循环以为的
那个意思了。下面两种情形以不同的方式出错,这正是它值得研究、而不只是背下来的
原因。
字典会当场拒绝:
d = {'a': [1], 'b': [1, 2], 'c': [], 'd': []}
for i in d:
if not d[i]: # 空列表是假值
d.pop(i) # RuntimeError: dictionary changed size during iteration
列表做的事更糟——它一声不吭:
这看起来像是成功了,而这正是这个 bug 能在真实代码里活下来的原因。但它靠的是运气,
不是正确性。那个 0 恰好落在循环访问的最后一个索引上,所以它底下没有任何东西发生
位移。换一组数据,同样的代码就会以两种不同的方式失灵:
d = [1, 0, 0, 4, 5]
for i in range(4):
if not d[i]:
d.pop(i)
print(d) # [1, 0, 4, 5] —— 有一个 0 活下来了
d = [0, 0, 0, 0, 5]
for i in range(4):
if not d[i]:
d.pop(i) # IndexError: list index out of range
把中间那个例子推一遍。移除索引 1 处的 0 之后,它后面的一切都往下挪一位,于是
第二个 0 滑进了索引 1——而循环已经走过那里了。它被跨了过去,从未被检验。第三个
例子里,列表缩短的速度快过循环前进的速度,直到 d[3] 指向一个已经不存在的索引。
所以字典那个 RuntimeError 才是厚道的行为:它立刻告诉你。列表则悄悄返回一个
看似合理、有时却是错的答案,这要难抓得多。
两者的修法是同一条:遍历一个东西,改动另一个东西。 与其原地编辑,不如把你想要
的结果构造出来——推导式(第 6 节)通常最清楚——或者遍历一份副本,写
for i in list(d):,让被遍历的和被改动的是两个不同的对象。
d = {'a': [1], 'b': [1, 2], 'c': [], 'd': []}
d = {k: v for k, v in d.items() if v} # 造一部新字典;什么都没被改动
print(d) # {'a': [1], 'b': [1, 2]}
这和 1.2 第 4 节那个别名易错点是同一个教训,只是换了个角度看:你改动了一个别人 正在依赖的对象。
1.1 用 range 计数
有时你并没有一个容器要遍历——你只是想做固定次数的事,或生成一串整数。这正是 range 的用武之地,而它最自然的归宿就在这里,作为 for 循环计数的对象。回忆 1.2:range 是一个惰性序列,range(5) 代表 0、1、2、3、4,却不会真的造出一个列表。
示例:在 for 循环里用 range
1.2 更 Pythonic 的遍历:enumerate 与 zip
当你以为需要下标加元素时,请用 enumerate,而不要手动数下标。当你需要并排遍历两个序列时,用 zip。它们更易读,也能避开一个经典 bug。
示例:enumerate 与 zip
这个名字就是那幅画面:一条拉链,把两排齿咬合起来。zip 也是用两个并行列表构建字典的自然方式——dict(zip(names, scores))——并且它会在较短的那个用尽时停止。
但 zip 交给你的并不是一个由配对组成的列表。把它打印出来,你会看到一个奇怪的东西:
示例:一个 zip 只能用一次
account = ["622848", "600314", "500297"]
balance = (1_000_000, 1_300_500, 500)
z1 = zip(account, balance)
print(z1) # <zip object at 0x...> —— 不是列表!
for k, v in z1:
print(k, "has a balance of", v)
print("second pass:")
for k, v in z1: # 什么都不会发生
print(k, "has a balance of", v)
print(list(zip(account, balance))) # 一个新的 zip,被物化出来
第二个循环什么也没打印,而且没有报任何错。一个 zip 对象并不持有那些配对;它按需一次产出一个——而一旦产出,它们就没了。再走一遍,它已经没有东西可给了。
这个行为并不是 zip 的怪癖。它是迭代器(iterator)的定义性性质,而 enumerate、生成器表达式、文件对象也都具有它。如果你需要用那些配对不止一次,就用 list(...) 把它们收下来。1.4 会把这件事拆开细讲——现在只需注意:Python 常常返回一个将会产出值的东西,而不是一个已经装着值的容器。
易错点:不要遍历 range(len(...))
一个来自其他语言的常见习惯是 for i in range(len(colors)): color = colors[i]。在 Python 里这既笨拙又易错——需要元素时直接遍历(for color in colors),确实需要下标时用 enumerate。
课堂练习:for 循环
- 把
"python"的每个字符各打印一行。 - 用
range打印 0 到 20 的偶数。 - 给定
names = ["Ada", "Bob", "Cleo"],用enumerate把每个打印成"1. Ada"、"2. Bob"……(计数从 1 开始)。
2. while 循环
for 循环每个元素重复一次。有时你想要的是只要条件成立就一直重复,事先并不知道要循环多少轮——一直问用户直到他输入有效答案,一直对一个数折半直到它足够小。这就是 while 循环:它检查一个条件,条件为真就运行循环体,然后重复。
下面的示例用了经典的累加器模式:一个每轮更新的累计值。
示例:带累加器的 while 循环
当你在遍历一个已知集合或固定次数时用 for;当“是否继续”取决于一个每轮重新检验的条件时,用 while。
易错点:死循环
while 循环只有在条件变假时才结束,所以循环体必须朝那个方向推进。漏掉上面的 n += 1 就会永远循环。如果你需要在循环体内部发现某个条件才停下,请用 break(见下一节)。
课堂练习:while 循环
- 从
n = 100开始,用整除不断折半(n //= 2),每次打印,直到它变成 0。 - 累加整数 1、2、3……一旦累计值超过 50 就停下;打印你一共加了多少个数。
3. break、continue 与循环 else
在任何循环内部,你有时需要更精细的控制。break 立即退出循环。continue 跳过当前这一轮余下的部分,直接进入下一轮。循环还可以带一个 else 子句,它只在循环没有因 break 而中断、自然结束时才运行——很适合“查找”型循环。
示例:break、continue 与 else
4. 条件执行:if / elif / else 课件
循环决定代码运行多少次;if 语句决定它是否运行。你给它一个条件,缩进的代码块只有在条件为真时才运行。用 elif(“否则如果”)依次检验更多条件,用末尾的 else 作为兜底。
下面的示例把一条 if 链与一个 for 循环结合起来——这正是对每个元素区别处理的日常套路。
示例:给数字分类
课堂练习:条件筛选
使用这个含十三个元素的列表
l = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]:
- 打印其中的奇数。
- 打印其中的完全平方数(1、4、9……)。
- 打印其中的完全立方数。
- 对 3 的倍数打印
"fizz",对 5 的倍数打印"buzz",其余打印数字本身。
第 2、3 小题里,你得在没有现成内置函数告诉你的情况下,判断一个数是不是平方数。
两条路子:检验 round(n ** 0.5) ** 2 == n,或者先造一个集合再看成员关系,比如
{i * i for i in range(1, 4)}。对非常大的数,你更信任哪一种?为什么?
5. 条件:比较与布尔 课件
每个 if 和 while 都依赖一个条件——一个求值为 True 或 False 的表达式。本节讲如何把这些条件写好。
5.1 比较值 vs. 比较标识
比较值时,Python 提供了熟悉的运算符 <、>、<=、>=、==(相等)和 !=(不等)。而要问另一个不同的问题——两个名称是否指向同一个对象(标识,来自 1.1)——则用 is。
示例:== 与 is
易错点:判断 None 要用 is
None 是唯一、单例的对象——无论多少个名称指向它,一个运行中的程序里都只有这一个。
所以在 a = None; b = None 之后,a is b 是 True,因为两个名称贴的是同一个对象。
这正是惯用判断写作 x is None(以及 x is not None)而非 x == None 的原因:
你问的是标识,而标识是更强、也更快的那个问题。当你想表达“同一个对象”时就用 is,
对 None 则永远如此。
每一个比较都是一个表达式(expression)——一段能被求值的代码——而比较求出来的值是一个 bool。正因如此,你才能把它直接放进 if 里。
Python 还允许比较被串联起来,而且它的含义与数学记号一致,并不像多数语言那样。写 0 <= x < 10 确实会检验两边,读起来就和写在黑板上一样。
示例:串联比较
串联是两两求值、再用 and 连起来的,所以 a < b > c 意思是 a < b and b > c。由此还带来一个细节:中间的每个操作数只被求值一次,而且只要有一环为假,整条链就提前停下。
对 0 <= x < 10 这样的区间判断,串联是真正好用的。像上面第一行那样更长的链虽然合法,却是谜题而不是好风格——如果你发现自己写出了一条,用 and 会说得更明白。
5.2 布尔逻辑与真值
条件用 and、or、not 组合。两点便利让 Python 的条件十分简洁。其一,每个对象自身都有真值(truthy/falsy):0、0.0、""、空容器([]、{}、set())以及 None 都算作假,其余大多算作真——所以 if items: 的意思就是“如果 items 非空”。其二,and/or 会短路:一旦结果已定就立即停止。
示例:真值与布尔运算符
Python 还允许链式比较,如 0 < x < 10,它读作 (0 < x) and (x < 10)——更贴近数学记法,也是把条件写得 Pythonic 的好例子。
课堂练习:条件
- 写一个条件,当字符串
s为空或只含空格时为真。(提示:s.strip()。) - 给定
age = 20,用一条链式比较检查它是否落在 13 到 64(含)之间。
6. 推导式:把循环写成表达式 课件
很多时候,一个循环存在的唯一目的,就是从旧集合构建一个新集合——把每个数平方、留下偶数、把名字和分数配对。Python 为此提供了一种紧凑、可读的写法:推导式(comprehension)。它是把控制流程浓缩成一个表达式,也是最具辨识度的 Pythonic 写法之一。
下面的示例用两种方式构建同一个列表——先用显式循环,再用推导式——好让你看清它们的对应关系。
示例:列表推导式
推导式可以用末尾的 if 进行筛选,它还有集合和字典两种形式,与 1.2 的容器相呼应——同样的花括号,同样的思路。
示例:筛选、集合与字典推导式
深入了解:圆括号给出的是生成器,不是元组
把方括号换成圆括号并不会造出“元组推导式”——它造出的是一个生成器表达式,它惰性地、一次一个地产生值,而不是一次性建好整个集合:
gen = (x * x for x in range(5))
print(gen) # <generator object ...>
print(list(gen)) # [0, 1, 4, 9, 16]
这种惰性正是 range 背后的同一个思路,也是 1.4 可迭代对象与迭代器 的主题。要得到元组,用 tuple(...) 包住一个生成器即可。
课堂练习:推导式
- 构建一个列表,包含 1 到 19 中奇数的平方。
- 从
words = ["Ada", "bob", "CLEO"]构建一个全小写形式的列表。 - 构建一个把
words中每个词映射到其长度的字典。
7. 人们说“Pythonic”是什么意思 课件
这个词你已经遇到过两次了——§1.2 把 enumerate 和 zip 称作“更 Pythonic 的遍历”——所以它值得有一个定义,哪怕是个松散的定义。
Pythonic 不是一个技术术语。它形容的是这样一种代码:它用的是 Python 真正给你的那些构件,而不是把别的语言里的习惯照搬过来、逐字转写。用 C 的思路写出来的 Python 通常能跑;只是读起来像是作者其实更想写 C。Pythonic 的写法一般更短,而更要紧的是——它把自己的意思说了出来,于是读者不必费力去还原作者的意图。
最清楚的试金石就是 §1.2 那个。下面两段产生同样的输出:
for i in range(len(colors)): # 不 Pythonic:把下标当作手段
print(i, colors[i])
for i, color in enumerate(colors): # Pythonic:说的是“下标和元素”
print(i, color)
第二段不只是更整洁。它不可能越界,它没有把 colors 重复两遍,而且它陈述了真正的意图——把这些元素编号——而不是留给读者从算术里去推断。
这一章其实已经把大部分标准词汇交到你手上了,只是没有贴上标签:
| 特性 | 出现在 | 它取代了什么 |
|---|---|---|
| f-string | 1.1 | 用 + 拼接、%、.format() |
None 与 is None |
1.1、§5.1 | -1、"" 之类的哨兵值 |
with |
1.2 §3.1 | 记得去调用 .close() |
zip |
§1.2 | 对两个列表做下标算术 |
enumerate |
§1.2 | range(len(...)) 外加一个计数器 |
sorted 配 key= |
1.2 | 手写排序 |
| 推导式 | §6 | 一个只为填满列表而存在的循环 |
真值判断(if not d[i]) |
§5.2 | if len(x) == 0、if x == None |
有两条告诫,因为“Pythonic”常被当成大棒用。它不是短的同义词——一个横跨三行、带两个条件的推导式,比它所取代的那个循环更糟,§6 的深入了解就是这么说的。它也不是聪明的同义词;§5.1 那条串联比较合法而紧凑,却仍然比 and 更难读。目标是让代码的含义对下一个读者显而易见,而那个人通常就是你自己。
课堂练习:把它写得 Pythonic
重写下面每一段,然后用一句话说出重写让什么变清楚了。
- 先
i = 0,再用while i < len(items):循环打印items[i]并递增i。 - 先
result = [],再用for循环在n是偶数时追加n * n。 if len(names) == 0: print("empty")。f = open("data.txt"); text = f.read(); f.close()。msg = "Total: " + str(total) + " items"。- 用一个
range(len(ks))循环把两个列表ks和vs合成一部字典。
小结
控制流程决定一个程序做什么、做多少次。你现在已掌握全部日常工具:
| 结构 | 用来 |
|---|---|
for ... in |
对容器中的每个元素重复一次(配合 range、enumerate、zip) |
while |
只要条件成立就重复 |
break / continue / 循环 else |
提前退出、跳过一轮、或在没发生 break 时动作 |
if / elif / else |
只在条件为真时运行一个代码块 |
== / is、and/or/not、真值 |
写出这些选择所依赖的条件 |
| 推导式 | 用一个表达式构建新的列表、集合或字典 |
这里的一切都作用于 1.1–1.2 的对象与容器——循环遍历它们,条件检验它们,推导式重建它们。接下来,1.4 可迭代对象与迭代器 将揭示迭代究竟如何运作,以及为什么像 range 和生成器这样的惰性序列如此重要。