CS1602计算导论
第 4 讲Part 1 计算基础与 Python 入门AI Level 0

条件、循环与函数

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 有三个要素,缺一个就会出错:

  1. 初始化:n = 1
  2. 条件:n <= 5
  3. 更新: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

练习

  1. 写 bmi_category(bmi: float) -> str,按 <18.5 偏瘦、<24 正常、<28 偏胖、其余肥胖分类。注意分支顺序。
  2. 不用 sum(),用循环求 1 到 100 中所有能被 3 或 5 整除的数之和。
  3. 写 count_vowels(s: str) -> int,统计字符串里元音字母(aeiou,大小写都算)的个数。
  4. 用嵌套循环打印九九乘法表的下三角,格式对齐。
  5. 下面两段代码输出不同,解释为什么:
    for i in range(5):
        if i == 3:
            break
    else:
        print("完整跑完")
    
    for i in range(5):
        if i == 3:
            continue
    else:
        print("完整跑完")
  6. 写 apply_all(*funcs, value),把 value 依次传给每个函数处理,返回最终结果。 例如 apply_all(abs, int, value=-3.7) 应该返回 3。想一想为什么 value 必须按名字传。
  7. 给 Lab 3 里写的所有函数补上类型提示。

本讲的配套上机题在 Lab 4。