跳转至

控制流程

引言

程序不只是一串从上到下顺序执行的语句。真实的程序需要重复做事——对班里的每个学生都做一遍——也需要选择做事——只在某个条件成立时才动作。决定哪些语句运行、运行多少次的工具,称为控制流程(control flow)。本页讲两种最常用的:循环(loop),负责重复;条件(conditional),负责选择。一路上我们都依赖 1.1–1.2:你所遍历、所检验的,正是你已经认识的那些对象与容器。

和之前一样,这里的代码可运行——按 Run(或 Ctrl/Cmd+Enter)执行、修改、再运行。

1. for 循环 课件

对容器最常做的事,就是逐个访问它的元素。for 循环正是如此:它依次取出容器的每个元素,把你的循环变量绑定到它,并对每个元素运行一次循环体。

下面的示例依次遍历一个列表、一个字符串、一个字典——你在 1.1–1.2 见过的每一种容器,都能用同样的方式遍历。

示例:遍历容器
for n in [10, 20, 30]:
    print(n)

for ch in "hi":
    print(ch)

prices = {"apple": 3, "pear": 5}
for key in prices:          # 遍历字典得到的是它的键
    print(key, prices[key])
名称指向对象:每一轮里,循环变量只是一个被重新绑定到容器下一个对象的名称——什么都没有被复制。
课堂练习:遍历每一种容器
  1. 造一个列表,用 for 循环打印它的元素。
  2. 对元组做同样的事。
  3. 造一部字典,用 for 循环打印它的
  4. for 循环打印它的
  5. 用 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

列表做的事更糟——它一声不吭:

d = [1, 2, 3, 0, 5]
for i in range(4):
    if not d[i]:
        d.pop(i)
print(d)                  # [1, 2, 3, 5] —— 对了!

这看起来像是成功了,而这正是这个 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
for i in range(5):          # 0, 1, 2, 3, 4
    print(i)

for i in range(2, 11, 2):   # 起点、终点(不含)、步长
    print(i)                # 2, 4, 6, 8, 10

1.2 更 Pythonic 的遍历:enumerate 与 zip

当你以为需要下标加元素时,请用 enumerate,而不要手动数下标。当你需要并排遍历两个序列时,用 zip。它们更易读,也能避开一个经典 bug。

示例:enumerate 与 zip
colors = ["red", "green", "blue"]
for i, color in enumerate(colors):
    print(i, color)

names  = ["Ada", "Bob"]
scores = [95, 88]
for name, score in zip(names, scores):
    print(name, "scored", score)

这个名字就是那幅画面:一条拉链,把两排齿咬合起来。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 循环
  1. "python" 的每个字符各打印一行。
  2. range 打印 0 到 20 的偶数。
  3. 给定 names = ["Ada", "Bob", "Cleo"],用 enumerate 把每个打印成 "1. Ada""2. Bob"……(计数从 1 开始)。

2. while 循环

for 循环每个元素重复一次。有时你想要的是只要条件成立就一直重复,事先并不知道要循环多少轮——一直问用户直到他输入有效答案,一直对一个数折半直到它足够小。这就是 while 循环:它检查一个条件,条件为真就运行循环体,然后重复。

下面的示例用了经典的累加器模式:一个每轮更新的累计值。

示例:带累加器的 while 循环
total = 0
n = 1
while n <= 5:        # 只要条件为真就继续
    total += n       # 累加
    n += 1           # 朝着条件变假的方向前进
print(total)         # 15  (1+2+3+4+5)

当你在遍历一个已知集合或固定次数时用 for;当“是否继续”取决于一个每轮重新检验的条件时,用 while

易错点:死循环

while 循环只有在条件变假时才结束,所以循环体必须朝那个方向推进。漏掉上面的 n += 1 就会永远循环。如果你需要在循环体内部发现某个条件才停下,请用 break(见下一节)。

