Structural pattern matching in Python 3.10

Ben Hoyt

Python 3.10 中的结构化模式匹配

原文由 Ben Hoyt 发布,订阅该博客

摘要:Python 3.10 计划于 2021 年 10 月初发布,其中将包含一项名为结构化模式匹配的大型新语言特性。本文将以批判但(希望)富有信息量的方式介绍这一特性,并结合真实代码给出示例。

在最近一次本地 Python 聚会上,一位朋友正在介绍 Python 3.8 和 3.9 的一些新特性,会后我们聊起了 Python 3.10 即将推出的模式匹配功能。我忍不住小小抱怨了一通,觉得 Python 已经有点走偏了:先是使用 := 的赋值表达式,现在又来了这么一个相当庞杂的特性。

朋友对我的抱怨倒是很大度,很快说道:“听起来你想在下次聚会上就这个话题做个分享。”好吧……那就试试吧!

与此同时,我想通过把自己的想法和一些代码示例写成文章来更深入地了解这个特性。想必你已经看出来,我对此颇有偏见,但我会尽量既谈优点,也不回避批评。

用来描述这一模式匹配特性的 PEP(Python 增强提案)多达三份:

其中教程那一篇尤其能很好地概览该特性,如果你只想读一份 PEP,就读它。下面我也会演示相关功能。

我多少有点 cynical 地注意到,论证原理的那份是最长的(足足有 8500 字)。是不是有点此地无银三百两的感觉?不过公平地说,看起来他们只是因为篇幅太长,把 PEP 中常见的“被否决的想法”一节单独拆成了一份 PEP。

但我认为 PEP 中缺少的是对成本与收益的权衡。成本在于开发者需要学习大量新的语言语义,以及实现层面的成本(对 CPython 和其他 Python 实现而言)。而收益则需要结合真实世界的代码来讨论:也就是人们日常使用 Python 所写的那类代码,而不仅仅是 PEP“动机”一节中那些颇为刻意的示例。

本文想做的部分工作,就是评估一些真实代码,看看模式匹配到底能在多大程度上(或多小程度上)改进它们。不过首先,我们先简要看一下 Python 中的结构化模式匹配是什么样子。

它是什么

很容易把模式匹配想成是加强版的 switch 语句。不过,正如论证原理的 PEP 所指出的,更恰当的理解是把它看作“可迭代解包的广义概念”。多年来不少人希望 Python 加入 switch,但我能理解为什么一直没有加入。相比一连串的 if ... elif 语句,它带来的价值还不足以抵消其成本。而新的 match ... case 特性则提供了 switch 的基本功能,再加上“结构化”匹配的部分——甚至更多。

下面的类 switch 示例展示了其基本语法(假设我们在自己实现一个 Git 命令行工具):

parser = argparse.ArgumentParser()
parser.add_argument('command', choices=['push', 'pull', 'commit'])
args = parser.parse_args()

match args.command:
    case 'push':
        print('pushing')
    case 'pull':
        print('pulling')
    case _:
        parser.error(f'{args.command!r} not yet implemented')

Python 会先求值 match 表达式,然后从上到下依次尝试每个 case,执行第一个匹配的分支;如果都不匹配,则执行 _ 默认分支。

结构化就体现在这里:case 模式并不一定只是字面量。模式还可以:

  • 使用变量名,若 case 匹配则会为其赋值
  • 使用列表或元组语法匹配序列(如同 Python 已有的可迭代解包特性)
  • 使用字典语法匹配映射
  • 使用 * 匹配列表的剩余部分
  • 使用 ** 匹配字典中的其他键
  • 使用类语法匹配对象及其属性
  • 使用 | 包含“或”模式
  • 使用 as 捕获子模式
  • 包含 if “守卫”子句

哇!功能可真不少。我们试着一次性把它们都用上,看看在一个非常刻意的例子中会是什么样子(若想循序渐进地了解,请阅读教程):

class Car:
    __match_args__ = ('key', 'name')
    def __init__(self, key, name):
        self.key = key
        self.name = name

expr = eval(input('Expr: '))
match expr:
    case (0, x):              # seq of 2 elems with first 0
        print(f'(0, {x})')    # (new variable x set to second elem)
    case ['a', x, 'c']:       # seq of 3 elems: 'a', anything, 'c'
        print(f"'a', {x!r}, 'c'")
    case {'foo': bar}:        # dict with key 'foo' (may have others)
        print(f"{{'foo': {bar}}}")
    case [1, 2, *rest]:       # seq of: 1, 2, ... other elements
        print(f'[1, 2, *{rest}]')
    case {'x': x, **kw}:      # dict with key 'x' (others go to kw)
        print(f"{{'x': {x}, **{kw}}}")
    case Car(key=key, name='Tesla'):  # Car with name 'Tesla' (any key)
        print(f"Car({key!r}, 'TESLA!')")
    case Car(key, name):      # similar to above, but use __match_args__
        print(f"Car({key!r}, {name!r})")
    case 1 | 'one' | 'I':     # int 1 or str 'one' or 'I'
        print('one')
    case ['a'|'b' as ab, c]:  # seq of 2 elems with first 'a' or 'b'
        print(f'{ab!r}, {c!r}')
    case (x, y) if x == y:    # seq of 2 elems with first equal to second
        print(f'({x}, {y}) with x==y')
    case _:
        print('no match')

