跳转到内容

序列模式匹配

match/case(Python 3.10 新增)表面上像 C 的 switch,但那只是一半。 真正的区别是解构(destructuring):它把数据结构拆开,按形状匹配, 顺手把值绑到变量上,还能做类型检查。

一句话概括:switch 只比相等,match 比的是形状。

假设设备收到的命令是「词和数字组成的序列」,比如 ['BEEPER', 440, 3]:

match message:
case ['BEEPER', frequency, times]:
self.beep(times, frequency)
case ['NECK', angle]:
self.rotate_neck(angle)
case ['LED', ident, intensity]:
self.leds[ident].set_brightness(ident, intensity)
case ['LED', ident, red, green, blue]:
self.leds[ident].set_color(ident, red, green, blue)
case _:
raise InvalidCommand(message)

逐条看:

  • 第 1 条匹配「3 项的序列,第一项必须是字符串 'BEEPER'」。后两项随便是什么, 分别绑到 frequency 和 times。
  • 第 3 和第 4 条都以 'LED' 开头,但项数不同(3 项 vs 5 项),所以不会互相吃掉。
  • 最后一条是兜底。_ 匹配任何东西,但不绑定。

一个序列模式匹配成功,需要同时满足三条:

  1. 被匹配的对象是序列
  2. 项数相同
  3. 每一项都能匹配,包括嵌套的项

[name, _, _, (lat, lon)] 匹配「4 项,最后一项是 2 项序列」。

>>> match ['Shanghai', 'CN', 24.9, (31.1, 121.3)]:
... case [name, _, _, (lat, lon)]:
... ...
变量 值
name 'Shanghai'
lat 31.1
lon 121.3

写 [a, b] 还是 (a, b) 完全一样。混着嵌套也行。 上面我写成「外层方括号、内层圆括号」,纯粹是为了少打几层括号。

标准库里能直接做序列模式匹配的类型:

list memoryview array.array
tuple range collections.deque

另一个限制:模式不解构非序列的可迭代对象。迭代器不行,range 和 deque 可以, 因为它们是序列。

这是我觉得最有意思的一点。看这个模式:

case [str(name), _, _, (float(lat), float(lon))]:

str(name) 和 float(lat) 长得像构造器调用,但在模式里语义完全不同 —— 它们是运行时类型检查。这个模式要求:

  • 第 0 项必须是 str,并绑到 name
  • 第 3 项必须是一对 float,分别绑到 lat 和 lon
# _ 匹配任意一项,但不绑定
case [name, _, _, (lat, lon)]:
# as 把匹配到的那一段绑个名字
case [name, _, _, (lat, lon) as coord]: # coord 是整对坐标
# *_ 匹配任意多项,不绑定
case [str(name), *_, (float(lat), float(lon))]:
# *extra 匹配任意多项,绑成列表
case [str(name), *extra, (float(lat), float(lon))]:

_ 是唯一能在同一个模式里出现多次的变量。

if 开头的守卫只在模式匹配成功后才会求值,而且能引用模式里绑定的变量:

case [name, _, _, (lat, lon)] if lon <= 0:
print(...)

这一条把「结构对」和「值合法」分成两步写,比在一个长表达式里混着写清楚得多。

书里用 Peter Norvig 的 lis.py(132 行的 Scheme 解释器)演示重构。我写了自己版本 —— 一个逆波兰计算器:

match token:
case float() | int() as number:
stack.append(float(number))
case [op, *operands] if op in {"+", "-", "*", "/"}:
...
case "+":
b, a = stack.pop(), stack.pop()
stack.append(a + b)
...

[3, 4, '+'] 算出 7。嵌套也行:[["+", 1, 2, 3, 4], 2, "*"] 算出 20。

书里对这里有个说法我很认同:模式匹配是声明式编程。代码描述的是「要匹配什么」, 不是「怎么匹配」。代码的形状跟着数据的形状走。

Scheme 语法 序列模式
(quote exp) ['quote', exp]
(if test conseq alt) ['if', test, conseq, alt]
(lambda (parms…) body…) ['lambda', [*parms], *body] if body
(define name exp) ['define', Symbol() as name, exp]
case _:
raise SyntaxError(...)

没有兜底的话,被匹配对象不匹配任何分支时,整个 match 语句什么都不做 —— 静默失败。这比抛异常难查得多。

  • test_match_is_not_switch_it_destructures —— 四条命令都命中正确的分支
  • test_pattern_type_check_rejects_bad_data —— 坐标是 int 而不是 float 时不匹配
  • test_star_wildcard_in_pattern —— 第一项类型不对时不匹配
  • test_unmatched_command_raises —— 兜底分支抛异常