LECTURE 02

控制:布尔值、条件语句与 while 循环

让程序不再是一条直路——学会按条件挑选要走的分支,以及把同一段代码重复执行到条件不再成立。

教材:Composing Programs §1.5 Control 对应作业:Lab 01 / HW 01

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」。

Truthy and Falsy Values 幻灯片
falsy 的完整名单短得可以背下来:False、0、0.0、None、空容器。凡是不在这张名单上的值,放在 if 或 while 的条件位置上都会被当成「真」——包括 -1、0.1、字符串 '0' 和只含一个空格的字符串 ' '。

下面这张表里的几个值,几乎每个初学者都会栽一次:

值真假性为什么容易搞错
0falsy—
-1truthy「负数」不是「零」。只有 0 才 falsy,负数照样为真
0.0falsy浮点零也是零
0.1truthy—
''(空字符串)falsy—
' '(一个空格)truthy它不是空串,里面装着一个空格字符
'0'truthy这是一个长度为 1 的字符串,不是数字 0
'False'truthy同上,字符串的内容长什么样跟真假性无关
Nonefalsy没写 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 > 18True
<小于21 < 18False
>=大于等于21 >= 21True
<=小于等于18 <= 21True
==等于3 == 3.0True
!=不等于3 != 4True
常见误区

= 和 == 是完全不同的两个东西,而且这是初学阶段出现频率最高的错误之一。

  • = 是赋值语句的一部分: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
左 = TTF
左 = FFF

or:至少有一边为真,结果就为真。

or右 = T右 = F
左 = TTT
左 = FTF

not:把当前真假翻转。它只有一个操作数。

表达式值
not TrueFalse
not FalseTrue

真值表告诉你结果的真假,但它没告诉你结果究竟是哪个值,也没告诉你 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 <右>:

逐步推演
1 求值左操作数。
2 如果它是 falsy:整个表达式的值就是左操作数的值,右操作数根本不被求值。结束。
3 否则(左操作数 truthy):求值右操作数,整个表达式的值就是右操作数的值。

求值 <左> or <右>:

逐步推演
1 求值左操作数。
2 如果它是 truthy:整个表达式的值就是左操作数的值,右操作数不被求值。结束。
3 否则:求值右操作数,整个表达式的值就是右操作数的值。

把这两条压成一句话:整个表达式的值等于「最后看过的那个操作数」的值,而不一定是布尔类型。

为什么要这样设计?动机是不做多余的计算。and 一旦碰到第一个 falsy 值,结果就已经注定是假了,再算下去没有意义;or 一旦碰到第一个 truthy 值,结果注定为真。能提前退出就提前退出——这就是短路(short circuiting)。

注意 not 不一样:not 永远返回真正的 True 或 False,不返回操作数本身。not 0 是 True,不是 0;not '' 是 True。