如你所见,它很复杂,但也很强大。匹配的具体细节在规范中有说明。好在上面的大部分内容都相当直观,不过 __match_args__ 属性需要解释一下:如果在类模式中使用了位置参数,类中 __match_args__ 元组里的项就会提供对应属性的名称。这是一种简写,可以避免在类模式中显式指定属性名。

需要留意的一点是,matchcase 并非真正的关键字,而是“软关键字”,意味着它们仅在 match ... case 块中才作为关键字起作用。这是有意为之,因为人们经常把 match 用作变量名——我自己就几乎总是用名为 match 的变量来保存正则匹配的结果。

它在何处出彩

如前所述,我认为如果只是把 match 当作更花哨的 switch 来用,并不划算。那么它在什么地方才真正有价值呢?

在教程 PEP 中有几个让它大放异彩的例子:在一个简单的文字游戏中匹配命令及其参数。我把其中的一些例子合并后摘录如下:

command = input("What are you doing next? ")
match command.split():
    case ["quit"]:
        print("Goodbye!")
        quit_game()
    case ["look"]:
        current_room.describe()
    case ["get", obj]:
        character.get(obj, current_room)
    case ["drop", *objects]:
        for obj in objects:
            character.drop(obj, current_room)
    case ["go", direction] if direction in current_room.exits:
        current_room = current_room.neighbor(direction)
    case ["go", _]:
        print("Sorry, you can't go that way")
    case _:
        print(f"Sorry, I couldn't understand {command!r}")

作为对比,我们来用不带模式匹配的传统方式快速重写一下这段代码。你几乎肯定会用一堆 if ... elif 块。为了让条件更简洁,我会新增变量 fields 来保存切分后的字段,以及 n 来保存字段数量:

command = input("What are you doing next? ")
fields = text.split()
n = len(fields)

if fields == ["quit"]:
    print("Goodbye!")
    quit_game()
elif fields == ["look"]:
    current_room.describe()
elif n == 2 and fields[0] == "get":
    obj = fields[1]
    character.get(obj, current_room)
elif n >= 1 and fields[0] == "drop":
    objects = fields[1:]
    for obj in objects:
        character.drop(obj, current_room)
elif n == 2 and fields[0] == "go":
    direction = fields[1]
    if direction in current_room.exits:
        current_room = current_room.neighbor(direction)
    else:
        print("Sorry, you can't go that way")
else:
    print(f"Sorry, I couldn't understand {command!r}")

除了稍微短一些之外,公平地说,结构化匹配的版本更易读,而且变量绑定避免了像 fields[1] 这样的手动索引。单从可读性来看,这个例子无疑是模式匹配的完胜。

教程还提供了基于类的匹配示例, presumably 是游戏事件循环的一部分:

match event.get():
    case Click((x, y), button=Button.LEFT):  # This is a left click
        handle_click_at(x, y)
    case Click():
        pass  # ignore other clicks
    case KeyPress(key_name="Q") | Quit():
        game.quit()
    case KeyPress(key_name="up arrow"):
        game.go_north()
    ...
    case KeyPress():
        pass # Ignore other keystrokes
    case other_event:
        raise ValueError(f"Unrecognized event: {other_event}")

我们试着用普通的 if ... elif 来重写这一段。和上面处理“go”命令的重写类似,我会在合适的地方把同一类的事件合并在一起:

e = event.get()
if isinstance(e, Click):
    x, y = e.position
    if e.button == Button.LEFT:
        handle_click_at(x, y)
    # ignore other clicks
elif isinstance(e, KeyPress):
    key = e.key_name
    if key == "Q":
        game.quit()
    elif key == "up arrow":
        game.go_north()
    # ignore other keystrokes
elif isinstance(e, Quit):
    game.quit()
else:
    raise ValueError(f"Unrecognized event: {e}")

在我看来,这一例就比较难说孰优孰劣。用模式匹配确实要好一些,但优势并不大。match 的好处在于所有 case 都整齐排列;if ... elif 的好处则是事件类型分组得更明显,也避免了重复写类型。

尽管我持怀疑态度,但我也在努力保持公正:这些例子看起来确实不错,即使你没读过模式匹配的规范,也能大致明白它们在做什么——可能除了 __match_args__ 那个魔法之外。

还有一个由 Guido van Rossum 编写、用于展示该特性的表达式解析器与求值器。它大量使用了 match ... case(在一个不大的文件里就用了 11 次)。下面是其中一个例子:

def eval_expr(expr):
    """Evaluate an expression and return the result."""
    match expr:
        case BinaryOp('+', left, right):
            return eval_expr(left) + eval_expr(right)
        case BinaryOp('-', left, right):
            return eval_expr(left) - eval_expr(right)
        case BinaryOp('*', left, right):
            return eval_expr(left) * eval_expr(right)
        case BinaryOp('/', left, right):
            return eval_expr(left) / eval_expr(right)
        case UnaryOp('+', arg):
            return eval_expr(arg)
        case UnaryOp('-', arg):
            return -eval_expr(arg)
        case VarExpr(name):
            raise ValueError(f"Unknown value of: {name}")
        case float() | int():
            return expr
        case _:
            raise ValueError(f"Invalid expression value: {repr(expr)}")

如果用 if ... elif 来写会是什么样子?同样,你可能会把结构稍作调整,把所有 BinaryOp 的情况放在一起。注意,由于 match 中的 case 子句本身就需要双重嵌套,嵌套的 if 块实际上并不会增加缩进层级:

