条件、循环与函数
Condition, Loop and Function
前三讲的程序都是从第一行顺序执行到最后一行。这一讲加上分支和重复:程序可以根据情况走不同的路,也可以把一段代码执行很多遍。有了这两样,Python 就能表达任何可计算的过程。
本讲结束后你应当能
- 用 if / elif / else 表达多路分支,并按正确的顺序排列条件
- 判断该用 for 还是 while,并正确使用 break 与 continue
- 说清楚 for-else 里的 else 什么时候执行
- 用默认参数、关键字参数和 *args / **kwargs 写出灵活的函数
- 给函数加类型提示,并说出为什么要写
本页目录
从顺序到分支和重复
前三讲写的程序都是一条直线:第一行、第二行、第三行,执行完就结束。这样的程序对输入没有任何反应,每次运行做的都是同样的事。
这一讲加两样东西:
- 分支(条件):根据情况,执行不同的代码
- 重复(循环):把同一段代码执行很多遍
顺序、分支、重复这三种结构,足以表达任何可计算的过程。这是 1966 年由 Böhm 和 Jacopini 证明的结构化程序定理。后面要学的递归、类、模块,都没有让 Python 能算的东西变多,它们解决的是另一个问题:程序越来越长时,怎么还能写清楚、读得懂。
条件
布尔表达式
L2 讲过,比较的结果是 True 或 False。if 语句要的就是这样一个值:
print(3 > 2)
print(3 == 2)
print("abc" != "abd")
print(3 >= 3)
几个条件可以用 and、or、not 组合起来。注意运算顺序:not 最先,其次 and,最后 or。
print(True or False and False) # and 先算 → True or False → True
print((True or False) and False) # 加括号改变顺序 → False
链式比较
Python 允许把比较连起来写,写法和数学一样:
x = 5
print(1 < x < 10) # 等价于 1 < x and x < 10
print(0 <= x <= 100)
这是 Python 比 C、Java 方便的地方。那些语言里 1 < x < 10 也合法,但它先算 1 < x 得到一个布尔值,再拿这个布尔值和 10 比较,结果和数学上的意思对不上,而且不报错。
if / elif / else
elif 是 else if 的缩写。
score = 85
if score >= 90:
print("优秀")
elif score >= 80:
print("良好")
elif score >= 60:
print("及格")
else:
print("不及格")
三点要注意:
- 条件后面的冒号不能少
- 分支里的语句要缩进(4 个空格)
- 从上往下,第一个为真的分支执行,其余全部跳过
最后一点最重要。上面 score = 85 时,程序先检查 score >= 90,为假;再检查 score >= 80,为真,打印「良好」。score >= 60 这个条件根本没有被检查,else 也不会执行。一个 if-elif-else 结构里,最多只有一个分支会执行。
所以 elif score >= 80 不需要写成 elif 80 <= score < 90:能走到这一行,说明 score >= 90 已经是假的了。同一个边界写两遍,两处就可能写得不一致。
闰年:早返回
规则是:能被 4 整除的是闰年,但能被 100 整除的不是,除非还能被 400 整除。
def is_leap(year):
if year % 400 == 0:
return True
if year % 100 == 0:
return False
return year % 4 == 0
for y in [1900, 2000, 2024, 2025, 2026]:
print(y, is_leap(y))
这里的写法是从最特殊的条件开始判断,能下结论就立即 return。这叫早返回。每判断完一层,读代码的人就不需要再记着它,比嵌套的 if-else 清楚得多:
# 同样的逻辑,嵌套写法:能用,但读起来费力
def is_leap_nested(year):
if year % 4 == 0:
if year % 100 == 0:
if year % 400 == 0:
return True
else:
return False
else:
return True
else:
return False
print(is_leap_nested(1900), is_leap_nested(2000), is_leap_nested(2024))
也可以写成一个表达式,但要把三个条件的关系同时在脑子里拼出来:
def is_leap_oneline(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
print(is_leap_oneline(1900), is_leap_oneline(2000), is_leap_oneline(2024))
三种写法结果完全相同,选早返回只因为它最好读。
条件表达式
只有两种情况、而且两个值都很短的时候,可以写成一行:
score = 75
result = "及格" if score >= 60 else "不及格"
print(result)
# 等价于
if score >= 60:
result = "及格"
else:
result = "不及格"
print(result)
格式是 值1 if 条件 else 值2。它是一个表达式,会算出一个值,所以可以直接赋给变量、写进 f-string、作为函数的参数。
不要嵌套它。写成 a if p else (b if q else c) 时,就该换回 if-elif-else 了。
match-case:模式匹配
Python 3.10 引入的语法,可以代替一长串 elif。要比较的值只写一次,各个分支平铺在下面:
def http_message(code):
match code:
case 200:
return "OK"
case 301 | 302: # | 表示"或"
return "重定向"
case 404:
return "未找到"
case 500:
return "服务器错误"
case _: # _ 是通配符,相当于 else
return "未知状态"
for c in [200, 302, 404, 500, 418]:
print(c, http_message(c))
它比 if-elif 强的地方在于能匹配结构,而不只是比较值:
def describe(point):
match point:
case (0, 0):
return "原点"
case (0, y):
return f"在 y 轴上,y={y}"
case (x, 0):
return f"在 x 轴上,x={x}"
case (x, y):
return f"普通点 ({x}, {y})"
case _:
return "不是一个二维点"
for p in [(0, 0), (0, 5), (3, 0), (2, 7), "hello"]:
print(p, "→", describe(p))
case (0, y) 做了两件事:检查这个值是不是由两个值组成、第一个是不是 0;如果是,就把第二个值绑定到名字 y 上。用 if 写同样的逻辑,要先判断类型,再判断长度,再取出第二个值赋给变量。
圆括号的写法叫元组,L6 讲,这里只需要知道它把两个值放在一起。
循环
开篇那个问题:怎么让同一段代码执行很多遍?Python 有两种循环。
while:条件为真就继续
n = 1
while n <= 5:
print(n, end=" ")
n += 1
print()
while 的执行过程是:判断条件,为真就执行一遍循环体,然后回到条件重新判断;直到条件为假,才往下执行。
上面这段代码里,条件一共判断了 6 次(n 分别是 1、2、3、4、5、6),循环体只执行了 5 次。n 变成 6 时条件第一次为假,那一次循环体不执行。
while 有三个要素,缺一个就会出错:
- 初始化:
n = 1 - 条件:
n <= 5 - 更新:
n += 1
for:逐个取出元素
for ch in "Python":
print(ch, end=" ")
print()
for x in [10, 20, 30]:
print(x, end=" ")
print()
for 把 in 后面那个东西里的元素逐个取出来,每取一个执行一遍循环体。in 后面可以是任何能逐个取出元素的东西:字符串、列表、元组、字典、集合。方括号括起来的叫列表,L5 讲;这背后的共同机制叫可迭代对象,L13 讲。
for 不需要写初始化和更新,元素取完循环就自动结束,所以比 while 更不容易写错。
range()
要把一段代码重复 n 次,for 后面需要一个有 n 个元素的东西。range() 用来生成这样一串整数:
print(list(range(5))) # 0 到 4
print(list(range(2, 8))) # 2 到 7
print(list(range(0, 10, 3))) # 步长 3
print(list(range(10, 0, -2))) # 倒着数
range(a, b) 含头不含尾,和切片是同一个约定,所以 range(a, b) 正好有 b - a 个数。
total = 0
for i in range(1, 101):
total += i
print("1 加到 100 =", total)
for 还是 while
判断标准只有一条:进入循环之前,知不知道要循环多少次。
用 for | 用 while |
|---|---|
| 知道要循环多少次 | 不知道要循环多少次 |
| 遍历一组已有的数据 | 循环到某个条件满足为止 |
| 大多数情况 | 反复读取输入直到合法、反复迭代直到达到精度 |
能用 for 就用 for:它把初始化和更新交给了语言,少了两处可能出错的地方。
需要 while 的典型例子是 3n+1 问题:偶数除以 2,奇数乘 3 加 1,一直做到得到 1 为止。要走多少步,事先算不出来:
def collatz_steps(n):
steps = 0
while n != 1:
if n % 2 == 0:
n = n // 2
else:
n = 3 * n + 1
steps += 1
return steps
for start in [6, 7, 27]:
print(f"从 {start} 出发,{collatz_steps(start)} 步到 1")
从 27 出发要走 111 步,中间会升到 9232 再降下来。这个问题叫考拉兹猜想,1937 年提出。计算机已经验证了非常大范围内的整数,但至今没有人能证明,从任何正整数出发最后都会回到 1。
break 和 continue
有时循环不需要按部就班地走完:
break:立即结束整个循环continue:跳过这一轮剩下的部分,进入下一轮
# break:找到第一个就停
for i in range(2, 50):
if 100 % i == 0:
print("100 的第一个大于 1 的因数是", i)
break
# continue:跳过不想要的
for i in range(1, 11):
if i % 3 == 0:
continue
print(i, end=" ")
print()
循环的 else
循环也可以带 else,这是 Python 特有的写法。
循环正常结束,执行 else;被 break 打断,不执行 else。
先看一个判断质数的函数,它用的是早返回:
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
for n in range(2, 30):
if is_prime(n):
print(n, end=" ")
print()
用循环的 else 可以这样写:
def find_divisor(n):
for i in range(2, n):
if n % i == 0:
print(f"{n} 能被 {i} 整除")
break
else:
print(f"{n} 是质数")
find_divisor(15)
find_divisor(17)
15 在 i 等于 3 时找到因数,break 跳出,else 不执行;17 一直找到最后也没找到因数,循环正常结束,执行 else。
位运算
L2 讲过,整数在计算机里是一串二进制位。前面的运算符都把整数当作一个整体来算,位运算符直接对每一位进行操作:
| 运算符 | 名称 | 作用 |
|---|---|---|
& | 按位与 | 两位都是 1 才得 1 |
| | 按位或 | 有一位是 1 就得 1 |
^ | 按位异或 | 两位不同才得 1 |
~ | 按位取反 | 0 变 1,1 变 0 |
<< | 左移 | 整体左移,右边补 0 |
>> | 右移 | 整体右移 |
0b 开头的数是二进制写法,0b1100 就是 12。把两个数按二进制上下对齐,每一列单独算:
a, b = 0b1100, 0b1010 # 12 和 10
print(f"a = {a:04b}")
print(f"b = {b:04b}")
print(f"a & b = {a & b:04b} = {a & b}")
print(f"a | b = {a | b:04b} = {a | b}")
print(f"a ^ b = {a ^ b:04b} = {a ^ b}")
格式说明 04b 表示按二进制输出、宽度 4、不足补 0。不补零,位数不同的两个数就对不齐。
左移一位等于乘 2,右移一位等于整除 2:
n = 5
print(n, "<< 1 =", n << 1) # 10
print(n, "<< 3 =", n << 3) # 40
print(n, ">> 1 =", n >> 1) # 2
n & 1 只留下最低位。二进制最低位是 1 的数是奇数:
for n in [7, 8, 9, 10]:
print(n, "奇" if n & 1 else "偶")
它和 n % 2 == 1 完全等价,在 Python 里两者速度几乎没有差别。平时写 % 就好,可读性更重要;别人的代码里会经常见到 n & 1,要能读懂。
一个实际用途:权限位
位运算常用来在一个整数里存好几个开关。Lab 2 里用 ls -l 看文件时,最左边有一串 rwxr-xr-x。r、w、x 分别表示可读、可写、可执行,每个字母就是一个二进制位:有是 1,没有是 0。
READ, WRITE, EXEC = 0b100, 0b010, 0b001
perm = READ | EXEC # r-x
print(f"{perm:03b}")
print((perm & WRITE) != 0) # 可以写吗?
print((perm & READ) != 0) # 可以读吗?
用 | 打开几个开关,用 & 查看某个开关:与运算的结果不是 0,说明这一位是开的。
函数进阶
L3 讲了函数的三要素:名字、参数、返回值。参数还有几种更灵活的写法,print 其实已经在用了:它能接受任意多个值,还有 sep 和 end 两个可以不写的参数。
默认参数
给参数一个默认值,调用时可以不传:
def greet(name, greeting="你好"):
return f"{greeting},{name}"
print(greet("张三"))
print(greet("张三", "早上好"))
这样一个函数既能照顾最常见的情况,又能在需要时定制。
有默认值的参数必须放在没有默认值的参数后面,否则函数定义本身就通不过:
def bad(greeting="你好", name):
return f"{greeting},{name}"
如果允许这样写,调用 bad("张三") 时,"张三" 该给谁?按位置应该给 greeting,可 name 没有默认值,又不能空着。规则自相矛盾,所以语言直接禁止。
默认值还有一个要注意的地方,要等 L5 讲完列表才讲得清楚,到时候再说。
按位置传参与按名字传参
def describe(name, age, city):
return f"{name},{age} 岁,来自{city}"
print(describe("张三", 18, "上海")) # 按位置
print(describe(name="张三", age=18, city="上海")) # 按名字
print(describe("张三", city="上海", age=18)) # 混用
混用时,按位置传的参数必须写在前面。Python 先按位置把前几个参数对应好,剩下的再按名字分配。
一条经验:参数只有一两个、意思很明显时,按位置传;参数多,或者有几个类型相同、容易弄混时,按名字传。
*args 和 **kwargs
参数个数事先不确定时用它们:
def total(*numbers): # * 把所有位置参数收集成一个元组
print("收到:", numbers, type(numbers))
return sum(numbers)
print(total(1, 2, 3))
print(total(1, 2, 3, 4, 5))
print(total())
打印出来是用圆括号括起的一组值,这叫元组,L6 讲。现在把它理解成一组不能修改的值就够了。
def config(**options): # ** 把所有关键字参数收集成一个字典
print("收到:", options)
config(debug=True, level=3, name="test")
** 收到的是一个字典,里面记着每个参数的名字和值。字典也是 L6 的内容。
两种可以一起用,顺序固定是 def f(普通参数, *args, **kwargs):
def show(first, *rest, **opts):
print("first =", first)
print("rest =", rest)
print("opts =", opts)
show(1, 2, 3, mode="fast", retry=2)
分配规则是:先按位置把普通参数填满,剩下的位置参数进 *args,关键字参数进 **kwargs。
args 和 kwargs 只是习惯用的名字,语法本身是 * 和 **。但请沿用这两个名字:大家都这样写,换了名字,读代码的人要多花时间确认有没有别的用意。
同名函数会覆盖
Python 没有函数重载。后定义的同名函数会覆盖前面的:
def f(x):
return x * 2
def f(x, y):
return x + y
print(f(3, 4))
def f(x):
return x * 2
def f(x, y):
return x + y
print(f(3)) # 原来那个单参数的 f 已经没有了
L3 讲过,def 也是一次赋值:把函数对象绑到那个名字上。第二个 def f 把 f 改指到新的函数,和先写 x = 1 再写 x = 2 是同一回事。
在 C++、Java 里,名字相同、参数不同的几个函数可以同时存在。Python 里想根据参数个数做不同的事,用默认参数或 *args。
类型提示
写法
一个函数叫什么,大致说明了它做什么;但它接受什么类型的参数、返回什么类型的值,只看名字不知道。从 Python 3.5 起,可以把这两件事写在函数定义上:
def c_to_f(c: float) -> float:
return c * 9 / 5 + 32
print(c_to_f(37))
c: float 说明参数应该是浮点数,-> float 说明返回浮点数。
Python 运行时不检查类型提示。 传一个字符串进去,照样开始执行,直到用到它的那一行才报错:
def c_to_f(c: float) -> float:
return c * 9 / 5 + 32
print(c_to_f("37"))
报错信息说的是字符串不能乘以浮点数,完全没有提到参数应该是 float。
为什么要写
既然不检查,为什么还要写?三个理由,一个比一个重要:
一、它是不会过期的文档。 写在注释里的「这个参数是整数」,改代码时很容易忘了同步。类型提示就写在参数旁边,改参数时一定看得见。
二、编辑器能用上它。 VS Code 装了 Python 扩展后,带类型提示的函数补全更准确,传错类型会当场标出来,不用等到运行。
三、它是交给 AI 的规格说明。 这一条 L15 会展开。先说结论:让 AI 写一个函数时,与其用一段话描述,不如先写好函数签名和类型,让它补全函数体。
def merge_sorted(a: list[int], b: list[int]) -> list[int]:
"""把两个已排序的整数列表合并成一个有序列表。"""
...
这三行说清了输入是什么、输出是什么、函数做什么。如果只说一句「写一个函数,把两个排好序的列表合起来」,AI 就要猜:元素是什么类型?返回新列表还是修改原来的?
... 是 Python 的一个合法语句,表示「这里暂时空着」,常用在写好了签名、还没写实现的时候。
常用写法
def f1(x: int) -> str:
return str(x)
def f2(items: list[int]) -> int:
return sum(items)
def f3(name: str, age: int = 18) -> str: # 默认值和类型可以一起写
return f"{name} is {age}"
def f4(x: float) -> None: # 没有返回值就写 None
print(x)
print(f1(42), f2([1, 2, 3]), f3("Ada"))
f4(3.14)
list[int] 表示元素是整数的列表。f4 写了 -> None:L3 讲过,没有 return 的函数返回 None。不要因为没有返回值就不写,写出来,读的人才知道这是有意为之。
缩进
Python 用缩进划分代码块。这让代码很整齐,代价是:缩进错了,程序的含义就变了,而且往往不报错。
def check(scores: list[int]) -> None:
for s in scores:
if s >= 60:
print(s, "及格")
print("检查完毕") # 在 for 外面,只打印一次
check([50, 70, 90])
def check(scores: list[int]) -> None:
for s in scores:
if s >= 60:
print(s, "及格")
print("检查完毕") # 多缩进了一层,每轮都打印
check([50, 70, 90])
两段代码只差四个空格,输出完全不同,都不报错。注意第二段输出的第一行就是「检查完毕」:50 那一轮不及格,不打印「及格」,但循环体里那句 print 照样执行。
写完循环,要回头检查每一行的缩进是不是在想要的那一层,特别是循环体的最后几行。
小结
- 顺序、分支、重复三种结构,足以表达任何可计算的过程
if-elif-else从上往下执行第一个为真的分支;多路分支要从最严格的条件写起- 链式比较
1 < x < 10和数学写法一致,中间那一项只求值一次 match-case能匹配结构并绑定名字,但不要滥用- 能用
for就用for;while留给「不知道要循环几次」的情况 break结束整个循环,continue跳过这一轮;嵌套循环里break只跳出一层- 循环的
else意思是「没有被 break 打断」,能读懂就行,自己少写 - 位运算和别的运算符写在一起时一律加括号
- 有默认值的参数放在后面;
*args收到元组,**kwargs收到字典 - Python 没有函数重载,同名函数后定义的覆盖前面的
- 类型提示不做检查,但要写:它是文档,编辑器能用,也是交给 AI 的规格说明
- 统一用 4 个空格缩进,不要混用 Tab
练习
- 写
bmi_category(bmi: float) -> str,按<18.5偏瘦、<24正常、<28偏胖、其余肥胖分类。注意分支顺序。 - 不用
sum(),用循环求 1 到 100 中所有能被 3 或 5 整除的数之和。 - 写
count_vowels(s: str) -> int,统计字符串里元音字母(aeiou,大小写都算)的个数。 - 用嵌套循环打印九九乘法表的下三角,格式对齐。
- 下面两段代码输出不同,解释为什么:
for i in range(5): if i == 3: break else: print("完整跑完") for i in range(5): if i == 3: continue else: print("完整跑完") - 写
apply_all(*funcs, value),把value依次传给每个函数处理,返回最终结果。 例如apply_all(abs, int, value=-3.7)应该返回3。想一想为什么value必须按名字传。 - 给 Lab 3 里写的所有函数补上类型提示。
本讲的配套上机题在 Lab 4。