去年我在处理一个数据清洗管道时,需要根据API返回的不同数据结构做分支处理。那个接口有时候返回一个对象,有时候返回一个列表,有时候在错误时又返回一个包含error键的字典,甚至还有嵌套了好几种格式的旧版数据。初始版本用了一大串if-elif-else,外加一堆isinstance和len检查,代码看了眼睛疼。后来全换成match-case,不仅行数减少了将近三分之一,而且每个分支的处理逻辑一眼就能看清。
Python在3.10版本里引入的结构化模式匹配,很多开发者第一反应是“这不就是加强版switch吗”。这个说法对,但远不够全面。match的能力可以解构序列、映射、自定义类实例,甚至结合条件守卫做细致的筛选。目前Python 3.12已经对模式匹配做了进一步优化,包括更快的匹配速度和更友好的错误提示。这篇文章就从真实场景切入,把match的常用模式串一遍,最后给出一个能直接拿来用的命令行JSON解析器例子。
从一段烂代码说起
假设我们有一个函数需要解析线上传来的数据包,数据可能长这样:
# 合法的几种格式
data1 = {"type": "point", "x": 10, "y": 20}
data2 = {"type": "line", "points": [{"x":0,"y":0}, {"x":5,"y":5}]}
data3 = {"type": "circle", "center": {"x":3,"y":4}, "radius": 5}
data4 = {"error": "invalid token", "code": 401}
按照传统的写法,我们可能会这样处理:
def handle_packet(packet):
if "error" in packet:
return f"错误: {packet['error']} (code {packet.get('code', 0)})"
packet_type = packet.get("type")
if packet_type == "point":
if "x" in packet and "y" in packet:
return f"绘制点 ({packet['x']}, {packet['y']})"
elif packet_type == "line":
points = packet.get("points", [])
if len(points) >= 2:
return f"绘制线,共{len(points)}个点"
elif packet_type == "circle":
center = packet.get("center", {})
if "x" in center and "y" in center and "radius" in packet:
return f"绘制圆,圆心({center['x']},{center['y']}), 半径{packet['radius']}"
return "未知数据格式"
这段代码的问题不只是啰嗦。它把“判断形状”和“提取字段”混在一起,每层嵌套都在做同一件事:试探某个键是否在字典里,然后取出来用。随着格式变多,if-elif会越拉越长,改一处可能牵动好几个地方。
用match-case重写
match可以直接在匹配头部完成解构和提取,不需要先判断再取值。下面是用模式匹配改造后的版本:
def handle_packet(packet):
match packet:
case {"error": msg, "code": code}:
return f"错误: {msg} (code {code})"
case {"error": msg}:
return f"错误: {msg}"
case {"type": "point", "x": x, "y": y}:
return f"绘制点 ({x}, {y})"
case {"type": "line", "points": [p1, p2, *_]}:
return f"绘制线,至少两个点"
case {"type": "circle", "center": {"x": x, "y": y}, "radius": r}:
return f"绘制圆,圆心({x},{y}), 半径{r}"
case _:
return "未知数据格式"
每一条case描述了一种“数据长这个样子,同时把我要的字段绑定到变量上”。匹配失败自动跳到下一条,且字典里没有指定的键时会直接短路,不需要get或in检查。
这里面有几个关键点值得展开说。
映射模式:用字典的形状做匹配
上面用到的{"key": pattern, ...}叫映射模式。它会检查待匹配对象是不是一个映射(字典),然后逐个比对指定的键。键对应的值再递归应用模式。比如{"type": "point", "x": x, "y": y}要求待匹配字典必须同时包含type、x、y这三个键,并且type的值精确等于字符串"point",x和y的值则绑定到变量x和y上。
未在模式中列出的键会被忽略,所以即使原始字典比模式多出了其他字段也不影响匹配。但反过来,如果待匹配字典缺少模式中要求的某个键,这一条case直接失败。
还可以用**rest捕获剩余的全部键值对。比如:
match packet:
case {"type": "point", **others}:
print(f"点数据,额外字段: {others}")
这在需要把不关心的部分暂存起来时非常顺手。注意**rest只能放在模式的最后,而且不能和已指定的键重名。
序列模式与通配符
["line", *_, "end"]这种写法叫序列模式,它可以匹配列表或元组。上面的{"type": "line", "points": [p1, p2, *_]}用到了序列模式,要求points对应的值是一个至少包含两个元素的序列,前两个分别绑定到p1和p2,后面的元素用*_吞掉。
这里的*_是星号通配符的变体——星号表示匹配剩余任意数量的元素,下划线是Python传统的“我不关心这个值”的惯例。如果写成*tail,那么剩余元素会被绑定到tail变量。
序列模式配合精确长度匹配也很有用。例如验证一个坐标点是不是二维的:
match coord:
case [x, y]:
print(f"二维坐标 ({x}, {y})")
case [x, y, z]:
print(f"三维坐标 ({x}, {y}, {z})")
只有列表恰好长度为2或3时才命中,多一个少一个都会跳过。
守卫:用if给模式加附加条件
有些条件光靠形状判断不出来,比如“圆的半径必须大于0”。这时候可以在case后追加if守卫:
match packet:
case {"type": "circle", "radius": r} if r > 0:
return f"有效圆,半径{r}"
case {"type": "circle", "radius": r}:
return f"无效圆,半径{r}"
守卫可以访问模式中绑定的所有变量,表达式为True时才认为匹配成功。注意守卫是附加在整条case上的,不是对单个字段的限制——如果需要同时对多个字段加约束,把它们都写在同一个if里就行。
或模式:同一分支处理多种类似结构
如果两种数据结构可以用类似的方式处理,可以用|将它们合并到一条case里。比如一个命令解析器,同时支持quit和exit:
match command.split():
case ["quit" | "exit"]:
print("退出程序")
case ["move", ("up" | "down" | "left" | "right") as direction]:
print(f"向{direction}移动")
第一个模式匹配单个词quit或exit。第二个模式匹配move后跟一个方向词,方向词被绑定到direction变量。为了在子模式中还能取名,用了as绑定。这个as可以出现在模式的任意层级,临时给子模式命名。
类模式:解构自定义对象
模式匹配不局限于内置类型,自定义类的实例也可以解构。只要类定义了__match_args__类属性,指明匹配时按照什么顺序解析属性。
class Point:
__match_args__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
def locate(p):
match p:
case Point(x=0, y=0):
return "原点"
case Point(x=0, y=y):
return f"Y轴, y={y}"
case Point(x=x, y=0):
return f"X轴, x={x}"
case Point(x=x, y=y):
return f"点({x},{y})"
如果不提供__match_args__,就只能用关键字形式Point(x=..., y=...)匹配;有了__match_args__后也可以写成位置形式Point(_, 0),不过通常关键字形式更可读。数据类(dataclass)会自动生成__match_args__,用起来非常方便。
一个落地的例子:命令行JSON处理工具
把上面这些点串起来,做一个简单的命令行程式。它从标准输入读取JSON文本,用户通过不同子命令来操作数据结构。我们实现三个命令:get(按路径取值)、set(设置键值)、validate(检查结构)。用match来解析命令行参数和JSON结构本身。
入口部分接收用户输入,按空格拆分成列表,交给match做分发:
import sys
import json
def main():
if len(sys.argv) < 2:
print("用法: tool [参数...]")
return
raw = sys.stdin.read()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
print(f"JSON解析失败: {e}")
return
command = sys.argv[1]
args = sys.argv[2:]
match [command, *args]:
case ["get", path]:
result = navigate(data, path)
print(json.dumps(result, ensure_ascii=False, indent=2))
case ["set", path, value]:
updated = assign(data, path, json.loads(value))
print(json.dumps(updated, ensure_ascii=False, indent=2))
case ["validate", schema_path]:
schema = load_schema(schema_path)
valid = check_schema(data, schema)
print("结构匹配" if valid else "结构不匹配")
case _:
print(f"未知命令: {command}")
重点在navigate函数,它需要根据path字符串(比如"users.0.name")逐级往下取值。路径解析可以用split(".")然后循环,但既然要用match,就让navigate根据数据当前形态和路径下一级来做模式匹配:
def navigate(obj, path):
"""通过点分隔路径访问嵌套结构"""
parts = path.split(".")
current = obj
for part in parts:
match current:
case dict() if part in current:
current = current[part]
case list() if part.isdigit():
idx = int(part)
if 0 <= idx < len(current):
current = current[idx]
else:
raise IndexError(f"索引 {idx} 越界")
case _:
raise KeyError(f"无法访问路径 '{part}'")
return current
这里case dict()和case list()是类模式,检查current的类型,然后结合守卫进一步判断。这种方式比一连串if isinstance紧凑很多。而且如果将来需要支持其他可索引类型(比如自定义映射),只需要在match里增加对应的case即可,不用改动已有分支。
check_schema函数可以做得更有意思。我们定义一种极简的schema语法:{"name": "string", "age": "number"},值用类型名表示。检查时同样用match递归对比结构:
def check_schema(instance, schema):
match instance, schema:
case dict(), dict():
return all(
key in instance and check_schema(instance[key], schema[key])
for key in schema
)
case list(), [item_schema]:
return all(check_schema(item, item_schema) for item in instance)
case str(), "string":
return True
case (int | float), "number":
return True
case bool(), "boolean":
return True
case None, "null":
return True
case _:
return False
这个递归检查用match同时解构两个元素,每个case描述一对条件。比如case str(), "string"要求左边是字符串、右边恰好是"string"这个字面值。双重匹配让本来要拆成两个if的逻辑一行就写完了。实际项目中这种“同时对两个相关结构做模式判断”的场景非常多,match能让代码的对称性直接体现在语法上。
什么时候不用match
模式匹配好用,但不是所有条件判断都适合往里塞。如果只有两三种情况,而且判断标准是简单的if x > 10这类数值比较,那用if-elif完全足够,换成match反而显得小题大做。另外,match目前不支持直接匹配正则表达式——你可以在守卫里用re.match,但模式本身不能内嵌正则。遇到复杂的文本分类场景,还是老方法更直接。
还有一点:match的比较使用的是==语义,不是is。匹配字面量时走的是相等性判断,这在大多数情况下符合预期,但如果你需要判断对象身份,得自己在守卫里写is。
写在最后
结构化模式匹配进入Python已经几年了,但很多代码库还没来得及大量采用。如果你的项目里还躺着上百行的if-elif在猜一个字典长什么样,不妨挑一个模块试试用match重写。它最舒服的地方在于,一旦你习惯了用“形状”而不是“条件”来描述数据,函数会突然变得很短,分支边界会非常清晰,维护的人一眼就能看到每种情形的输入是什么。这种直观性是层层嵌套的条件判断很难做到的。