def eval_expr(expr):
    """Evaluate an expression and return the result."""
    if isinstance(expr, BinaryOp):
        op, left, right = expr.op, expr.left, expr.right
        if op == '+':
            return eval_expr(left) + eval_expr(right)
        elif op == '-':
            return eval_expr(left) - eval_expr(right)
        elif op == '*':
            return eval_expr(left) * eval_expr(right)
        elif op == '/':
            return eval_expr(left) / eval_expr(right)
    elif isinstance(expr, UnaryOp):
        op, arg = expr.op, expr.arg
        if op == '+':
            return eval_expr(arg)
        elif op == '-':
            return -eval_expr(arg)
    elif isinstance(expr, VarExpr):
        raise ValueError(f"Unknown value of: {name}")
    elif isinstance(expr, (float, int)):
        return expr
    raise ValueError(f"Invalid expression value: {repr(expr)}")

由于需要手动解包 BinaryOpUnaryOp 的字段,这里多了两行。也许只是我的感觉,但我觉得这个版本和 match 版本一样易读,而且更显式。

另一个 match 可能有用的场景是在验证来自 HTTP 请求的 JSON 结构时(这是我自己编造的例子):

try:
    obj = json.loads(request.body)
except ValueError:
    raise HTTPBadRequest(f'invalid JSON: {request.body!r}')

match obj:
    case {
        'action': 'sign-in',
        'username': str(username),
        'password': str(password),
        'details': {'email': email, **other_details},
    } if username and password:
        sign_in(username, password, email=email, **other_details)
    case {'action': 'sign-out'}:
        sign_out()
    case _:
        raise HTTPBadRequest(f'invalid JSON structure: {obj}')

这看起来相当不错。不过一个缺点是它无法提供良好的校验错误信息:理想情况下,API 应该告知调用者缺少了哪些字段,或哪些类型不正确。

在我的代码中使用它

我们来看看把一些现有代码改用新特性的情况。我基本上是在扫描 if ... elif 块,看是否值得转换。我先从自己写的几段代码说起。

前面几个例子来自pygit,这是一个玩具级的 git 子集,刚好够作为 Git 客户端来创建仓库、提交并把自身推送到 GitHub(完整源代码)。

默认已折叠下面的代码块。只需点击箭头或摘要段落即可展开。
if answer() == 42:
    print('The meaning of life, the universe and everything!')
来自 find_object() 的示例。某些方面确实更好一些,但总体而言,我认为改用 match 有点过度使用该特性的意味。
def find_object(sha1_prefix):
    ...
    objects = [n for n in os.listdir(obj_dir) if n.startswith(rest)]
    if not objects:
        raise ValueError('object {!r} not found'.format(sha1_prefix))
    if len(objects) >= 2:
        raise ValueError('multiple objects ({}) with prefix {!r}'.format(
                len(objects), sha1_prefix))
    return os.path.join(obj_dir, objects[0])

原来的写法已经相当清晰,但我们来看看用 match 是否会更简单:

def find_object(sha1_prefix):
    ...
    objects = [n for n in os.listdir(obj_dir) if n.startswith(rest)]
    match objects:
        case []:
            raise ValueError('object {!r} not found'.format(sha1_prefix))
        case [obj]:
            return os.path.join(obj_dir, obj)
        case _:
            raise ValueError('multiple objects ({}) with prefix {!r}'
                .format(len(objects), sha1_prefix))

这些 case 本身确实更好一些,尤其是 obj 会自动绑定,而不必再写 objects[0]

不过不太好的一点是,“成功分支”被夹在了中间,使得正常代码路径有点被淹没。你可以像下面这样强行把它挪到最后(但这样肯定不如原来清晰):

    match objects:
        case []:
            raise ValueError('object {!r} not found'.format(sha1_prefix))
        case [_, _, *_]:
            raise ValueError('multiple objects ({}) with prefix {!r}'
                .format(len(objects), sha1_prefix))
        case [obj]:
            return os.path.join(obj_dir, obj)

或者,你也可以把成功(最具体)的分支放在最前面,这样会稍微好一点:

    match objects:
        case [obj]:
            return os.path.join(obj_dir, obj)
        case []:
            raise ValueError('object {!r} not found'.format(sha1_prefix))
        case _:
            raise ValueError('multiple objects ({}) with prefix {!r}'
                .format(len(objects), sha1_prefix))
来自 cat_file() 的示例,展示了两种不同写法。看起来略有优势。
def cat_file(mode, sha1_prefix):
    obj_type, data = read_object(sha1_prefix)
    if mode in ['commit', 'tree', 'blob']:
        if obj_type != mode:
            raise ValueError('expected type {}, got {}'.format(
                    mode, obj_type))
        sys.stdout.buffer.write(data)
    elif mode == 'size':
        print(len(data))
    elif mode == 'type':
        print(obj_type)
    elif mode == 'pretty':
        if obj_type in ['commit', 'blob']:
            sys.stdout.buffer.write(data)
        elif obj_type == 'tree':
            ... # pretty print tree
        else:
            assert False, 'unhandled type {!r}'.format(obj_type)
    else:
        raise ValueError('unexpected mode {!r}'.format(mode))

直接翻译会是下面这样(注意“pretty”分支中嵌套的 match):

def cat_file(mode, sha1_prefix):
    obj_type, data = read_object(sha1_prefix)
    match mode:
        case 'commit' | 'tree' | 'blob':
            if obj_type != mode:
                raise ValueError('expected type {}, got {}'.format(
                        mode, obj_type))
            sys.stdout.buffer.write(data)
        case 'size':
            print(len(data))
        case 'type':
            print(obj_type)
        case 'pretty':
            match obj_type:
                case 'commit' | 'blob':
                    sys.stdout.buffer.write(data)
                case 'tree':
                    ... # pretty print tree
                case _:
                    assert False, 'unhandled type {!r}'.format(obj_type)
        case _:
            raise ValueError('unexpected mode {!r}'.format(mode))