课堂练习:while 循环
  1. n = 100 开始,用整除不断折半(n //= 2),每次打印,直到它变成 0。
  2. 累加整数 1、2、3……一旦累计值超过 50 就停下;打印你一共加了多少个数。

3. break、continue 与循环 else

在任何循环内部,你有时需要更精细的控制。break 立即退出循环。continue 跳过当前这一轮余下的部分,直接进入下一轮。循环还可以带一个 else 子句,它只在循环没有break 而中断、自然结束时才运行——很适合“查找”型循环。

示例:break、continue 与 else
for n in range(2, 10):
    if n % 2 == 0:
        continue          # 跳过偶数
    print("odd:", n)

target = 7
for n in [3, 5, 7, 9]:
    if n == target:
        print("found", target)
        break             # 停止查找
else:
    print("not found")    # 只有在没发生 break 时才运行

4. 条件执行:if / elif / else 课件

循环决定代码运行多少次if 语句决定它是否运行。你给它一个条件,缩进的代码块只有在条件为真时才运行。用 elif(“否则如果”)依次检验更多条件,用末尾的 else 作为兜底。

下面的示例把一条 if 链与一个 for 循环结合起来——这正是对每个元素区别处理的日常套路。

示例:给数字分类
for n in range(-2, 3):
    if n > 0:
        print(n, "is positive")
    elif n < 0:
        print(n, "is negative")
    else:
        print(n, "is zero")
课堂练习:条件筛选

使用这个含十三个元素的列表 l = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]

  1. 打印其中的奇数
  2. 打印其中的完全平方数(1、4、9……)。
  3. 打印其中的完全立方数
  4. 对 3 的倍数打印 "fizz",对 5 的倍数打印 "buzz",其余打印数字本身。

第 2、3 小题里,你得在没有现成内置函数告诉你的情况下,判断一个数是不是平方数。 两条路子:检验 round(n ** 0.5) ** 2 == n,或者先造一个集合再看成员关系,比如 {i * i for i in range(1, 4)}。对非常大的数,你更信任哪一种?为什么?

5. 条件:比较与布尔 课件

每个 ifwhile 都依赖一个条件——一个求值为 TrueFalse 的表达式。本节讲如何把这些条件写好。

5.1 比较值 vs. 比较标识

比较时,Python 提供了熟悉的运算符 <><=>===(相等)和 !=(不等)。而要问另一个不同的问题——两个名称是否指向同一个对象(标识,来自 1.1)——则用 is

示例:== 与 is
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b)   # True  —— 内容相同
print(a is b)   # False —— 两个不同的对象

c = a
print(a is c)   # True  —— 同一个对象(两个名称,一个列表)
一切皆对象:== 比较对象的值,而 is 比较它们的标识(id)——正是我们在 1.1 中画出来的那个东西。
易错点:判断 None 要用 is

None 是唯一、单例的对象——无论多少个名称指向它,一个运行中的程序里都只有这一个。 所以在 a = None; b = None 之后,a is bTrue,因为两个名称贴的是同一个对象。 这正是惯用判断写作 x is None(以及 x is not None)而非 x == None 的原因: 你问的是标识,而标识是更强、也更快的那个问题。当你想表达“同一个对象”时就用 is, 对 None 则永远如此。

每一个比较都是一个表达式(expression)——一段能被求值的代码——而比较求出来的值是一个 bool。正因如此,你才能把它直接放进 if 里。

Python 还允许比较被串联起来,而且它的含义与数学记号一致,并不像多数语言那样。写 0 <= x < 10 确实会检验两边,读起来就和写在黑板上一样。

示例:串联比较
a, b, c, d, e = 1, 4, 3, 3, 5

print(a < b > c == d != e)   # True
print(a < b and b > c and c == d and d != e)   # 同一件事,摊开来写

x = 7
print(0 <= x < 10)           # True —— 一个清楚的检验,而不是两个

串联是两两求值、再用 and 连起来的,所以 a < b > c 意思是 a < b and b > c。由此还带来一个细节:中间的每个操作数只被求值一次,而且只要有一环为假,整条链就提前停下。

0 <= x < 10 这样的区间判断,串联是真正好用的。像上面第一行那样更长的链虽然合法,却是谜题而不是好风格——如果你发现自己写出了一条,用 and 会说得更明白。

5.2 布尔逻辑与真值

条件用 andornot 组合。两点便利让 Python 的条件十分简洁。其一,每个对象自身都有真值(truthy/falsy)00.0""、空容器([]{}set())以及 None 都算作假,其余大多算作真——所以 if items: 的意思就是“如果 items 非空”。其二,and/or短路:一旦结果已定就立即停止。

示例:真值与布尔运算符
items = []
if not items:
    print("the list is empty")

name = ""
print(name or "anonymous")   # "anonymous" —— or 返回第一个为真的值