表达式值发生了什么
True and 00左 truthy,继续看右,返回右操作数的值 0
0 or ''''左 falsy,继续看右,返回右操作数的值 ''
1 and 22左 truthy,返回右
-1 and 0.00.0-1 是 truthy,所以要看右边
'' or 'hi''hi'空串 falsy,返回右
1 or 1/01左 truthy 立刻短路,1/0 从来没被计算过,所以不报错
not 0Truenot 返回真正的布尔值
not ''True同上

运算符优先级

没有括号时,Python 按固定的紧密程度分组。跟本讲相关的部分,从紧到松是:

松紧运算符
最紧算术:** > * / // % > + -
↓比较:< <= > >= == !=
↓not
↓and
最松or

「紧」的意思是它先把两边的东西抓过去结合。所以 i % 2 == 0 and i % 3 == 0 不需要任何括号——% 比 == 紧,== 比 and 紧,自动分组成 ((i % 2) == 0) and ((i % 3) == 0),正是我们想要的。

What just happened? Short circuiting! 幻灯片
短路的两条规则加上一条最容易被忽略的结论:整个表达式的值是「最后看的那个值」,未必是布尔类型。下方的例子说明没有括号时 Python 先把 not 结合、再 and、最后 or。

把两件事合起来推演

看幻灯片上那个例子。6 / 0 是一个必然会抛 ZeroDivisionError 的表达式,但下面这行代码平安无事,值是 5:

>>> 5 or (6 / 0) and not None
5
逐步推演
1 先按优先级补括号。not 最紧,先抓住 None;然后 and;最后 or。于是原式等价于 5 or ((6 / 0) and (not None))。
2 顶层运算符是 or,它的左操作数是 5,右操作数是整个 ((6 / 0) and (not None))。
3 按 or 的规则,先求值左操作数:5 求值成 5。
4 5 是 truthy(不是 0,不代表「什么都没有」)。
5 短路:右操作数整个不求值。6 / 0 从头到尾没被计算,因此没有 ZeroDivisionError。
6 整个表达式的值 = 最后看过的那个操作数的值 = 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 bx = 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——控制它执不执行、执行几次。

Statements:Compound Statement / Clause / Suite 的结构图
虚线框标出了三层嵌套关系:最外层是一条复合语句,它由若干 clause 组成;每个 clause = 一个 header + 它控制的 suite。到本讲之前你只见过一种复合语句:def。

到目前为止你只学过一种复合语句: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 语句。

Conditional Statements 语法:if / elif / else
条件语句的完整形状。三个 clause 各有一个 header 和一个 suite;只有 if 是必需的,elif 可以有任意多个(包括 0 个),else 可有可无且没有条件表达式。
if <expression>:
    <suite>
elif <expression>:
    <suite>
else:
    <suite>

执行规则(要背下来)

条件语句的执行规则

按从上到下的顺序,逐个考察每个 clause:

  1. 求值这个 clause 的 header 表达式(else 没有表达式,视作条件永远成立)。
  2. 如果它是 truthy:执行这个 clause 的 suite,然后跳过整条条件语句剩下的全部 clause,控制流走到条件语句之后。
  3. 如果它是 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)
1 求值调用表达式 absolute_value(-3):先求值算子 absolute_value,在全局帧里查到一个函数对象;再求值算子数 -3,得到 -3。
2 新建一帧 f1,parent 是 Global,把形参 x 绑定到 -3。
3 在 f1 里执行函数体。函数体的第一条(也是唯一一条)语句是条件语句,考察第一个 clause。
4 求值 x < 0:在 f1 里查到 x 是 -3,-3 < 0 求值成 True。
5 True 是 truthy → 执行这个 clause 的 suite:return -x。求值 -x 得到 3。
6 return 让函数立即结束,返回值 3。elif 和 else 两个 clause 连条件都不会被求值。
环境图:调用 absolute_value(-3) 期间
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) 与 absolute_value(5)
1 absolute_value(0):新建帧,x 绑定到 0。
2 求值 x < 0 → 0 < 0 → False,falsy,跳过它的 suite,考察下一个 clause。
3 求值 x == 0 → True,执行 return 0,函数结束,返回 0。else 不被考察。
4 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 < 0if-xif-x一致
x == 0elif0elsex,此时 x 就是 0一致
x > 0elsexelsex一致

关键在第二行:x == 0 时,return 0 和 return x 给出同一个值,所以那个 elif 是多余的。elif 和 else 本来就都是可选的,而且 elif 可以写任意多个。

三个真实会犯的错

常见误区一:分支里只有 print,没有 return
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 就是专门用来抓这个错的。

常见误区二:函数有条路径没写 return
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

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>
while 语句的执行规则
  1. 求值 header 里的条件表达式。
  2. 若为 truthy:把 suite 从头到尾执行一遍,然后回到第 1 步。
  3. 若为 falsy:整条 while 语句结束,控制流走到它后面的语句。

「回到第 1 步」这四个字是全部关键:条件在每一轮开始时都会被重新求值,不是只算一次。所以如果循环体里没有任何东西能改变条件的真假,条件就会永远为真。

Demo:把 1 到 100 全加起来

Demo: Add all integers from 1 to 100,左边死循环右边正确
左右两段只差一行 i += 1。左边少了这一行,条件 i <= 100 里的 i 永远是 1,循环再也退不出去;右边加上之后,i 一路走到 101,条件转假,total 得到 5050。

先看错的那版:

>>> i = 1
>>> total = 0
>>> while i <= 100:
...     total += i
...
<runs forever, infinite loop>
逐步推演:死循环是怎么发生的
1 执行 i = 1:全局帧里 i 绑定到 1。执行 total = 0:total 绑定到 0。
2 求值条件 i <= 100:查到 i 是 1,1 <= 100 → True。
3 执行 suite:total += i 即 total = total + i,先求值 0 + 1 得 1,把 total 重新绑定到 1。
4 回到第 2 步。i 还是 1——suite 里没有任何语句碰过 i。条件依然为 True。
5 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 后
110Truetotal = 1i = 2
221Truetotal = 3i = 3
333Truetotal = 6i = 4
446Truetotal = 10i = 5
………………
1001004950Truetotal = 5050i = 101
(第 101 次检查)1015050False循环结束,total 停在 5050

注意最后一行:循环体一共执行了 100 次,但条件被求值了 101 次。最后那次求值为假,才把控制流放出来。写循环时想清楚「条件最后一次为假时各个名字是什么值」,能省掉大量差一错误。

这个和确实是 5050 $$\sum_{i=1}^{100} i = \frac{100 \times 101}{2} = 5050$$

循环三要素

写任何 while 循环之前,先把这三件事写在纸上
  1. 初始化(initialization):进入循环前,把要变化的名字都设好初值。i = 1、total = 0。
  2. 条件(condition):什么情况下还要继续。i <= 100。
  3. 更新(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

这就是「初始化」不能省的机器层面的原因,不是什么编码风格。

同一件事的其他写法

Demo: Alternate solutions,两种等价的 while 写法
左边把条件写成 i < 101(和 i <= 100 等价);右边干脆倒着数:i 从 100 出发,条件 i > 0,更新 i -= 1。三要素配套即可,写法不唯一,结果都是 5050。
>>> 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 循环和环境图

把上面这段代码放在全局帧里跑,环境图是这样的:

环境图:1 加到 100(全局帧,无函数调用)
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 的实现自动就对了。

怎么想到的

1 「从 1 到 n 逐个检查」——这就是循环。需要一个计数器 i 从 1 走到 n。三要素:i = 1 / i <= n / i += 1。
2 「求和」——需要一个累加器 total,进入循环前置 0,符合条件时 total += i。
3 「符合条件」——两个条件同时成立,用 and(不是 or)。
4 「是偶数」怎么写? Python 没有 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 被写进循环里

「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 的短路:

ii % 2 == 0i % 3 == 0整个条件本轮末 total
11 % 2 是 1 → False短路,不求值False0
2True2 % 3 是 2 → FalseFalse0
3False短路False0
4True4 % 3 是 1 → FalseFalse0
5False短路False0
6True6 % 3 是 0 → TrueTrue6
7False短路False6
8True8 % 3 是 2 → FalseFalse6
9False短路False6
10True10 % 3 是 1 → FalseFalse6
11条件 11 <= 10 为 False,循环结束,return total 交出 6

注意第 1、3、5、7、9 行:i 是奇数时左操作数就是 False,and 短路,i % 3 == 0 那半边根本没算。短路不只是考试题,它在每一次循环里都在悄悄省事。

环境图:调用 add_all_even_div_three(10)
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 的经典对照:

对比项returnprint
做什么把一个值交回给调用者把值的文本形式送到屏幕
对控制流的影响函数立即结束(外层循环一并结束)没有影响,函数继续往下跑
一次调用能做几次至多一次任意多次
能被赋值捕获吗能:a = f(x) 拿到返回值不能:a = print(1) 让 a 变成 None
不写会怎样函数返回 None屏幕上什么都没有
在交互式解释器里返回 None 时不显示任何东西立刻显示
在别的地方调用时(如脚本里)值可以继续参与计算只是副作用,值没了

怎么想到的

1 「1 到 n 逐个处理」→ 又是 while,三要素:i = 1 / i <= n / i += 1。
2 每个数四选一输出 → 一条 if / elif / elif / else,四个 clause。
3 三个判定分别是 i % 3 == 0、i % 5 == 0、以及两者同时成立即 i % 3 == 0 and i % 5 == 0。
4 然后是这道题真正的考点:这四个 clause 该按什么顺序排?

回到第 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 = 15(走第一个 clause)
1 求值条件 i % 3 == 0 and i % 5 == 0。按优先级,先算两边的比较:左边 (15 % 3) == 0 → 0 == 0 → True。
2 左操作数 truthy,and 不短路,继续求值右操作数 (15 % 5) == 0 → 0 == 0 → True。
3 整个 and 表达式的值 = 右操作数的值 = True,truthy。
4 执行这个 clause 的 suite:print("fizzbuzz"),屏幕上出现 fizzbuzz。
5 跳过后面两个 elif 和 else,控制流走到条件语句之后的 i += 1。
i = 3(第一个条件因短路而失败)
1 求值 3 % 3 == 0 → True,and 继续看右边。
2 求值 3 % 5 == 0:3 % 5 是 3(3 除以 5 商 0 余 3),3 == 0 → False。
3 整个 and 的值 = False,falsy → 跳过第一个 suite,考察第二个 clause。
4 求值 3 % 3 == 0 → True → 执行 print("fizz")。
5 跳过剩下的 elif 和 else。
i = 4(三个条件全假,落到 else)
1 4 % 3 是 1,1 == 0 → False。and 短路,右边 4 % 5 == 0 不求值。整个条件为 False。
2 第二个 clause:4 % 3 == 0 → False。
3 第三个 clause:4 % 5 是 4,4 == 0 → False。
4 全部为假 → 执行 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
逐步推演:i = 15 在错误版本里发生了什么
1 考察第一个 clause,求值 15 % 3 == 0:15 % 3 是 0,0 == 0 → True。
2 truthy → 执行它的 suite:print("fizz")。
3 按执行规则,整条条件语句到此结束,后面两个 elif 和 else 全部跳过——包括那个专门为 15 写的 clause。
4 屏幕上是 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 的标准手法,以后你会反复见到。

怎么想到的:手上只有一个整数

这是本讲最值钱的一次思维跨度,所以把弯路也写出来。

1 第一反应:「把数字转成字符串,一个字符一个字符地加」。问题是:字符串、索引、for 循环现在都还没学。而且就算学了,61A 出这道题的目的恰恰是逼你不用它们。
2 退回来问:只用算术,能不能从 4224 里把某一位数字单独取出来?
3 找到工具一:y % 10 给出个位。4224 % 10 是 4。因为十进制下除以 10 的余数就是最低位。
4 找到工具二:y // 10 把个位砍掉。// 是整除(floor division),只保留商的整数部分:4224 // 10 是 422。
5 组合:拿个位、加进累加器、砍掉个位——重复这三步,数字每轮短一位,早晚变成 0。「重复到某条件不成立」正是 while。
6 三要素:初始化 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 > 0digit = y % 10total += digit 后y //= 10 后
14224True40 + 4 = 4422
2422True24 + 2 = 642
342True26 + 2 = 84
44True48 + 4 = 120
(第 5 次检查)0False循环结束,执行 return total,交出 12

和 doctest 的 12 一致。注意第 4 轮:4 // 10 是 0(商 0 余 4),正是这一步把 y 送到了 0,循环才停下来。

环境图:调用 sum_digits(4224)
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)
1 先把右边所有表达式全部求值完(从左到右)。y // 10 → 422;y % 10 → 4。此时 y 仍然是 4224。
2 求值全部完成后,再一次性把左边的名字绑定过去:y 绑定到 422,last 绑定到 4。
3 关键结论:右边的 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 总要给好几个例子。

其余三个真实会犯的错

错误一:return 写在 while 里面
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 printreturn 交出值并立即结束函数(连同外层循环);print 只打字return 缩进进 while → 只跑一轮;用 print 冒充 return → 返回 None
不新建帧环境图里只有调用用户定义函数才新建帧;if、while 都不建给每轮循环画一个帧
% 与 //n % 10 取最低位,n // 10 去掉最低位用 / 代替 // → 变浮点数,结果错且循环拖很久;负数上 // 向下取整会卡在 -1
多重赋值右边全部求值完,再一起绑定左边拆成两行写,第二行用到的是已经被改过的值
循环不变式「每轮开始时都成立的一句话」,用来证明循环对不对、算出退出时的结果只靠脑内模拟前两轮就下结论
如果只记三句话
  1. 条件语句最多执行一个 suite——所以更特殊的条件必须写在前面。
  2. while 循环三要素缺一不可,更新语句的缩进层级决定它是不是每轮都跑。
  3. 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 22and 比 or 紧,等价于 (1 and 0) or 2。1 truthy → and 返回右操作数 0。再算 0 or 2:0 falsy → 返回右操作数 2
not 0 and 33not 比 and 紧,等价于 (not 0) and 3。not 0 是 True,truthy → and 返回右操作数 3。不是 True
'' or 00空串 falsy → 返回右操作数 0。注意结果依然是 falsy 的,但值是 0 不是 False
0 or '' or NoneNone从左往右:0 falsy → 看 '',还是 falsy → 看 None。没得看了,返回最后一个操作数 None。解释器什么都不显示
2 or 1 / 022 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):

轮次进入时 ncount += 1 后n //= 10 后
142241422
2422242
34234
4440
(第 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 节讲的循环不变式就是用来严格回答这个问题的。