我们把 match 当作简单的 switch 来用,但优势非常微弱。如果尝试把 modeobj_type 作为元组一起匹配,会怎么样:

def cat_file(mode, sha1_prefix):
    obj_type, data = read_object(sha1_prefix)
    match (mode, obj_type):
        case ('commit' | 'tree' | 'blob', _) if obj_type == mode:
            sys.stdout.buffer.write(data)
        case ('size', _):
            print(len(data))
        case ('type', _):
            print(obj_type)
        case ('pretty', 'commit' | 'blob'):
            sys.stdout.buffer.write(data)
        case ('pretty', 'tree'):
            ... # pretty print tree
        case _:
            raise ValueError('unexpected mode {!r} or type {!r}'.format(
                mode, obj_type))

现在它比原版更精简了,不过可以说也并没有更清晰!

来自参数解析的示例,根据 CLI 子命令进行分支。使用 match 是合理的,但这只是简单的 switch,并未用到结构化特性。
args = parser.parse_args()
if args.command == 'add':
    ... # do add
elif args.command == 'cat-file':
    ... # do cat-file
elif args.command == 'commit':
    ... # do commit
...

使用 match 可以减少视觉噪音:

args = parser.parse_args()
match args.command:
    case 'add':
        ... # do add
    case 'cat-file':
        ... # do cat-file
    case 'commit':
        ... # do commit
    ...

但为此引入一个全新的特性其实并无必要。通过起一个简短的变量名,也能消除大部分视觉噪音:

args = parser.parse_args()
cmd = args.command
if cmd == 'add':
    ... # do add
elif cmd == 'cat-file':
    ... # do cat-file
elif cmd == 'commit':
    ... # do commit
...

下面还有几个来自 Canonical 的 ops 库中pebble.py 的例子,这是我为工作编写的代码。

来自 add_layer() 的示例。它处理 layer 参数允许的各种类型。视觉噪音更少,不过也更不显式。
def add_layer(self, label, layer, *, combine=False):
    ...
    if isinstance(layer, str):
        layer_yaml = layer
    elif isinstance(layer, dict):
        layer_yaml = Layer(layer).to_yaml()
    elif isinstance(layer, Layer):
        layer_yaml = layer.to_yaml()
    else:
        raise TypeError('layer must be str, dict, or pebble.Layer')
    # use layer_yaml

match 版本使用了类匹配语法:

def add_layer(self, label, layer, *, combine=False):
    ...
    match layer:
        case str():
            layer_yaml = layer
        case dict():  # could also be written "case {}:"
            layer_yaml = Layer(layer).to_yaml()
        case Layer():
            layer_yaml = layer.to_yaml()
        case _:
            raise TypeError('layer must be str, dict, or pebble.Layer')
    # use layer_yaml

match 版本更清晰吗?它确实更简洁,但我还是有点喜欢 isinstance() 调用的显式性。此外,各个 case 中空的括号有点奇怪——在没有位置参数或属性的情况下,它们看起来很多余,但如果没有它们,match 就会绑定名为 strdict 的新变量。

起初我觉得在 case 块中绑定(并赋值)的变量会活到整个 match 块之外有点奇怪。但如上所示,这么设计是有道理的——你常常会想在 match 之后的代码中使用这些变量。

来自 exec() 的示例,是我目前正在写的代码。在这种情况下并没有更清晰。
def exec(command, stdin=None, encoding='utf-8', ...):
    if isinstance(command, (bytes, str)):
        raise TypeError('command must be a list of str, not {}'
            .format(type(command).__name__))
    if len(command) < 1:
        raise ValueError('command must contain at least one item')

    if stdin is not None:
        if isinstance(stdin, str):
            if encoding is None:
                raise ValueError('encoding must be set if stdin is str')
            stdin = io.BytesIO(stdin.encode(encoding))
        elif isinstance(stdin, bytes):
            if encoding is not None:
                raise ValueError('encoding must be None if stdin is bytes')
            stdin = io.BytesIO(stdin)
        elif not hasattr(stdin, 'read'):
            raise TypeError('stdin must be str, bytes, or a readable file-like object')
    ...

match 能否简化这些检查呢?我们来看看:

def exec(command, stdin=None, encoding='utf-8', ...):
    match command:
        case bytes() | str():
            raise TypeError('command must be a list of str, not {}'
                .format(type(command).__name__))
        case []:
            raise ValueError('command must contain at least one item')

    match stdin:
        case str():
            if encoding is None:
                raise ValueError('encoding must be set if stdin is str')
            stdin = io.BytesIO(stdin.encode(encoding))
        case bytes():
            if encoding is not None:
                raise ValueError('encoding must be None if stdin is bytes')
            stdin = io.BytesIO(stdin)
        case None:
            pass
        case _ if not hasattr(stdin, 'read'):
            raise TypeError('stdin must be str, bytes, or a readable file-like object')
    ...

我认为这样并没有更清晰。case None 有点别扭——我们可以通过像原代码那样用 if stdin is not None: 把整个块包起来来避免它,但那会增加第三层嵌套,并不理想。

带守卫的默认分支 case _ if not hasattr(stdin, 'read') 也比原来的 elif 版本更晦涩一些。当然,你也可以直接用 case _,然后在里面再嵌套 if not hasattr