x = 5
print(0 < x < 10)            # True —— 链式比较

Python 还允许链式比较,如 0 < x < 10,它读作 (0 < x) and (x < 10)——更贴近数学记法,也是把条件写得 Pythonic 的好例子。

课堂练习:条件
  1. 写一个条件,当字符串 s 为空只含空格时为真。(提示:s.strip()。)
  2. 给定 age = 20,用一条链式比较检查它是否落在 13 到 64(含)之间。

6. 推导式:把循环写成表达式 课件

很多时候,一个循环存在的唯一目的,就是从旧集合构建一个新集合——把每个数平方、留下偶数、把名字和分数配对。Python 为此提供了一种紧凑、可读的写法:推导式(comprehension)。它是把控制流程浓缩成一个表达式,也是最具辨识度的 Pythonic 写法之一。

下面的示例用两种方式构建同一个列表——先用显式循环,再用推导式——好让你看清它们的对应关系。

示例:列表推导式
# 显式循环
squares = []
for x in range(6):
    squares.append(x * x)
print(squares)

# 同一件事,用推导式
squares = [x * x for x in range(6)]
print(squares)

推导式可以用末尾的 if 进行筛选,它还有集合字典两种形式,与 1.2 的容器相呼应——同样的花括号,同样的思路。

示例:筛选、集合与字典推导式
evens = [x for x in range(10) if x % 2 == 0]
print(evens)

unique_lengths = {len(w) for w in ["hi", "bye", "ok"]}   # 一个集合
print(unique_lengths)

squares_map = {x: x * x for x in range(5)}               # 一个字典
print(squares_map)
深入了解:圆括号给出的是生成器,不是元组

把方括号换成圆括号并不会造出“元组推导式”——它造出的是一个生成器表达式,它惰性地、一次一个地产生值,而不是一次性建好整个集合:

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. 构建一个列表,包含 1 到 19 中奇数的平方。
  2. words = ["Ada", "bob", "CLEO"] 构建一个全小写形式的列表。
  3. 构建一个把 words 中每个词映射到其长度的字典。

7. 人们说“Pythonic”是什么意思 课件

这个词你已经遇到过两次了——§1.2 把 enumeratezip 称作“更 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()
Noneis None 1.1、§5.1 -1"" 之类的哨兵值
with 1.2 §3.1 记得去调用 .close()
zip §1.2 对两个列表做下标算术
enumerate §1.2 range(len(...)) 外加一个计数器
sortedkey= 1.2 手写排序
推导式 §6 一个只为填满列表而存在的循环
真值判断if not d[i] §5.2 if len(x) == 0if x == None

有两条告诫,因为“Pythonic”常被当成大棒用。它不是的同义词——一个横跨三行、带两个条件的推导式,比它所取代的那个循环更糟,§6 的深入了解就是这么说的。它也不是聪明的同义词;§5.1 那条串联比较合法而紧凑,却仍然比 and 更难读。目标是让代码的含义对下一个读者显而易见,而那个人通常就是你自己。

课堂练习:把它写得 Pythonic

重写下面每一段,然后用一句话说出重写让什么变清楚了。

  1. i = 0,再用 while i < len(items): 循环打印 items[i] 并递增 i
  2. result = [],再用 for 循环在 n 是偶数时追加 n * n
  3. if len(names) == 0: print("empty")
  4. f = open("data.txt"); text = f.read(); f.close()
  5. msg = "Total: " + str(total) + " items"
  6. 用一个 range(len(ks)) 循环把两个列表 ksvs 合成一部字典。

小结

控制流程决定一个程序做什么、做多少次。你现在已掌握全部日常工具:

结构 用来
for ... in 对容器中的每个元素重复一次(配合 rangeenumeratezip
while 只要条件成立就重复
break / continue / 循环 else 提前退出、跳过一轮、或在没发生 break 时动作
if / elif / else 只在条件为真时运行一个代码块
== / isand/or/not、真值 写出这些选择所依赖的条件
推导式 用一个表达式构建新的列表、集合或字典

这里的一切都作用于 1.1–1.2 的对象与容器——循环遍历它们,条件检验它们,推导式重建它们。接下来,1.4 可迭代对象与迭代器 将揭示迭代究竟如何运作,以及为什么像 range 和生成器这样的惰性序列如此重要。