控制:布尔值、条件语句与 while 循环
让程序不再是一条直路——学会按条件挑选要走的分支,以及把同一段代码重复执行到条件不再成立。
0. 本讲导读
上一讲把「一个表达式被求值时到底发生了什么」讲清楚了:求值算子、求值算子数、把函数施加到实参上、新建一帧、在帧里绑定形参、执行函数体。有了这套机制,你已经能写出 square、area 这样的函数。
但你写出来的每一个程序都还是一条直路:从上到下,每条语句不多不少执行一次。这样的程序有两件事做不了。
第一件:做不了选择。「求绝对值」这个需求本身就带着一个岔路——输入是负数走一条路,不是负数走另一条路。你没法用一串顺序执行的语句表达「只在某种情况下才执行这几行」。
第二件:做不了重复。「把 1 到 100 全加起来」如果只能顺序执行,你就得手写 100 行 total = total + 1、total = total + 2……那把 1 加到 1000000 呢?更要命的是,重复多少次往往在写代码的时候还不知道——它取决于运行时传进来的那个 n。
本讲补上的正是这两样:分支(branching)用 if / elif / else,迭代(iteration)用 while。而这两样都要先有一个能判断真假的「条件」,所以本讲从布尔值(boolean)讲起。这三块内容合称控制流(control flow)——它决定了「下一条被执行的语句是哪一条」。
往后看:下一讲的高阶函数会把函数当成值传来传去,届时你写的那些被传来传去的小函数,函数体里几乎都装着本讲的 if 和 while。本讲的 sum_digits 就是 Lab 01 的最后一题,而 HW 01 的 Hailstone 是一道纯粹的 while 循环题。这一讲学不扎实,后面每一讲都会卡。
- 表达式被求值(evaluate)成一个值;语句被执行(execute)以产生某种效果。控制流靠复合语句(compound statement)实现:一个 header 后面跟一个冒号,它「控制」下面缩进的那一整块代码。
- Python 里任何值都有真假性。falsy 的只有那么几个:
False、0、0.0、None、空容器(如空字符串'');剩下的全是 truthy。 and/or会短路(short circuit):它们返回的是「最后看过的那个操作数的值」,不一定是 True 或 False;没看到的操作数根本不求值。- 条件语句最多执行一个 suite。一旦某个 clause 的条件为真并执行了它的 suite,剩下所有
elif/else全部跳过。所以更特殊的条件必须写在前面。 while循环不新建帧。循环里改的名字就是当前帧里那个名字。写循环必须同时想清三件事:初始化、条件、更新——漏掉更新就是死循环。return一执行,函数立刻结束,套在外面的循环也一起结束;print只是往屏幕上打字,函数照跑不误。
1. 布尔值:程序里的「是」与「否」
布尔值(boolean)是一种原始数据类型(primitive data type),整个类型只有两个值:True 和 False。它用来表示真值(truth value),也就是「是非问句的答案」:
- 你的年龄满 18 了吗?
- 作业和家务都做完了吗?
- 灯开着吗?
为什么要专门有这么一个类型?因为分支和循环都需要一个「往哪走」的依据,而这个依据必须能被程序算出来。把「是非」变成一个可以计算、可以传递、可以命名的值,控制流才有立足点。
>>> age = 21
>>> age > 18
True
>>> age > 21
False
这里最容易被跳过、但其实最重要的一点:age > 18 是一个表达式。它跟 2 + 3 是同一类东西——被求值之后得到一个值。2 + 3 求值成 5,age > 18 求值成 True。不要把它读成「一句问话」,它就是一个值,跟 5 一样普通。
既然是值,就能绑定到名字上,也能被别的表达式当成子表达式使用:
>>> hw_done = True
>>> chores_done = False
>>> can_scroll = hw_done and chores_done
>>> can_scroll
False
第三行执行的是一条赋值语句:先求值右边的 hw_done and chores_done(用到了下一节要讲的逻辑运算符),得到 False,再把名字 can_scroll 绑定到这个值上。名字绑定的规则跟上一讲完全一样,值恰好是布尔值而已。
True 和 False 首字母必须大写。写成 true 不是语法错误,而是名字查找失败:
>>> can_scroll = true
Traceback (most recent call last):
...
NameError: name 'true' is not defined
因为 Python 把 true 当成一个普通名字去环境里找,找不到就报 NameError。这个错误信息很有价值:看到 NameError,你就该想「我用了一个还没被绑定过的名字」,而不是「语法写错了」。(Python 3.10 以后的版本还会贴心地补一句 Did you mean: 'True'?)
2. 真假性:为什么 if 0: 进不去
如果 Python 只允许布尔值出现在条件的位置,事情会简单很多,但 Python 不是这样设计的。在 Python 里,所有数据类型都能表现得像布尔值,哪怕它根本不是布尔值。
规则只有一句话——记住 falsy 的那张短名单,剩下的全是 truthy:
- falsy(假值):
False、0、0.0、None,以及空容器,例如空字符串''。 - truthy(真值):其他一切。
助记:「是零,或者代表『什么都没有』,就是 falsy」。
下面这张表里的几个值,几乎每个初学者都会栽一次:
| 值 | 真假性 | 为什么容易搞错 |
|---|---|---|
0 | falsy | — |
-1 | truthy | 「负数」不是「零」。只有 0 才 falsy,负数照样为真 |
0.0 | falsy | 浮点零也是零 |
0.1 | truthy | — |
''(空字符串) | falsy | — |
' '(一个空格) | truthy | 它不是空串,里面装着一个空格字符 |
'0' | truthy | 这是一个长度为 1 的字符串,不是数字 0 |
'False' | truthy | 同上,字符串的内容长什么样跟真假性无关 |
None | falsy | 没写 return 的函数返回的就是它 |
「是 truthy」跟「等于 True」是两件不同的事。 -1 是 truthy,但:
>>> -1 == True
False
>>> 1 == True
True
因为 True 在数值运算里等于 1(True + True 会得到 2),所以只有 1 跟它相等。这就是为什么判断真假永远写 if x:,不要写 if x == True:——后者不但啰嗦,语义还窄了一圈,x 是 -1 或 'hi' 时它会给出你不想要的 False。
真假性这套设计是为了让条件写起来更贴近自然语言。往后学到字符串和列表,你会写 while s: 表示「只要 s 还不为空」、if lst: 表示「如果这个列表里还有东西」。现在只需要记住那张 falsy 名单;本讲的循环条件里 while y > 0 和 while y 对正整数是一回事,但前者更清楚地写出了意图,所以更推荐。
3. 布尔表达式:比较运算符与逻辑运算符
布尔表达式(boolean expression)就是求值成某个 truthy 或 falsy 值的表达式。构造它的方式是:拿值或名字,用运算符拼起来。可用的运算符分两类。
比较运算符
| 运算符 | 含义 | 例子 | 值 |
|---|---|---|---|
> | 大于 | 21 > 18 | True |
< | 小于 | 21 < 18 | False |
>= | 大于等于 | 21 >= 21 | True |
<= | 小于等于 | 18 <= 21 | True |
== | 等于 | 3 == 3.0 | True |
!= | 不等于 | 3 != 4 | True |
= 和 == 是完全不同的两个东西,而且这是初学阶段出现频率最高的错误之一。
=是赋值语句的一部分:x = 3的意思是「把名字 x 绑定到值 3」。它是一条语句,不产生值。==是比较运算符:x == 3是一个表达式,求值成True或False。
把 = 写进条件里会直接语法错误,程序一行都跑不了:
>>> if x = 3:
File "<stdin>", line 1
if x = 3:
^^^^^
SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='?
注意 SyntaxError 和 NameError 的区别:SyntaxError 是 Python 在读你的代码时就发现读不通,此时一条语句都还没执行;NameError 则是代码已经跑起来了,跑到某处发现名字查不到。看到 SyntaxError 就去检查标点和结构,别去检查逻辑。
逻辑运算符
三个:and、or、not。它们不是函数,是特殊形式——后面会看到它们的求值规则和普通调用表达式不一样。先看「结果长什么样」的真值表。
and:两边都为真,结果才为真。
and | 右 = T | 右 = F |
|---|---|---|
| 左 = T | T | F |
| 左 = F | F | F |
or:至少有一边为真,结果就为真。
or | 右 = T | 右 = F |
|---|---|---|
| 左 = T | T | T |
| 左 = F | T | F |
not:把当前真假翻转。它只有一个操作数。
| 表达式 | 值 |
|---|---|
not True | False |
not False | True |
真值表告诉你结果的真假,但它没告诉你结果究竟是哪个值,也没告诉你 Python 为了算出这个结果做了多少事。这两件事正是下一节的主题,也是这一讲最容易在考试里丢分的地方。
4. 短路求值:and 和 or 到底返回什么
先看一个反直觉的现象:
>>> True and 0
0
>>> 0 or ''
''
>>> 1 and 2
2
>>> '' or 'hi'
'hi'
True and 0 的值不是 False,是 0。1 and 2 的值不是 True,是 2。真值表没骗你(0 确实是 falsy,2 确实是 truthy),但它没说全。
真正的求值规则
求值 <左> and <右>:
求值 <左> or <右>:
把这两条压成一句话:整个表达式的值等于「最后看过的那个操作数」的值,而不一定是布尔类型。
为什么要这样设计?动机是不做多余的计算。and 一旦碰到第一个 falsy 值,结果就已经注定是假了,再算下去没有意义;or 一旦碰到第一个 truthy 值,结果注定为真。能提前退出就提前退出——这就是短路(short circuiting)。
注意 not 不一样:not 永远返回真正的 True 或 False,不返回操作数本身。not 0 是 True,不是 0;not '' 是 True。
| 表达式 | 值 | 发生了什么 |
|---|---|---|
True and 0 | 0 | 左 truthy,继续看右,返回右操作数的值 0 |
0 or '' | '' | 左 falsy,继续看右,返回右操作数的值 '' |
1 and 2 | 2 | 左 truthy,返回右 |
-1 and 0.0 | 0.0 | -1 是 truthy,所以要看右边 |
'' or 'hi' | 'hi' | 空串 falsy,返回右 |
1 or 1/0 | 1 | 左 truthy 立刻短路,1/0 从来没被计算过,所以不报错 |
not 0 | True | not 返回真正的布尔值 |
not '' | True | 同上 |
运算符优先级
没有括号时,Python 按固定的紧密程度分组。跟本讲相关的部分,从紧到松是:
| 松紧 | 运算符 |
|---|---|
| 最紧 | 算术:** > * / // % > + - |
| ↓ | 比较:< <= > >= == != |
| ↓ | not |
| ↓ | and |
| 最松 | or |
「紧」的意思是它先把两边的东西抓过去结合。所以 i % 2 == 0 and i % 3 == 0 不需要任何括号——% 比 == 紧,== 比 and 紧,自动分组成 ((i % 2) == 0) and ((i % 3) == 0),正是我们想要的。
把两件事合起来推演
看幻灯片上那个例子。6 / 0 是一个必然会抛 ZeroDivisionError 的表达式,但下面这行代码平安无事,值是 5:
>>> 5 or (6 / 0) and not None
5
not 最紧,先抓住 None;然后 and;最后 or。于是原式等价于 5 or ((6 / 0) and (not None))。or,它的左操作数是 5,右操作数是整个 ((6 / 0) and (not None))。or 的规则,先求值左操作数:5 求值成 5。5 是 truthy(不是 0,不代表「什么都没有」)。6 / 0 从头到尾没被计算,因此没有 ZeroDivisionError。5。不是 True。括号挪一个位置,答案就变了:
>>> (5 or (6 / 0)) and not None
True
此时顶层运算符变成了 and。左操作数 (5 or (6 / 0)) 先求值:or 短路得到 5(还是没算 6 / 0)。5 是 truthy,于是 and 要看右操作数 not None,它求值成 True。整个表达式的值是右操作数的值 True。
两次都躲过了除零,但一次返回 5,一次返回 True。这类题目考的就是你会不会老老实实把括号补出来。
短路的实际用途:守卫
短路不只是个考点,它是写健壮代码的常用手段。想判断「n 不为零,且 100 除以 n 大于 5」:
>>> n = 0
>>> n != 0 and 100 // n > 5
False
安全,因为 n != 0 求值成 False(falsy),and 立刻短路,100 // n 没被计算。左边这个条件叫守卫(guard):它挡在危险操作前面。
把两边换个位置就崩:
>>> n = 0
>>> 100 // n > 5 and n != 0
Traceback (most recent call last):
...
ZeroDivisionError: integer division or modulo by zero
因为 and 必须先求值左操作数,而左操作数里就有 100 // 0。and 和 or 的两边不能随便交换顺序——真值表里它们看着对称,求值过程里它们一点也不对称。
5. 语句、子句与代码块:缩进是语法,不是排版
要讲控制流,先得把「语句」这个词说清楚。
语句(statement)是被解释器执行(execute)以完成某个动作的东西。这和表达式(expression)形成对照:表达式被求值(evaluate),产生一个值。
| 对比项 | 表达式 expression | 语句 statement |
|---|---|---|
| 解释器对它做什么 | 求值(evaluate) | 执行(execute) |
| 结果 | 一个值 | 一个效果(绑定名字、打印、改变控制流……) |
| 例子 | 2 + 3、square(4)、x > 0、a and b | x = 3、return x、def f(): ...、if ...: |
| 能不能放在赋值号右边 | 能 | 不能 |
一条语句可以只占一行(简单语句,如 x = 3),也可以管着一大片代码(复合语句 compound statement)。复合语句的形状长这样:
<header>:
<statement>
<statement>
...
<separating header>:
<statement>
<statement>
...
...
三个术语要分清:
- header(头部):以关键字开头、以冒号结尾的那一行。header 决定这条语句是什么类型——看到
def就是函数定义,看到if就是条件语句,看到while就是循环。 - suite(代码块):header 下面那一整块缩进的语句。
- clause(子句):一个 header 加上它控制的那个 suite,合起来叫一个 clause。一条复合语句可以由多个 clause 拼成(比如
if加elif加else是三个 clause、一条语句)。
核心关系一句话:一个 clause 的 header「控制」跟在它后面的那个 suite——控制它执不执行、执行几次。
到目前为止你只学过一种复合语句:def。def square(x): 是 header,下面缩进的 return x * x 是 suite。本讲要加的 if 和 while,语法形状跟它一模一样,区别只在于 header 决定的执行规则不同:
| header | 它的 suite 什么时候执行 |
|---|---|
def f(...): | 定义时不执行,被调用时才执行(每调用一次执行一次,并且在新建的一帧里执行) |
if <expr>: | 条件为真时执行一次 |
while <expr>: | 只要条件为真就反复执行 |
suite 的执行规则本身很简单:按从上到下的顺序执行里面的每一条语句。复杂的部分全在 header 上——header 决定这个 suite 是执行 0 次、1 次还是 n 次。
在 Python 里缩进不是排版风格,它是语法的一部分。 别的语言用花括号划定代码块,Python 用缩进。这意味着把一行代码往左往右挪一格,程序的含义就变了——本讲后面几个真实 bug 全都是缩进错位造成的。
统一用 4 个空格缩进。把 tab 和空格混着用会得到:
TabError: inconsistent use of tabs and spaces in indentation
另外两个高频语法错:
>>> if 1
File "<stdin>", line 1
if 1
^
SyntaxError: expected ':'
>>> def f():
... return 1
File "<stdin>", line 2
return 1
^
IndentationError: expected an indented block after function definition on line 1
第二个的意思是:header 写完了,Python 期待下面出现一个缩进的 suite,结果没有。header 后面必须至少有一条缩进的语句。
6. 条件语句:if / elif / else
动机很直白:怎样才能让一组语句只在某些情况下才执行? 比如一个课程网站,如果当前用户是助教就显示学生名册,否则把名册页藏起来。用顺序执行的语句写不出「否则」。
解决办法是条件语句(conditional statement),俗称 if 语句。
if <expression>:
<suite>
elif <expression>:
<suite>
else:
<suite>
执行规则(要背下来)
按从上到下的顺序,逐个考察每个 clause:
- 求值这个 clause 的 header 表达式(
else没有表达式,视作条件永远成立)。 - 如果它是 truthy:执行这个 clause 的 suite,然后跳过整条条件语句剩下的全部 clause,控制流走到条件语句之后。
- 如果它是 falsy:什么都不做,考察下一个 clause。
两个推论:最多执行一个 suite;也可能一个 suite 都不执行(所有条件都为假、且没有 else)。
请特别注意规则里的两个词:truthy(不是「等于 True」)和跳过剩下的全部。前者呼应第 2 节,后者是第 8 节 fizzbuzz 那个 bug 的根源。
例子:绝对值
def absolute_value(x):
if x < 0:
return -x
elif x == 0:
return 0
else:
return x
(实际写代码时当然直接用内置的 abs,这里是为了拆开看清楚控制流。)
这条 def 语句的 suite 里只有一条语句——那条由三个 clause 组成的条件语句。三个 clause 分别是 if x < 0: 及其 suite、elif x == 0: 及其 suite、else: 及其 suite。
absolute_value(-3):先求值算子 absolute_value,在全局帧里查到一个函数对象;再求值算子数 -3,得到 -3。x 绑定到 -3。x < 0:在 f1 里查到 x 是 -3,-3 < 0 求值成 True。True 是 truthy → 执行这个 clause 的 suite:return -x。求值 -x 得到 3。return 让函数立即结束,返回值 3。elif 和 else 两个 clause 连条件都不会被求值。Global frame
absolute_value ──→ func absolute_value(x) [parent=Global]
f1: absolute_value [parent=Global]
x ──→ -3
返回值 ──→ 3 # 由 return -x 产生,函数随即结束
环境图里只有两个帧:Global 和 f1。if、elif、else 不会创建新的帧,while 也不会。在 61A 的环境图里,新建一帧只有一个原因:调用一个用户定义的函数。 这条规则从现在起一直用到学期末,务必记牢。
换两个输入再走一遍,重点体会「跳过剩下的全部」:
absolute_value(0):新建帧,x 绑定到 0。x < 0 → 0 < 0 → False,falsy,跳过它的 suite,考察下一个 clause。x == 0 → True,执行 return 0,函数结束,返回 0。else 不被考察。absolute_value(5):5 < 0 → False;5 == 0 → False;两个都假,走到 else,执行 return x,返回 5。简化:为什么可以删掉中间那个 clause
def absolute_value(x):
if x < 0:
return -x
else:
return x
这一版和上一版逻辑等价。怎么确信?把输入空间分区,逐区对比输出——这是判断两段代码是否等价的通用手法,比「看着差不多」可靠得多。
| x 的区间 | 三分支版走哪条 | 返回 | 两分支版走哪条 | 返回 | 是否一致 |
|---|---|---|---|---|---|
x < 0 | if | -x | if | -x | 一致 |
x == 0 | elif | 0 | else | x,此时 x 就是 0 | 一致 |
x > 0 | else | x | else | x | 一致 |
关键在第二行:x == 0 时,return 0 和 return x 给出同一个值,所以那个 elif 是多余的。elif 和 else 本来就都是可选的,而且 elif 可以写任意多个。
三个真实会犯的错
def absolute_value(x):
if x < 0:
print(-x)
else:
print(x)
在交互式解释器里跑 absolute_value(-3),你会看到屏幕上出现 3,于是以为对了。但是:
>>> y = absolute_value(-3)
3
>>> y + 1
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
屏幕上那个 3 是 print 打出来的,函数本身没有 return,所以返回 None。用 print 冒充 return,在交互式解释器里几乎看不出来,一旦把结果拿去参与运算就原形毕露。Lab 01 里 sum_digits 的最后两行 doctest 就是专门用来抓这个错的。
def f(x):
if x > 0:
return "pos"
f(-1) 在交互式解释器里什么都不显示。很多人以为「函数没执行」或者「出错了但没报」。都不是:条件为假,suite 被跳过,函数体执行完了却没碰到任何 return,于是返回 None;而交互式解释器拿到 None 时不显示任何东西。要看清楚,用 repr 或者赋值:
>>> f(-1)
>>> print(f(-1))
None
习惯:写完一个该有返回值的函数,检查每一条可能的执行路径是不是都撞上了 return。把最后一个 elif 改成 else 通常就能保证这一点。
elif 和「新起一条 if 语句」是完全不同的东西:
- 一串
if / elif / elif是一条复合语句,最多执行一个 suite。 - 三条独立的
if是三条语句,每条都会被独立考察,可能执行 0 到 3 个 suite。
当分支之间互斥时,写成独立的 if 常常还能碰对;一旦分支之间存在包含关系(比如「是 3 的倍数」包含「同时是 3 和 5 的倍数」),就一定出事。第 9 节的 fizzbuzz 会把这个坑完整地演一遍。
7. while 循环:把一段代码重复到条件不成立
动机:我想让一组语句执行很多次。 笨办法是复制粘贴代码块——写 1 加到 100 就复制 100 遍。这不只是累,而是根本行不通:重复次数常常取决于运行时才知道的 n,你在写代码的时候压根不知道要粘几遍。
正经办法是迭代(iteration)。while 循环是一种迭代语句:只要某个布尔条件为真,就执行循环体里的全部内容。
while <condition>:
<suite>
- 求值 header 里的条件表达式。
- 若为 truthy:把 suite 从头到尾执行一遍,然后回到第 1 步。
- 若为 falsy:整条 while 语句结束,控制流走到它后面的语句。
「回到第 1 步」这四个字是全部关键:条件在每一轮开始时都会被重新求值,不是只算一次。所以如果循环体里没有任何东西能改变条件的真假,条件就会永远为真。
Demo:把 1 到 100 全加起来
先看错的那版:
>>> i = 1
>>> total = 0
>>> while i <= 100:
... total += i
...
<runs forever, infinite loop>
i = 1:全局帧里 i 绑定到 1。执行 total = 0:total 绑定到 0。i <= 100:查到 i 是 1,1 <= 100 → True。total += i 即 total = total + i,先求值 0 + 1 得 1,把 total 重新绑定到 1。i 还是 1——suite 里没有任何语句碰过 i。条件依然为 True。total 变成 2、3、4……i 永远是 1,条件永远为真。程序永远不会走到 while 之后的语句。真的跑进死循环时,在终端里按 Ctrl+C 打断它,你会看到 KeyboardInterrupt。这不是程序出 bug 报的错,是你亲手把它掐掉了。看到光标一直闪、什么都不输出,第一反应应该是「我是不是漏了更新语句」。
正确的版本只多一行:
>>> i = 1
>>> total = 0
>>> while i <= 100:
... total += i
... i += 1
...
>>> total
5050
逐轮追踪(只列头尾,中间同理):
| 第几轮 | 进入时 i | 进入时 total | 条件 i <= 100 | 执行 total += i 后 | 执行 i += 1 后 |
|---|---|---|---|---|---|
| 1 | 1 | 0 | True | total = 1 | i = 2 |
| 2 | 2 | 1 | True | total = 3 | i = 3 |
| 3 | 3 | 3 | True | total = 6 | i = 4 |
| 4 | 4 | 6 | True | total = 10 | i = 5 |
| … | … | … | … | … | … |
| 100 | 100 | 4950 | True | total = 5050 | i = 101 |
| (第 101 次检查) | 101 | 5050 | False | 循环结束,total 停在 5050 | |
注意最后一行:循环体一共执行了 100 次,但条件被求值了 101 次。最后那次求值为假,才把控制流放出来。写循环时想清楚「条件最后一次为假时各个名字是什么值」,能省掉大量差一错误。
循环三要素
- 初始化(initialization):进入循环前,把要变化的名字都设好初值。
i = 1、total = 0。 - 条件(condition):什么情况下还要继续。
i <= 100。 - 更新(update):循环体里必须有语句让条件朝着「不成立」的方向走一步。
i += 1。
三缺一必出问题:缺初始化 → NameError;缺条件 → 语法都写不出来;缺更新 → 死循环。
顺带说清 +=
total += i 完全等价于 total = total + i。它是一条赋值语句,执行过程是:先求值右边的 total + i,再把结果重新绑定到左边的名字 total。同理 i += 1 是 i = i + 1,y //= 10 是 y = y // 10,i -= 1 是 i = i - 1。
+= 不会凭空创建名字。因为它要先求值右边的 total + i,而这需要先查到 total。名字不存在就直接报错:
>>> total += 1
Traceback (most recent call last):
...
NameError: name 'total' is not defined
这就是「初始化」不能省的机器层面的原因,不是什么编码风格。
同一件事的其他写法
>>> i = 1 >>> i = 100
>>> total = 0 >>> total = 0
>>> while i < 101: >>> while i > 0:
... total += i ... total += i
... i += 1 ... i -= 1
... ...
>>> total >>> total
5050 5050
右边这版值得看一眼:条件从 i <= 100 变成 i > 0,更新从 += 1 变成 -= 1。它循环体也执行 100 次,加的是 100、99、98……1,顺序反了但和不变。
循环不变式(loop invariant)是一句「每一轮开始时都成立」的话。找到它,循环的正确性就不用靠试出来了。
对第一版,不变式是:「total 等于 1 到 i-1 这些数的和」。
- 进入循环前:i = 1,1 到 0 是空的,空的和是 0,而 total 正好是 0。成立。
- 保持:假设某轮开始时成立(total = 1+…+(i-1))。执行
total += i后 total = 1+…+i;再执行i += 1,i 变成 i+1,此时 total 恰好等于「1 到 新i 减 1」的和。不变式又成立了。 - 退出时:条件
i <= 100为假意味着 i = 101(因为 i 每次只加 1,不会跳过 101)。代入不变式:total = 1 到 100 的和 = 5050。
这套「初值成立 + 每轮保持 + 退出时代入」的推理,比在脑子里模拟 100 轮可靠得多,而且能直接告诉你循环结束时到底得到了什么。
while 循环和环境图
把上面这段代码放在全局帧里跑,环境图是这样的:
Global frame
i ──→ 1 → 2 → 3 → … → 100 → 101 # 同一个名字被反复重新绑定
total ──→ 0 → 1 → 3 → 6 → … → 5050
(整个过程中一共只有一个帧。while 不新建帧。)
| 对比项 | 调用一个用户定义的函数 | 执行一轮 while 循环 |
|---|---|---|
| 新建帧吗 | 每调用一次新建一帧 | 从不新建帧 |
| 名字发生了什么 | 形参在新帧里绑定,和外面同名的名字互不干扰 | 在当前帧里把旧绑定覆盖掉 |
| 跑 100 次之后 | 图上有 100 个帧,每个帧保留自己那份 x | 图上还是 1 个帧,i 只有当前这一个值,之前的值全没了 |
「循环里的变量只有一份、会被不断覆盖」听起来平淡,但它正是循环能用固定的内存处理任意长输入的原因,也是后面学递归时最大的对照组——递归靠帧来「记住」中间状态,循环靠反复覆盖同一批名字来推进。
8. 实战一:add_all_even_div_three
随堂练习的起始代码在 02.py。题面:实现 add_all_even_div_three(n),接收一个正整数 n,返回 1 到 n 之间既是偶数、又能被 3 整除的所有数之和。
def add_all_even_div_three(n):
"""
Return the sum of all even numbers from 1 to n that are divisible by 3.
>>> add_all_even_div_three(10) # 6
6
>>> add_all_even_div_three(20) # 6 + 12 + 18
36
>>> add_all_even_div_three(30) # 6 + 12 + 18 + 24 + 30
90
"""
题目到底要什么
把 doctest 翻译成人话,重点看第三个:n = 30 时答案是 6 + 12 + 18 + 24 + 30 = 90。30 本身被算进去了。 这告诉你「从 1 到 n」是闭区间,n 要包含在内。这个细节直接决定循环条件写 <= 还是 <。
另一个边界:n = 1 时,1 到 1 之间没有满足条件的数,函数应当返回 0(空和)。doctest 没测这条,但一个把 total 初始化为 0 的实现自动就对了。
怎么想到的
i 从 1 走到 n。三要素:i = 1 / i <= n / i += 1。total,进入循环前置 0,符合条件时 total += i。and(不是 or)。is_even 这种现成运算符,得自己造。工具是取余运算符 %(读作 modulo):a % b 是 a 除以 b 的余数。「i 是偶数」就是「i 除以 2 余 0」,即 i % 2 == 0。同理「能被 3 整除」是 i % 3 == 0。第 4 步是这道题真正的思维跨度:题目用自然语言描述的性质,你得翻译成一个能求值成布尔值的算术表达式。 后面两道题会一再用到 %。
代码
这是 02-sol.py 里的实现(本仓库里用 python3 -m doctest 02-sol.py 实测通过):
def add_all_even_div_three(n):
i = 1
total = 0
while i <= n:
if i % 2 == 0 and i % 3 == 0:
total += i
i += 1
return total
逐行讲,以及为什么不能是别样
| 行 | 为什么这样写 | 写错会怎样(真实后果) |
|---|---|---|
i = 1 | 题目说「从 1 到 n」,起点是 1 | 写 i = 0 不影响结果(0 不满足条件……其实 0 % 2 == 0 且 0 % 3 == 0 都成立,会多加一个 0,结果碰巧不变,但意思就错了) |
total = 0 | 累加器必须先存在,+= 才能查到它 | 省掉这行:NameError: name 'total' is not defined |
while i <= n: | 闭区间,n 本身要检查 | 写成 i < n:add_all_even_div_three(30) 漏掉 30,返回 60 而不是 90,第三个 doctest 挂 |
if i % 2 == 0 and i % 3 == 0: | 两个条件同时成立 | 写成 or:add_all_even_div_three(10) 把 2,3,4,6,8,9,10 全加上,返回 42 |
total += i | 缩进在 if 的 suite 里,只有满足条件才累加 | 顶格到 while 那一层:所有数都被加,1 到 10 加成 55 |
i += 1 | 在 while 的 suite 里、在 if 的 suite 外 | 缩进进 if 里:i=1 时条件不成立,i 不变,条件永远为真 → 死循环 |
return total | 和 while 同级,循环彻底结束后才返回 | 缩进进 while 里:第一轮末就 return,add_all_even_div_three(10) 返回 0 |
这张表里有四行错误全部源自缩进。回到第 5 节那句话:在 Python 里缩进是语法。i += 1 和 return total 各自属于哪个 suite,是这道题唯一真正的难点。
「return 一执行,函数立刻结束」意味着套在外面的循环也一并结束。所以下面这版只跑了一轮:
def add_all_even_div_three(n):
i = 1
total = 0
while i <= n:
if i % 2 == 0 and i % 3 == 0:
total += i
i += 1
return total # 错:缩进多了一层
实测 add_all_even_div_three(10) 返回 0:i=1 不满足条件,i 变成 2,然后 return total 把还是 0 的 total 交出去,函数结束。凡是「循环跑完才有答案」的函数,return 一定和 while 平级。
手动验证:n = 10
doctest 说答案是 6。逐轮追踪一遍,顺便观察 and 的短路:
| i | i % 2 == 0 | i % 3 == 0 | 整个条件 | 本轮末 total |
|---|---|---|---|---|
| 1 | 1 % 2 是 1 → False | 短路,不求值 | False | 0 |
| 2 | True | 2 % 3 是 2 → False | False | 0 |
| 3 | False | 短路 | False | 0 |
| 4 | True | 4 % 3 是 1 → False | False | 0 |
| 5 | False | 短路 | False | 0 |
| 6 | True | 6 % 3 是 0 → True | True | 6 |
| 7 | False | 短路 | False | 6 |
| 8 | True | 8 % 3 是 2 → False | False | 6 |
| 9 | False | 短路 | False | 6 |
| 10 | True | 10 % 3 是 1 → False | False | 6 |
| 11 | 条件 11 <= 10 为 False,循环结束,return total 交出 6 | |||
注意第 1、3、5、7、9 行:i 是奇数时左操作数就是 False,and 短路,i % 3 == 0 那半边根本没算。短路不只是考试题,它在每一次循环里都在悄悄省事。
Global frame
add_all_even_div_three ──→ func add_all_even_div_three(n) [parent=Global]
f1: add_all_even_div_three [parent=Global]
n ──→ 10 # 形参,由实参 10 绑定,全程不变
i ──→ 1 → 2 → 3 → … → 10 → 11 # 循环体里被反复重新绑定
total ──→ 0 → … → 6 # 只在 i = 6 那一轮变过
返回值 ──→ 6
三点值得记:
n也住在 f1 里,它是形参绑定的名字,和i、total平起平坐。i和total是函数体里第一次赋值时创建的局部名字。它们只存在于 f1,函数返回后这一帧就不再被用到,外面看不到i和total。在全局作用域里敲i会得到NameError。- 循环跑了 10 轮,图上始终只有 f1 这一个帧。
「既是 2 的倍数、又是 3 的倍数」等价于「是 6 的倍数」。于是可以直接以 6 为步长跳着走:
def add_all_even_div_three(n):
i = 6
total = 0
while i <= n:
total += i
i += 6
return total
我对 n = 1, 5, 6, 10, 20, 30 逐一比对过,两版结果完全一致。它循环轮数少得多(n=30 时只转 5 轮而不是 30 轮)。但它把「偶数且被 3 整除」这个条件藏进了步长里,读代码的人得自己反推出 6 是怎么来的;而且一旦题目改成「被 4 和 6 整除」,很多人会顺手写 24 而不是正确的 12。61A 阶段更推荐把条件写在明处的第一版——先写对、写清楚,再谈快。
9. 实战二:fizzbuzz —— 条件顺序为什么要命
题面:实现 fizzbuzz(n),接收一个正整数 n,打印 1 到 n 的数字,其中 3 的倍数换成 "fizz",5 的倍数换成 "buzz",同时是 3 和 5 的倍数换成 "fizzbuzz"。
>>> fizzbuzz(5)
1
2
fizz
4
buzz
>>> fizzbuzz(15)
1
2
fizz
4
buzz
fizz
7
8
fizz
buzz
11
fizz
13
14
fizzbuzz
题目到底要什么
第一件要认清的事:这道题打印,不返回。doctest 里 >>> fizzbuzz(5) 下面那五行是 print 打出来的,不是返回值。
能不能用 return 蒙混?fizzbuzz(1) 那条碰巧看不出区别(return 1 也会让解释器显示 1),但 fizzbuzz(3) 要求输出三行,而一次函数调用只能 return 一次——return 一执行函数就结束了。「要输出多行」本身就排除了 return。
第二件:fizzbuzz 没有 return 语句,所以它返回 None。doctest 里看不到 None,是因为交互式解释器拿到 None 时什么都不显示。这正好是 return 与 print 的经典对照:
| 对比项 | return | print |
|---|---|---|
| 做什么 | 把一个值交回给调用者 | 把值的文本形式送到屏幕 |
| 对控制流的影响 | 函数立即结束(外层循环一并结束) | 没有影响,函数继续往下跑 |
| 一次调用能做几次 | 至多一次 | 任意多次 |
| 能被赋值捕获吗 | 能:a = f(x) 拿到返回值 | 不能:a = print(1) 让 a 变成 None |
| 不写会怎样 | 函数返回 None | 屏幕上什么都没有 |
| 在交互式解释器里 | 返回 None 时不显示任何东西 | 立刻显示 |
| 在别的地方调用时(如脚本里) | 值可以继续参与计算 | 只是副作用,值没了 |
怎么想到的
while,三要素:i = 1 / i <= n / i += 1。if / elif / elif / else,四个 clause。i % 3 == 0、i % 5 == 0、以及两者同时成立即 i % 3 == 0 and i % 5 == 0。回到第 6 节的执行规则:条件语句最多执行一个 suite,一旦某个条件为真,剩下的全部跳过。 而这三个条件不是互斥的——满足「3 和 5 的倍数」的数(15、30、45……)同时也满足「3 的倍数」和「5 的倍数」。用集合的说法,「fizzbuzz 的数」是「fizz 的数」的一个子集。
当若干条件存在包含关系时,更特殊(覆盖范围更小)的条件必须写在更靠前的位置。写在后面的话,靠前那个更宽松的条件会先把它拦下来,后面那个 clause 就永远轮不到——成了死代码(dead code)。
代码
02-sol.py 的实现(实测 doctest 通过):
def fizzbuzz(n):
i = 1
while i <= n:
if i % 3 == 0 and i % 5 == 0:
print("fizzbuzz")
elif i % 3 == 0:
print("fizz")
elif i % 5 == 0:
print("buzz")
else:
print(i)
i += 1
最特殊的 fizzbuzz 排第一,然后是两个单条件,最后 else 兜底。i += 1 在 while 的 suite 里、在整条条件语句之后、和 if 平级——它必须每轮都执行,不管走的是哪个分支。函数末尾没有 return,因为题目只要求打印。
逐步推演三个关键的 i
i % 3 == 0 and i % 5 == 0。按优先级,先算两边的比较:左边 (15 % 3) == 0 → 0 == 0 → True。and 不短路,继续求值右操作数 (15 % 5) == 0 → 0 == 0 → True。and 表达式的值 = 右操作数的值 = True,truthy。print("fizzbuzz"),屏幕上出现 fizzbuzz。i += 1。3 % 3 == 0 → True,and 继续看右边。3 % 5 == 0:3 % 5 是 3(3 除以 5 商 0 余 3),3 == 0 → False。and 的值 = False,falsy → 跳过第一个 suite,考察第二个 clause。3 % 3 == 0 → True → 执行 print("fizz")。elif 和 else。4 % 3 是 1,1 == 0 → False。and 短路,右边 4 % 5 == 0 不求值。整个条件为 False。4 % 3 == 0 → False。4 % 5 是 4,4 == 0 → False。else 的 suite:print(i),打印 4。把顺序写反会发生什么(真实输出)
下面这版是初学者最常写出来的,看上去「三个条件都写全了」:
def fizzbuzz_bad(n):
i = 1
while i <= n:
if i % 3 == 0:
print("fizz")
elif i % 5 == 0:
print("buzz")
elif i % 3 == 0 and i % 5 == 0: # 永远走不到这里
print("fizzbuzz")
else:
print(i)
i += 1
fizzbuzz_bad(15) 的实际输出,最后一行是 fizz 而不是 fizzbuzz:
1
2
fizz
4
buzz
fizz
7
8
fizz
buzz
11
fizz
13
14
fizz <-- 应该是 fizzbuzz
15 % 3 == 0:15 % 3 是 0,0 == 0 → True。print("fizz")。elif 和 else 全部跳过——包括那个专门为 15 写的 clause。fizz。那句 print("fizzbuzz") 一次都没执行过。错误版里第三个 clause 是永远不可能执行的死代码,而且理由可以严格推出来:能走到第三个 clause,说明第一个 clause 的条件 i % 3 == 0 已经求值为 False;而第三个 clause 的条件里包含 i % 3 == 0,它必然也是 False。这个 clause 的条件恒为假。
Python 不会警告你,doctest 只会告诉你输出不对。自己检查 if 链的办法:从上往下读,读到第 k 个 clause 时,先把前面 k-1 个条件全部加上 not 当成已知前提,再看当前条件在这个前提下还有没有可能为真。为假就是死代码。
「同时是 3 和 5 的倍数」等价于「是 15 的倍数」,所以第一个条件也可以写成 i % 15 == 0。它更短,但和第 8 节那个「步长 6」的写法一样,把两个条件的合取压缩成了一个魔法数字。写成 and 的形式和题面一一对应,更不容易在改需求时出错。
print(i)别写成print("i")——后者打印的是字母i,doctest 会给你一整屏的i。- 三个字符串常量的拼写和大小写必须和 doctest 完全一致:
"fizz"、"buzz"、"fizzbuzz"。doctest 是逐字符比对输出的,多一个空格、大写一个字母都算失败。
10. 实战三:sum_digits —— 用算术把一个数拆开
这道题同时是 Lab 01 的最后一题(Q10)。题面:实现 sum_digits(y),接收一个正整数,返回它各位数字之和。
def sum_digits(y):
"""Sum all the digits of y.
>>> sum_digits(10) # 1 + 0 = 1
1
>>> sum_digits(4224) # 4 + 2 + 2 + 4 = 12
12
>>> sum_digits(1234567890)
45
>>> a = sum_digits(123) # make sure that you are using return rather than print
>>> a
6
"""
题目到底要什么
前三条 doctest 直白。值得琢磨的是最后两条——注释都写在脸上了:make sure that you are using return rather than print。
它是这样抓人的:如果你把结果 print 出来而不是 return,那么 sum_digits(123) 会先在屏幕上打一个 6,然后返回 None。a = sum_digits(123) 这行的期望输出是空(赋值语句不显示任何东西),但你的版本会打出一个 6,doctest 当场失败;就算蒙混过去,下一行 >>> a 期望显示 6,而 a 是 None,解释器什么都不显示,又失败。把返回值先存进一个名字再检查——这是 61A 全学期用来分辨 return 和 print 的标准手法,以后你会反复见到。
怎么想到的:手上只有一个整数
这是本讲最值钱的一次思维跨度,所以把弯路也写出来。
for 循环现在都还没学。而且就算学了,61A 出这道题的目的恰恰是逼你不用它们。y % 10 给出个位。4224 % 10 是 4。因为十进制下除以 10 的余数就是最低位。y // 10 把个位砍掉。// 是整除(floor division),只保留商的整数部分:4224 // 10 是 422。while。total = 0(y 是形参,已经有值了);条件 y > 0;更新 y //= 10——正是这一句让 y 单调变小、循环必然终止。对一个十进制整数 n(n > 0):n % 10 取出最低位,n // 10 去掉最低位。 这一对操作是 61A 前几周所有「逐位处理数字」题目的通用钥匙——Lab 01 的 double_eights、digit,HW 01 的 hailstone 里的奇偶判断,全靠 % 和 //。没有它,你连「取出一个数的某一位」都做不到。
代码
讲义 02-sol.py 的版本:
def sum_digits(y):
total = 0
while y > 0:
digit = y % 10
total += digit
y //= 10
return total
逐行:
total = 0:累加器初始化。空和是 0,所以sum_digits(0)会返回0(循环一次都不进)——题目保证正整数,不用管,但知道这个行为有助于理解「循环体可能执行 0 次」。while y > 0::只要 y 还有位数就继续。digit = y % 10:取出当前最低位,起个名字方便读。total += digit:累加。y //= 10:更新,砍掉最低位。这一句就是让循环能终止的那一句。return total:和 while 平级,循环彻底跑完才交出答案。
手动追踪:sum_digits(4224)
| 轮次 | 进入时 y | 条件 y > 0 | digit = y % 10 | total += digit 后 | y //= 10 后 |
|---|---|---|---|---|---|
| 1 | 4224 | True | 4 | 0 + 4 = 4 | 422 |
| 2 | 422 | True | 2 | 4 + 2 = 6 | 42 |
| 3 | 42 | True | 2 | 6 + 2 = 8 | 4 |
| 4 | 4 | True | 4 | 8 + 4 = 12 | 0 |
| (第 5 次检查) | 0 | False | 循环结束,执行 return total,交出 12 | ||
和 doctest 的 12 一致。注意第 4 轮:4 // 10 是 0(商 0 余 4),正是这一步把 y 送到了 0,循环才停下来。
Global frame
sum_digits ──→ func sum_digits(y) [parent=Global]
f1: sum_digits [parent=Global]
y ──→ 4224 → 422 → 42 → 4 → 0 # 形参也可以被重新绑定
total ──→ 0 → 4 → 6 → 8 → 12
digit ──→ 4 → 2 → 2 → 4 # 第一轮才被创建出来
返回值 ──→ 12
y //= 10 改的是 f1 这一帧里的名字 y。如果调用方写的是:
>>> n = 4224
>>> sum_digits(n)
12
>>> n
4224
n 还是 4224,一点没变。原因有两层:(1)n 住在全局帧,y 住在 f1,是两个不同帧里的两个不同名字,重新绑定 y 不会碰到 n;(2)整数本身是不可变的,y //= 10 并没有「把 4224 这个对象改小」,而是算出一个新的整数 422,再让 y 指向它。这两层区别在后面学到列表时会变得极其重要——那时候第(2)条不再成立。
Lab 01 官方那一版:多重赋值
本仓库里 labs/lab01/lab01.py 中真实通过 ok 的写法是这样的:
def sum_digits(y):
total = 0
while y > 0:
y, last = y // 10, y % 10
total = total + last
return total
它把「砍掉个位」和「取出个位」合并成一行。要看懂这一行,得知道多重赋值(multiple assignment)的执行规则:
y, last = y // 10, y % 10(此时 y 是 4224)
y // 10 → 422;y % 10 → 4。此时 y 仍然是 4224。y 绑定到 422,last 绑定到 4。y % 10 用的是旧的 y,不会被同一行里刚算出来的 y // 10 影响。看起来只是排版变化,实际是完全不同的程序:
def sum_digits_wrong(y):
total = 0
while y > 0:
y = y // 10 # y 先被改了
last = y % 10 # 这里取的是「砍过一位之后」的个位,取错了
total = total + last
return total
实测 sum_digits_wrong(4224) 返回 8(正确答案是 12)。追一遍就清楚了:第 1 轮 y 变成 422,取到的 last 是 2(不是 4);第 2 轮 y 变成 42,last 是 2;第 3 轮 y 变成 4,last 是 4;第 4 轮 y 变成 0,last 是 0。加起来 2+2+4+0 = 8——最高位的 4 被加了,最低位的 4 从头到尾没被加过。
更阴险的是 sum_digits_wrong(10) 返回 1,跟正确答案一样。只用第一条 doctest 测试是发现不了这个 bug 的。 这就是为什么 61A 的 doctest 总要给好几个例子。
其余三个真实会犯的错
def sum_digits(y):
total = 0
while y > 0:
digit = y % 10
total += digit
y //= 10
return total # 错:缩进多一层
实测 sum_digits(123) 返回 3。循环只跑了一轮:取到个位 3,累加,把 y 变成 12,然后 return 让整个函数立刻结束——连带把 while 也终结了。
症状很好认:结果总是只反映第一轮。看到「答案等于最低位 / 第一个元素」,先去检查 return 的缩进。
/ 而不是 //
/ 是真除法,结果是浮点数:4224 / 10 是 422.4,不是 422。之后 422.4 % 10 得到 2.4000000000000057 这种东西,累加出来的结果毫无意义。
而且循环不会立刻停:浮点数每次除以 10 只是不断趋近 0,永远不等于 0。我实测过:这个循环会跑 328 轮,直到浮点数下溢成 0.0 才退出,最后返回 13.333333333333306。既慢又错,还不报错——最难查的那种 bug。
凡是要「把数字往下砍一位」,一律用 //。
while y != 0
对正整数,y != 0 和 y > 0 行为完全一样,doctest 都能过。但要是有人传进负数:
>>> -123 // 10
-13
>>> -13 // 10
-2
>>> -2 // 10
-1
>>> -1 // 10
-1
因为 // 是向下取整(朝负无穷方向),-1 // 10 是 -1 而不是 0。于是 y 卡在 -1 上再也变不动,y != 0 永远为真——死循环。而 y > 0 版本会在第一次检查时就退出,返回 0。
题目保证正整数,两种写法都算对;但当两个写法「在合法输入上等价」时,选那个在非法输入上失败得更温和的。这是一个能用一辈子的习惯。
自己动手跑一遍
把实现写进 02.py 之后,用讲义给的命令跑 doctest:
python3 -m doctest 02.py
没有任何输出就是全部通过。 这一点常让人困惑——很多人以为「什么都没显示」意味着命令没跑起来。想看细节就加 -v:
python3 -m doctest 02.py -v
失败时它会明确打出 Expected: 和 Got: 两段,逐字符对比。把这两段读清楚,往往比重读一遍自己的代码有用得多。
本讲小结
| 概念 | 要点 | 典型陷阱 |
|---|---|---|
| 布尔值 | 只有 True / False 两个值;x > 0 是求值成布尔值的表达式 | 写成小写 true → NameError |
| 真假性 | falsy 只有 False、0、0.0、None、空容器;其余全 truthy | 以为 -1、'0'、' ' 是假;用 if x == True: 代替 if x: |
= vs == | = 是赋值语句,== 是比较表达式 | 条件里写 = → SyntaxError,整个文件一行都跑不了 |
| 短路 | and 遇 falsy 停、or 遇 truthy 停;返回最后看过的操作数的值,未必是布尔值 | 以为 1 and 2 是 True;以为 5 or 1/0 会报错 |
| 优先级 | 算术 > 比较 > not > and > or | 不补括号就下结论;括号位置一变答案就变 |
not | 唯一总是返回真正 True/False 的逻辑运算符 | 以为 not 0 是 0 |
| 复合语句 | header + suite = clause;缩进是语法 | 漏冒号 → SyntaxError: expected ':';header 后没缩进 → IndentationError |
| 条件语句 | 从上到下考察,最多执行一个 suite,命中即跳过其余全部 | 把更特殊的条件写在后面 → 死代码;把 elif 写成独立 if → 多个分支都执行 |
| 路径覆盖 | 每条执行路径都该撞上 return | 某条路径没 return → 悄悄返回 None,解释器里看不出来 |
while | 三要素:初始化 / 条件 / 更新;条件每轮开头重新求值 | 漏更新 → 死循环;更新语句被缩进进 if 里 → 有时死循环 |
return vs print | return 交出值并立即结束函数(连同外层循环);print 只打字 | return 缩进进 while → 只跑一轮;用 print 冒充 return → 返回 None |
| 不新建帧 | 环境图里只有调用用户定义函数才新建帧;if、while 都不建 | 给每轮循环画一个帧 |
% 与 // | n % 10 取最低位,n // 10 去掉最低位 | 用 / 代替 // → 变浮点数,结果错且循环拖很久;负数上 // 向下取整会卡在 -1 |
| 多重赋值 | 右边全部求值完,再一起绑定左边 | 拆成两行写,第二行用到的是已经被改过的值 |
| 循环不变式 | 「每轮开始时都成立的一句话」,用来证明循环对不对、算出退出时的结果 | 只靠脑内模拟前两轮就下结论 |
- 条件语句最多执行一个 suite——所以更特殊的条件必须写在前面。
while循环三要素缺一不可,更新语句的缩进层级决定它是不是每轮都跑。and/or返回的是操作数的值,不是布尔值;没看到的那半边根本没被计算。
动手练习
五道题。先自己在纸上写出答案,再打开折叠对照——直接看答案等于没做。
练习 1:WWPD(布尔表达式)
写出下列每个表达式的值。不要只写「真」或「假」,要写出具体是哪个值。
>>> 1 and 0 or 2
>>> not 0 and 3
>>> '' or 0
>>> 0 or '' or None
>>> 2 or 1 / 0
看答案
| 表达式 | 值 | 推演 |
|---|---|---|
1 and 0 or 2 | 2 | and 比 or 紧,等价于 (1 and 0) or 2。1 truthy → and 返回右操作数 0。再算 0 or 2:0 falsy → 返回右操作数 2 |
not 0 and 3 | 3 | not 比 and 紧,等价于 (not 0) and 3。not 0 是 True,truthy → and 返回右操作数 3。不是 True |
'' or 0 | 0 | 空串 falsy → 返回右操作数 0。注意结果依然是 falsy 的,但值是 0 不是 False |
0 or '' or None | None | 从左往右:0 falsy → 看 '',还是 falsy → 看 None。没得看了,返回最后一个操作数 None。解释器什么都不显示 |
2 or 1 / 0 | 2 | 2 truthy,or 短路,1 / 0 没被求值,因此不报 ZeroDivisionError |
练习 2:WWPD(条件语句)
下面这个函数把本该用 elif 的地方写成了两条独立的 if。写出 g(7)、g(3)、g(-1) 各自打印什么(一行一个)。
def g(x):
if x > 5:
print("big")
if x > 0:
print("positive")
else:
print("nonpositive")
看答案
g(7) 打印两行:big,然后 positive。
g(3) 打印一行:positive。
g(-1) 打印一行:nonpositive。
关键在于这里是两条独立的语句,不是一条三分支的条件语句:
- 第一条语句
if x > 5:只有一个 clause,考察完就结束了。它不会阻止第二条语句被执行。 - 第二条语句
if x > 0: ... else: ...有两个 clause,无论如何都会执行其中恰好一个。 - 所以
x = 7时两条语句各打印一次,一共两行。若把第二个if改成elif,整段就变成一条三 clause 的语句,g(7)就只会打印big一行。
顺带注意:else 属于离它最近的那个 if(第二个),跟第一个 if 毫无关系。这也是这段代码读起来容易误解的原因——缩进对齐了,但归属关系不是靠对齐决定的。
练习 3:找 bug
下面这个函数想返回 1 到 n 之间所有偶数的和。f(6) 会发生什么?
def f(n):
i = 1
total = 0
while i <= n:
if i % 2 == 0:
total += i
i += 1
return total
看答案
死循环,f(6) 永远不返回(终端里按 Ctrl+C 打断会看到 KeyboardInterrupt)。
推演:
- 初始
i = 1,total = 0。 - 条件
1 <= 6→True,进入循环体。 - 循环体里唯一的语句是那条
if。求值1 % 2 == 0→1 == 0→False,suite 被整个跳过——包括i += 1,因为它被缩进进了if的 suite 里。 - 循环体执行完毕,回到条件。
i还是 1,条件还是True。 - 从此每一轮都完全重复上一轮,
i永远是 1。
修法:把 i += 1 往左退一级,让它和 if 平级——它必须每轮都执行,跟当前这个 i 是不是偶数无关。
def f(n):
i = 1
total = 0
while i <= n:
if i % 2 == 0:
total += i
i += 1
return total
改完 f(6) 返回 12(2 + 4 + 6)。
诊断口诀:程序卡住不动 → 先看更新语句的缩进层级 → 它是不是被塞进了某个 if 里。
练习 4:写代码
实现 count_digits(n),返回正整数 n 的位数。count_digits(4224) 应得 4,count_digits(7) 应得 1,count_digits(1234567890) 应得 10。
看答案
def count_digits(n):
count = 0
while n > 0:
count += 1
n //= 10
return count
思路和 sum_digits 是同一个骨架:反复砍掉最低位,直到数字变成 0。区别只在于累加器加的是 1(数一位)而不是 n % 10(加一位的值)。这里连 n % 10 都不需要——我们只关心「还剩几位」,不关心是哪几位。
追踪 count_digits(4224):
| 轮次 | 进入时 n | count += 1 后 | n //= 10 后 |
|---|---|---|---|
| 1 | 4224 | 1 | 422 |
| 2 | 422 | 2 | 42 |
| 3 | 42 | 3 | 4 |
| 4 | 4 | 4 | 0 |
| (第 5 次检查) | 0 | 条件 0 > 0 为假,返回 4 | |
边界值得一提:count_digits(0) 会返回 0,但严格说 0 写出来是一位。题目限定正整数,所以不算错;真要处理 0,得在循环外单独加一个 if n == 0: return 1。能说清自己的实现在边界上是什么行为,比含糊地「应该没问题」强得多。
练习 5:循环退出时的值
下面这个循环的条件是 i < n,但更新是 i += 2。h(5) 返回什么?h(6) 呢?h(0) 呢?
def h(n):
i = 0
while i < n:
i += 2
return i
看答案
h(5) → 6;h(6) → 6;h(0) → 0。
h(5):i 依次取 0 → 2 → 4 → 6。条件检查的过程是 0 < 5 True、2 < 5 True、4 < 5 True、6 < 5 False。i 跳过了 5,直接冲到 6 才让条件转假。
h(6):0 < 6、2 < 6、4 < 6 都为真,6 < 6 为假。i 恰好停在 6。
h(0):第一次检查 0 < 0 就是假,循环体一次都不执行,直接返回初值 0。
这道题要打掉一个很常见的错觉:「循环结束时 i 恰好等于 n」并不总是成立。只有当更新步长是 1、且条件是 i < n 时,i 才恰好停在 n。步长一变,i 会「冲过头」,最终落在第一个使条件为假的值上。
所以在写「循环结束后再用一次 i」的代码时,一定要先问自己:条件最后一次为假的那一刻,i 到底是几? 第 7 节讲的循环不变式就是用来严格回答这个问题的。