也许只是我不太常写能从这一特性中受益的那类代码,但我猜有很多人也属于这种情况。不过,我们还是来扫描几个流行的 Python 项目,看看能发现什么。

在其他项目中的使用

我将从三种不同类型的代码中挑选例子:库代码(来自标准库)、框架代码(来自 Django Web 框架)以及应用代码(来自为 Python 包索引提供服务的 Warehouse,以及 Mercurial 和 Ansible)。

为了尽量公平,我尝试寻找那些真正能从 match 中受益、而不仅仅是更花哨的 switch 的例子(这类例子有很多,但它们并未使用模式匹配的结构化部分,所以转换后的提升并不大)。我专门寻找那些看起来在测试数据结构的 elif 块。在只用 if 而不用 elif 的代码中,也可能有适合用 match 的好例子,但我认为那会比较少见。

标准库

Python 标准库大约有 70.9 万行代码,包括测试(使用scc 统计)。ripgrep 搜索工具(rg --type=py 'elif ' | wc)显示,其中有 2529 行是 elif 语句,占比 0.4%。我知道这也会把注释中的“elif ”算进去,但想必这种情况很少。

来自 ast.literal_eval()_convert() 辅助函数的示例,_convert() 中。毫不意外,我找到的第一个真正优秀的用例是在 AST 处理中。这绝对是一个成功案例。
def _convert(node):
    if isinstance(node, Constant):
        return node.value
    elif isinstance(node, Tuple):
        return tuple(map(_convert, node.elts))
    elif isinstance(node, List):
        return list(map(_convert, node.elts))
    elif isinstance(node, Set):
        return set(map(_convert, node.elts))
    elif (isinstance(node, Call) and isinstance(node.func, Name) and
          node.func.id == 'set' and node.args == node.keywords == []):
        return set()
    elif isinstance(node, Dict):
        if len(node.keys) != len(node.values):
            _raise_malformed_node(node)
        return dict(zip(map(_convert, node.keys),
                        map(_convert, node.values)))
    elif isinstance(node, BinOp) and isinstance(node.op, (Add, Sub)):
        left = _convert_signed_num(node.left)
        right = _convert_num(node.right)
        if isinstance(left, (int, float)) and isinstance(right, complex):
            if isinstance(node.op, Add):
                return left + right
            else:
                return left - right
    return _convert_signed_num(node)

将其改用 match

def _convert(node):
    match node:
        case Constant(value):
            return value
        case Tuple(elts):
            return tuple(map(_convert, elts))
        case List(elts):
            return list(map(_convert, elts))
        case Set(elts):
            return set(map(_convert, elts))
        case Call(Name('set'), args=[], keywords=[]):
            return set()
        case Dict(keys, values):
            if len(keys) != len(values):
                _raise_malformed_node(node)
            return dict(zip(map(_convert, keys),
                            map(_convert, values)))
        case BinOp(left, (Add() | Sub()) as op, right):
            left = _convert_signed_num(left)
            right = _convert_num(right)
            match (op, left, right):
                case (Add(), int() | float(), complex()):
                    return left + right
                case (Sub(), int() | float(), complex()):
                    return left - right
    return _convert_signed_num(node)

绝对是完胜!语法树处理似乎是 match 的理想用例。在 Python 3.10 中,ast 模块的节点类型已经设置了 __match_args__,因此可以避免 Constant(value=value) 这类重复,写得更加简洁。

不过,我还想在 ast 模块之外再找一个例子。这里就不贴出来了,但在 curses/textpad.pydo_command() 中有一条很长的 if ... elif 链:它大多是简单的 switch,但如果用上带几个 if 守卫的 match ... case,还是会有所受益。

来自 dataclasses_asdict_inner() 的示例。减少了视觉噪音,是一个不错的小改进。
def _asdict_inner(obj, dict_factory):
    if _is_dataclass_instance(obj):
        result = []
        for f in fields(obj):
            value = _asdict_inner(getattr(obj, f.name), dict_factory)
            result.append((f.name, value))
        return dict_factory(result)
    elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
        return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
    elif isinstance(obj, (list, tuple)):
        return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
    elif isinstance(obj, dict):
        return type(obj)((_asdict_inner(k, dict_factory),
                          _asdict_inner(v, dict_factory))
                         for k, v in obj.items())
    else:
        return copy.deepcopy(obj)

我们试着把它改成 match

def _asdict_inner(obj, dict_factory):
    match obj:
        case _ if _is_dataclass_instance(obj):
            result = []
            for f in fields(obj):
                value = _asdict_inner(getattr(obj, f.name), dict_factory)
                result.append((f.name, value))
            return dict_factory(result)
        case tuple(_fields=_):
            return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
        case list() | tuple():
            return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
        case {}:
            return type(obj)((_asdict_inner(k, dict_factory),
                              _asdict_inner(v, dict_factory))
                             for k, v in obj.items())
        case _:
            return copy.deepcopy(obj)

一个不错的小改进,不过第一个带 if 守卫的 case _ 有点奇怪。它也许可以移到最后的 case _ 里面作为普通的 if 语句,但我对这段代码不够熟悉,不确定那样调整顺序是否仍能满足需求。

来自 email.utilsparsedate_tz() 的示例,parsedate_tz() 中。使用元组解包进行匹配让代码清晰了不少。
def _parsedate_tz(data):
    ...
    tm = tm.split(':')
    if len(tm) == 2:
        [thh, tmm] = tm
        tss = '0'
    elif len(tm) == 3:
        [thh, tmm, tss] = tm
    elif len(tm) == 1 and '.' in tm[0]:
        # Some non-compliant MUAs use '.' to separate time elements.
        tm = tm[0].split('.')
        if len(tm) == 2:
            [thh, tmm] = tm
            tss = 0
        elif len(tm) == 3:
            [thh, tmm, tss] = tm
    else:
        return None
    # use thh, tmm, tss

我们来试着把它改用 match

def _parsedate_tz(tm):
    ...
    match tm.split(':'):
        case [thh, tmm]:
            tss = '0'
        case [thh, tmm, tss]:
            pass
        case [s] if '.' in s:
            match s.split('.'):
                case [thh, tmm]:
                    tss = 0
                case [thh, tmm, tss]:
                    pass
                case _:
                    return None
        case _:
            return None
    # use thh, tmm, tss

这确实要清晰不少。用 str.split() 时,总得先测试长度再解包元组,多少有点麻烦(你也可以捕获 ValueError 异常,但那样不够清晰,而且嵌套层级也会变深)。

顺带一提,str.partition() 方法在类似情况下常常很有用,但仅适用于中间有一个分隔符、两边各一项的场景。

有趣的是,在测试 parsedate_tz() 时,我发现这段代码存在一个 bug,在收到无效用户输入时会抛出 UnboundLocalError:如果传入像 12.34.56.78 这样带超过 3 段点分隔的时间,thh/tmm/tss 变量在后续代码中将未被定义。看看这个例子:

$ python3.10 -c 'import email.utils; \
    email.utils.parsedate_tz("Wed, 3 Apr 2002 12.34.56.78+0800")'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/usr/local/lib/python3.10/email/_parseaddr.py", line 50, in parsedate_tz
    res = _parsedate_tz(data)
  File "/usr/local/lib/python3.10/email/_parseaddr.py", line 134, in _parsedate_tz
    thh = int(thh)
UnboundLocalError: local variable 'thh' referenced before assignment

其实只需要在点分隔的分支中再加一个 else: return None 就行了。我已经提交了一个issue 和一个pull request,为此添加了测试用例并修复了这个 bug。

Django

Django 有 32.7 万行代码,包括测试。其中有 905 处使用了 elif,占比 0.3%。

来自 Django 管理后台检查中 _check_fieldsets_item() 的示例,_check_fieldsets_item() 中。结构化匹配在这里很出色,但无助于生成良好的错误信息。
def _check_fieldsets_item(self, obj, fieldset, label, seen_fields):
    if not isinstance(fieldset, (list, tuple)):
        return must_be('a list or tuple', option=label, obj=obj, id='admin.E008')
    elif len(fieldset) != 2:
        return must_be('of length 2', option=label, obj=obj, id='admin.E009')
    elif not isinstance(fieldset[1], dict):
        return must_be('a dictionary', option='%s[1]' % label, obj=obj, id='admin.E010')
    elif 'fields' not in fieldset[1]:
        return [
            checks.Error(
                "The value of '%s[1]' must contain the key 'fields'." % label,
                obj=obj.__class__,
                id='admin.E011',
            )
        ]
    elif not isinstance(fieldset[1]['fields'], (list, tuple)):
        return must_be('a list or tuple', option="%s[1]['fields']" % label, obj=obj, id='admin.E008')

    seen_fields.extend(flatten(fieldset[1]['fields']))
    ...

这很有意思:它做了大量嵌套的结构化匹配,看起来非常适合。我们来看看如何转换。这样写算是能完成任务:

def _check_fieldsets_item(self, obj, fieldset, label, seen_fields):
    match fieldset:
        case (_, {'fields': [*fields]}):
            pass
        case _:
            return must_be('a list or tuple of length 2 with a fields dict')

    seen_fields.extend(flatten(fields))
    ...

如果不需要具体的错误信息,这样写确实很棒!不过在这种情况下,详细的错误信息很可能很重要,否则也不会像原来那样细致地拆分。为了做到这一点,我们需要列出所有情况,但顺序要与原来相反,让最具体的先匹配:

def _check_fieldsets_item(self, obj, fieldset, label, seen_fields):
    match fieldset:
        case [_, {'fields': [*fields]}]:
            pass  # valid, fall through
        case [_, {'fields': _}]:
            return must_be('a list or tuple', option="%s[1]['fields']" % label, obj=obj, id='admin.E008')
        case [_, {}]:
            return [
                checks.Error(
                    "The value of '%s[1]' must contain the key 'fields'." % label,
                    obj=obj.__class__,
                    id='admin.E011',
                )
            ]
        case [_, _]:
            return must_be('a dictionary', option='%s[1]' % label, obj=obj, id='admin.E010')
        case [*_]:
            return must_be('of length 2', option=label, obj=obj, id='admin.E009')
        case _:
            return must_be('a list or tuple', option=label, obj=obj, id='admin.E008')

    seen_fields.extend(flatten(fields))
    ...

这样更清晰吗?并不见得。不断重复自己、越来越不具体,有点奇怪。而且在我看来,把 case“反过来”写、逐级回落到更宽松的匹配,也不那么直观。而用 [_, _] 紧跟着 [*_] 来表示“长度不为 2”,也算不上显式。

Warehouse

Warehouse,作为 PyPI 服务端代码,有 5.9 万行 Python 代码,包括测试。其中有 35 处使用了 elif,占比 0.06%。有趣的是,这比标准库或 Django 少了一个数量级,这也印证了我的推测:match 在“常规”代码中的收益不会那么大。

来自 BigQuery 同步中 sync_bigquery_release_files() 的示例,sync_bigquery_release_files() 中。这是我在 Warehouse 中找到的唯一一个(起初!)看起来会受益于 match 的例子,但结果证明并非如此。
for sch in table_schema:
    if hasattr(file, sch.name):
        field_data = getattr(file, sch.name)
    elif hasattr(release, sch.name) and sch.name == "description":
        field_data = getattr(release, sch.name).raw
    elif sch.name == "description_content_type":
        field_data = getattr(release, "description").content_type
    elif hasattr(release, sch.name):
        field_data = getattr(release, sch.name)
    elif hasattr(project, sch.name):
        field_data = getattr(project, sch.name)
    else:
        field_data = None

然而仔细一看,这些结构化测试是针对三个不同的值(filereleaseproject)进行的,而且所测试的结构是动态的。起初我以为 object(name=name) 就能达到目的,但实际上这段代码是在匹配属性名等于 sch.name 取值的那个属性。挺 tricky 的!

看起来 Warehouse 并不怎么迫切需要 match。我还是决定把它留在这里,因为我认为这是一个很好的反例。我们再通过浏览另外两个大型应用——Mercurial 和 Ansible——来找几个例子。

Mercurial

Mercurial,这一版本控制系统,有 26.8 万行 Python 代码,包括测试。其中有 1941 处使用了 elif,占比 0.7%——是目前最高的比例。

来自 context.pyancestor() 的示例,ancestor() 中。使用元组解包带来了小幅改进。
def ancestor(self, c2, warn=False):
    n2 = c2._node
    if n2 is None:
        n2 = c2._parents[0]._node
    cahs = self._repo.changelog.commonancestorsheads(self._node, n2)
    if not cahs:
        anc = self._repo.nodeconstants.nullid
    elif len(cahs) == 1:
        anc = cahs[0]
    else:
        anc = ...
    return self._repo[anc]

将其改用 match

def ancestor(self, c2, warn=False):
    n2 = c2._node
    if n2 is None:
        n2 = c2._parents[0]._node
    cahs = self._repo.changelog.commonancestorsheads(self._node, n2)
    match cahs:
        case []:
            anc = self._repo.nodeconstants.nullid
        case [anc]:
            pass
        case _:
            anc = ...
    return self._repo[anc]

像这样的例子还有不少,虽然可能不是巨大的胜利,但对开发者来说是一个小的“体验优化”。

Ansible

Ansible 是一个广泛使用的、用 Python 编写的配置管理系统。它有 21.7 万行 Python 代码,包括测试。其中有 1594 处使用了 elif,占比同样是 0.7%。下面是几个我看到的、可能会受益于模式匹配的例子。

来自 module_utils/basic.py_return_formatted() 的示例,_return_formatted() 中。可读性有小幅提升。
def _return_formatted(self, kwargs):
    ...
    for d in kwargs['deprecations']:
        if isinstance(d, SEQUENCETYPE) and len(d) == 2:
            self.deprecate(d[0], version=d[1])
        elif isinstance(d, Mapping):
            self.deprecate(d['msg'], version=d.get('version'), date=d.get('date'),
                           collection_name=d.get('collection_name'))
        else:
            self.deprecate(d)
    ...

使用带有一些轻量结构化模式的 match,确实能带来小小的可读性提升——不过我不太确定处理 SEQUENCETYPE 中其他类型的最佳方式:

def _return_formatted(self, kwargs):
    ...
    for d in kwargs['deprecations']:
        match d:
            case (msg, version):
                self.deprecate(msg, version=version)
            case {'msg': msg}:
                self.deprecate(msg, version=d.get('version'), date=d.get('date'),
                               collection_name=d.get('collection_name'))
            case _:
                self.deprecate(d)
    ...
来自 utils/version.py_Alpha.__lt__() 的示例,_Alpha.__lt__() 中,一段版本比较代码。用 match 做类型检查要稍微好看一些。
class _Alpha:
    ...
    def __lt__(self, other):
        if isinstance(other, _Alpha):
            return self.specifier < other.specifier
        elif isinstance(other, str):
            return self.specifier < other
        elif isinstance(other, _Numeric):
            return False
        raise ValueError

同样,用 match 会稍微好一点:

class _Alpha:
    __match_args__ = ('specifier',)
    ...
    def __lt__(self, other):
        match other:
            case _Alpha(specifier):
                return self.specifier < specifier
            case str():
                return self.specifier < other
            case _Numeric():
                return False
            case _:
                raise ValueError

在所有这些项目中,还有许多可以改用 match 的情况,但我已尽量挑选了几种不同类型的代码,至少值得一试。

该特性的一些问题

如我所展示的,模式匹配确实在少数情况下能让代码更清晰,但我对这一特性仍有不少顾虑。显然木已成舟——Python 3.10 几天后就要发布了!——但我认为为未来的设计考虑这些问题仍有价值。(Python 显然不会把人们想要的每个特性都发布:那些被否决的 PEP 就很有意思,值得一读。)

有些是琐碎的问题,比如 match ... case 需要两级缩进:PEP 作者考虑过多种替代方案,我认为他们选对了路——这只是个小麻烦。但更大的问题呢?

学习曲线与特性表层面积。从规范 PEP 的篇幅就能看出,这个特性包含了很多内容,大约有 10 个子特性被打包在一起。Python 一直是一门易于学习的语言,而这个特性虽然在纸面上看起来不错,其语义却相当复杂。

又一种实现方式。Python 之禅说:“应该有一种——最好只有一种——显而易见的方法来实现它。”实际上,Python 一直都有多种实现方式。但如今新增的这一种给开发者带来了不小的认知负担:如许多例子所示,开发者往往需要两种写法都尝试一下,仍会纠结哪一种更“显而易见”。

仅在较少见的领域有用。如上所示,确实有 match 大放异彩的场景。但它们少之又少,主要集中在处理语法树和编写解析器时。很多代码确实有 if ... elif 链,但这些往往要么是普通的按值 switch,用 elif 也差不多了,要么是所测试的条件是更复杂的组合,无法套进 case 模式(除非使用别扭的 case _ if cond 子句,但那比 elif 还要糟糕)。

我的直觉是,PEP 的作者(Brandt Bucher 和 Guido van Rossum,两位 Python 核心开发者)经常编写的那类代码确实能从模式匹配中受益,但大多数应用开发者和脚本编写者用到 match 的频率要低得多。特别是 Guido van Rossum,一段时间以来一直在做 Mypy 类型检查器,现在又在致力于加速 CPython——编译器工作无疑会涉及大量 AST。

语法行为不一致。该特性至少有两处语法在“普通 Python”中看起来是一种含义,在模式中却表现不同:

  1. 变量名:在 case 子句中的变量不会像在普通代码中那样返回其值,而是作为名称进行绑定。这意味着 case RED 不会如你预期的那样工作——它会创建一个名为 RED 的新变量,而不是匹配你的颜色常量。要匹配常量,常量名中必须包含点——所以 case Colors.RED 才有效。在编写上面的一些代码时,我实际上就犯了这个错误:我写了 case ('commit' | 'tree' | 'blob', mode),期望它在元组的第二项等于 mode 时匹配,但实际上它会把第二项赋值给 mode
  2. 类模式:它们看起来像函数调用,但实际上是 isinstancehasattr 测试。看起来很漂亮,但有时会引起混淆。这也意味着你无法对实际函数调用的结果进行匹配——那必须放在 if 守卫中。

论证原理的 PEP 在“Patterns”一节中确实承认了这些语法差异:

尽管模式表面上看起来像表达式,但重要的是要记住二者有明确的区别。事实上,没有任何模式是表达式或包含表达式。更有效的方式是把模式看作类似函数定义中形参那样的声明性元素。

__match_args__ 魔法。在我看来,__match_args__ 特性过于魔法化,要求开发者决定一个类的哪些属性(如果有的话)应该支持按位置匹配。 __match_args__ 的顺序还可能与类的 __init__ 参数顺序不同,这也很奇怪(尽管实践中你会尽量避免这样)。我能理解他们为什么要加入这一特性,因为它让 AST 节点匹配之类的代码变得非常漂亮,但它不够显式。

对其他实现的成本。CPython 迄今为止是最常用的 Python 解释器,但还有其他解释器,如 PyPy 和 MicroPython,它们将不得不决定是否实现这一特性。其他解释器本来就一直在追赶,而在 Python 发展的这个阶段加入如此庞大的特性,会让其他实现更难跟上。

最初我还担心 match 的类模式与 Python 的鸭子类型不太兼容——鸭子类型是指你直接访问对象的属性并调用其方法,而不先检查其类型(例如在使用类文件对象时)。然而,使用类模式时,你需要指定类型,它会执行 isinstance 检查。仍然可以通过 object() 来实现鸭子类型,但会显得有点奇怪。

不过,在实际使用过该特性后,我认为这主要是理论上的担忧——你会使用类模式的地方与你会使用鸭子类型的地方其实并不怎么重叠。

这一关于鸭子类型的顾虑在论证原理的 PEP 中有简要讨论

然而,为了向 Python 借助“鸭子类型”所体现的动态特性致敬,我们还添加了一种更直接的方式来指定特定属性的存在或约束。除了 Node(x, y),你也可以写 object(left=x, right=y),从而有效地去掉 isinstance() 检查,进而支持任何具有 leftright 属性的对象。

总结

我确实喜欢模式匹配的某些方面,而且某些代码用 match ... case 确实比用 if ... elif 更简洁。但这一特性所提供的价值是否足以证明其复杂性是合理的,更不用说它给学习 Python 或阅读 Python 代码的人带来的认知负担?

话虽如此,Python 一直是一门务实的编程语言,而非纯粹主义者的理想。正如 C++ 之父 Bjarne Stroustrup 所说:“只有两种语言:一种是人们抱怨的,另一种是没人用的。”我一直很喜欢 Python,并且已经成功地使用了很多年。我几乎肯定还会继续在许多任务中使用它。它并不完美,但如果它完美了,反而没人会用了。

最近我也大量使用 Go,语言本身有意地缓慢演进,这确实有其好处。大多数版本的发布说明都以“语言本身没有变化”开头——例如,在 Go 1.16 中,所有变化都在工具链和标准库中。不过,再过几个月,Go 也将迎来自己的大型新特性,即 Go 1.18 中的泛型

总体而言,我对 Python 中的结构化模式匹配有点悲观。在这个阶段(Python 今年已经 30 岁了)加入如此庞大的特性,实在有点晚。这门语言是否开始在自身的重量下走向内爆?

或者,正如我朋友所预测的,它会不会是那种在头几年被滥用于各种场景,然后社区逐渐冷静下来、只在真正能改进代码的地方使用它的特性呢?拭目以待!

可在 LobstersHacker News 上留言讨论。

本文章由 muse-spark-1.2-contributor 进行翻译

评论