1. FlyPython首页
  2. Python高级话题

Python 3.10中的结构化模式匹配

二零二一年九月

概要: Python 3.10将于2021年10月初发布,其中将包括一个称为结构化模式匹配的大型新语言特性。本文是这个特性的一个重要但是(希望是)信息丰富的展示,其中有基于现实世界代码的示例。

在最近的一次本地 Python 聚会上,我的一个朋友展示了 Python 3.8和3.9的一些新特性,之后我们讨论了 Python 3.10中的模式匹配特性。我对我认为 Python 已经失败的想法进行了温和的抱怨: 首先是使用: = 的赋值表达式,现在是这个相当庞大的特性。

我的朋友相当大方地解释了我的咆哮,很快就说,“听起来你想在我们下次见面的时候就这个问题发表一个演讲。”。好吧为什么不呢!

与此同时,我认为通过以文章形式写下自己的想法和一些代码示例,可以更好地了解这个特性。正如你所知道的,我有偏见,但是我会试着提出正面的和批评。

模式匹配特性有不少于3个 pep (Python Enhancement propotions)来描述它:

  • PEP 634: 规范
  • 635: 动机和理由
  • PEP 636: 这个特性的教程

本教程特别提供了一个很好的功能概述,所以如果您只想阅读其中一个 pep,请阅读这一个。我还将演示下面的功能。

我愤世嫉俗的一面注意到,这个理由是迄今为止最长的(计时8500字)。我认为你抗议得太多了?然而,公平地说,由于篇幅的关系,他们似乎只是将 PEP 中通常的“被拒绝的想法”部分移到了它自己的 PEP 中。

但是我认为,在这些计划中缺少的是对成本和收益的评估。对于开发人员来说,这些成本是需要学习的重要的新语言语义,以及实现的成本(对于 CPython 和其他 Python 实现)。需要结合现实世界的代码来讨论这些好处: 人们在日常基础上使用 Python 的代码类型,而不仅仅是 PEP 的“ Motivation”部分中相当人为的示例。

我在这里想做的部分工作是评估一些实际的代码,看看有多少(或者很少)模式匹配改进了它。但是首先,让我们简单看一下 Python 中的模式匹配结构是什么样的。

这是什么

人们很容易把模式匹配看作是一种类固醇上的转变。但是,正如 PEP 的基本原理所指出的那样,最好将其看作是“可迭代拆包的广义概念”。这么多年来,很多人都要求在 Python 中进行切换,尽管我能理解为什么从来没有添加过这种切换。它只是没有提供足够的价值超过一堆如果… elif 语句来支付自己。新的匹配… 案例功能提供了开关的基础知识,加上“结构”匹配部分-和一些。

基本语法如下面类似开关的例子所示(假设我们正在滚动自己的 Git CLI) :

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 表达式,然后从顶部尝试每个大小写,执行第一个匹配的大小写,如果其他大小写不匹配,则执行 _ default 大小写。

但是这里是结构部分的来源: 案例模式不仅仅是文字。这些模式也可以:

  • 如果大小写匹配,则使用设置的变量名
  • 使用列表或元组语法匹配序列(比如 Python 现有的可迭代解压缩特性)
  • 使用 dict 语法的匹配映射
  • 使用 * 匹配列表的其余部分
  • 使用 * * 匹配字典中的其他键
  • 使用类语法匹配对象及其属性
  • 使用“或”模式|
  • 使用 as 捕获子模式
  • 包括一个 if“ guard”子句

哇!这是一个很大的功能。让我们看看我们是否可以一次性使用它们,看看它们在一个非常做作的例子中是什么样子的(对于一个更渐进的介绍,请阅读教程) :

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 _ tuple 中的项提供属性的名称。这是避免在类模式中指定属性名称的一种简化方法。

需要注意的是,match 和 case 不是真正的关键字,而是“软关键字”,这意味着它们只在 match… case 块中作为关键字运行。这是经过设计的,因为人们一直使用 match 作为变量名——我几乎总是使用一个名为 match 的变量作为正则表达式匹配的结果。

它在哪里闪耀

正如我所提到的,如果你只是把匹配当作一个美化的开关,那么我认为匹配是没有回报的。那么,它的回报在哪里呢?

在 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}")

作为比较,让我们快速地重写一下这个片段,不要用模式匹配的,老派的方式。你几乎可以肯定会用到一堆如果… elif 方块。我将为 pre-split 字段设置一个新的变量字段,并为字段数设置 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}")

除了有点短,我认为可以说结构匹配版本更容易阅读,而且变量绑定避免了像字段[1]那样的手工索引。从可读性的角度来看,这个例子似乎是模式匹配的一个明显的胜利。

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

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}")

在我看来,这个问题更像是一个边缘问题。用模式匹配的确会好一点,但也不会好太多。匹配的优点是所有的案例都排列在一起; if… elif 的优点是事件类型组合得更强,我们避免重复类型。

尽管我持怀疑态度,但我还是尽量公平: 这些例子看起来确实不错,而且即使你没有读过模式匹配规范,它们的作用也是相当清楚的——可能除了 match args magic 之外。

另外还有一个表达式解析器和计算器,由吉多·范罗苏姆编写,用于展示这个特性。它大量使用 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)}")

如果用 elif 会是什么样子?再一次,您可能会使用稍微不同的结构,将所有的 BinaryOp 案例放在一起。请注意,嵌套的 if 块实际上并没有增加缩进级别,因为在匹配中 case 子句需要双重嵌套:

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)}")

由于对 BinaryOp 和 UnaryOp 字段进行了手动属性解压缩,所以还需要两行。也许只有我这么觉得,但是我发现这个版本和匹配版本一样容易阅读,而且更加直白。

另一个位置匹配可能很有用,那就是当通过 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 客户机创建一个 repo、 commit 并将自己推送到 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))

案例本身要好一些,特别是如何自动绑定 obj 而不是使用对象[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”中的嵌套匹配) :

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))

我们用比赛作为一个简单的转换,但这是一个非常小的胜利。如果我们尝试将其重新设置为与 mode 和 obj _ type 同时匹配 tuple,会怎么样:

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 是有意义的,但它是一个简单的开关,不使用结构特性。
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 的 Python Operator Framework 中的一些例子,用 pebble.py 表示,这是我为工作而写的。

例如 add _ 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

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

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

匹配版本更清晰吗?它没有那么吵,但是我有点喜欢 isinstance ()调用的明确性。此外,各种情况下的空括号有点奇怪——它们在没有位置参数或属性的情况下看起来是不必要的,但是如果没有它们,匹配将绑定名为 str 或 dict 的新变量。

一开始,我觉得在一个 case 块中绑定(并赋值)变量的方式比整个匹配块的寿命都长,这很奇怪。但是如上所示,这是有意义的——您通常希望使用匹配下面的代码中的变量。

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')
    ...

匹配是否有助于简化这些检查? 让我们看看:

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')
    ...

我认为这是不清楚的。情况 None 是尴尬的——如果 stdin 不是 None,我们可以通过包装整个事情来避免它: 就像原始代码那样,但是这增加了第三个嵌套级别,这并不理想。

带有保护的默认大小写 case _ if not hasattr (stdin,‘ read’)也比原始 elif 版本稍微晦涩一些。你当然可以只使用 case,然后嵌套 if not hasattr。

也许我只是没有写出多少能从这个特性中受益的代码,但是我猜想有很多人都属于这个阵营。但是,让我们扫描一些流行的 Python 项目中的代码,看看我们能找到什么。

在其他项目中使用它

访问: 标准图书馆 | Django | Warehouse | Mercurial | Ansible

我将从三种不同类型的代码中挑选示例: 库代码(来自标准库)、框架代码(来自 Django web 框架)和应用程序代码(来自为 Python Package Index 提供支持的服务器 Warehouse、来自 Mercurial 和来自 Ansible)。

为了公平起见,我试图找到一些能够真正从比赛中受益的例子,而且这些例子不仅仅是一个被美化了的开关(有很多这样的例子,但是他们没有使用模式匹配的结构部分,所以转换这些例子并不是一个巨大的胜利)。我去寻找 elif 块,它们看起来像是在测试数据的结构。在只使用 if 而不使用 elif 的代码中,可能会有一些很好的匹配用法,但我认为这种情况很少见。

标准库

Python 的标准库有大约709,000行代码,包括测试(使用 scc 进行测量)。Ripgrep 搜索工具(rg — type = py‘ elif’| wc)告诉我们,其中2529行是 elif 语句,即0.4% 。我知道这会在评论中找到“ elif”,但是想必这很少见。

例子来自 ast.literal _ eval () ,在 _ convert () helper 中。毫不奇怪,我发现的第一个真正好的用例是在 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)

这绝对是一场胜利!语法树处理似乎是匹配的理想用例。在 Python 3.10中,ast 模块的节点类型已经有了 _ match _ args _ set,因此通过避免 Constant (value = value)类型的重复,使其更加整洁。

尽管如此,我还是想在模块外面找到一个。我不会在这里包括它,但是在 curses/textpad.py in do _ command ()中有一个很长的 if… elif 链: 它通常是一个简单的开关,但是如果有几个 if 警卫的话,它会受益于 match… case。

来自数据类的例子,在 _ 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)

让我们试着把它转换成匹配的:

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 后卫有点奇怪。它可以移动到最后一种情况下的常规 if 语句,但是我不太了解代码,不知道这种排序是否仍然能够满足需要。

在 parsetate _ tz ()中,使用 email.utils 示例。
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: 如果您传递的时间包含多于3个点线段,比如12.34.56.78,那么下面的代码将不会定义 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

所有它需要的是另一个: 返回没有在点的情况下。我打开了一个问题和一个拉请求,为此添加了一个测试用例并修复了错误。

姜戈

Django 有327,000行代码,包括测试,其中 elif 有905种用法,占0.3% 。

例如 Django 管理员检查,in _ 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))
    ...

是不是更清晰了?也不尽然。重复自己有点奇怪,越来越不具体。对我来说,这也似乎不那么明显的情况下“倒退”,落到松散的比赛。并且[ _,_ ]后跟[ * _ ]表示“长度不是2”并不十分明确。

仓库

仓库,PyPI 的服务器代码,有59,000行 Python 代码,包括测试。Elif 有35种用途,或者说0.06% 。有趣的是,这个数量级比标准库或 Django 都要低,这与我的猜想相符,即在“常规”代码中,匹配不会带来太多回报。

来自 BigQuery 同步的示例,在 sync _ BigQuery _ release _ files ()中。这是我在 Warehouse 中找到的唯一一个例子(起初)看起来会从匹配中获益,但结果并非如此。
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

但是,仔细检查一下,这些结构测试是针对三个不同的值(文件、发布和项目)进行的,并且测试的结构是动态的。一开始我认为 object (name = name)可以做我们想要的事情,但是代码实际上是匹配一个名为 whatever sch.name 的值的属性。狡猾!

看来《仓库》并不是急需配对。我决定把它留在这里,因为我认为这是一个很好的对比。让我们通过浏览另外两个大型应用程序: Mercurial 和 Ansible 来找到更多的例子。

Mercurial

版本控制系统 Mercurial 有268,000行 Python 代码,包括测试。1941年使用了 elif,即0.7% ——这是迄今为止最高的比例。

示例来自 context.py,在 ancestors ()中。
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]

有相当多的情况下,这可能不是一个巨大的胜利,但它是一个小的“生活质量”的改善为开发人员。

安塞波的

是一个广泛使用的 Python 组态管理系统。它有217,000行 Python 代码,包括测试。Elif 有1594种用法,也是0.7% 。下面是我看到的几个案例,它们可能会从模式匹配中受益。

来自模块 _ utils/basic.py 的示例,在 _ 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)
    ...
在 _ Alpha. _ lt _ ()中的 utils/version.py 示例,一些版本比较代码。
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,但是我已经尝试挑选了一些不同类型的代码,这些代码至少可以尝试一下。

特性的一些问题

正如我所展示的,在一些情况下,模式匹配文档确实使代码更加清晰,但是我对这个特性有一些担心。显然,这艘船已经启航了—— python3.10将在几天内发布!但是我认为为将来的设计考虑这些问题是有价值的。(Python 肯定没有提供人们想要的所有特性: 被拒绝的 pep 值得仔细研究。)

有一些琐碎的东西,比如如何匹配… case 需要两个缩进级别: PEP 作者考虑了各种替代方案,我相信他们选择了正确的路线——这只是一个小麻烦。但是更大的问题呢?

学习曲线和表面积。从规范 PEP 的大小可以看出,这个特性有很多,一个特性包含大约10个子特性。Python 一直是一种容易学习的语言,这个特性虽然在页面上看起来不错,但在语义上却有很多复杂性。

另一种做事方式。Python 之禅说,“应该有一个——最好只有一个——显而易见的方法来实现它。”实际上,Python 总是有许多不同的方法来做事情。但现在有一个问题给开发人员增加了相当大的认知负荷: 正如许多例子所示,开发人员可能经常需要同时尝试有匹配和没有匹配,仍然需要讨论哪个更“明显”。

只有在更罕见的领域有用。如上所示,在某些情况下,匹配真的很出色。但是它们很少而且相差很远,主要是在处理语法树和编写解析器时。很多代码确实有 if… elif 链,但这些通常都是简单的值开关(elif 几乎同样可以工作) ,或者它们测试的条件是一个更复杂的测试组合,不符合用例模式(除非您使用笨拙的 case _ if cond 子句,但是这比 elif 严格地更糟糕)。

我的直觉是,PEP 的作者(Brandt Bucher 和吉多·范罗苏姆都是 Python 的核心开发人员)经常编写那种从模式匹配中获益的代码,但是大多数应用程序开发人员和脚本编写人员需要匹配的代码要少得多。特别是吉多·范罗苏姆已经在 Mypy 类型检查器上工作了一段时间,现在他正在加速 CPython-compiler 工作,毫无疑问涉及 ast。

语法的工作方式不同。这个特性至少有两个部分,在一个模式中,看起来像“普通 Python”中某个东西的语法的行为是不同的:

  1. 变量名: case 子句中的变量不像普通代码那样返回其值,而是绑定为名称。这意味着情况红色不工作,因为你期望-它将设置一个新的变量名为红色,不匹配你的颜色常数。为了匹配常量,它们必须有一个点——所以大小写为 Colors.RED。在编写上面的一些代码时,我实际上犯了这样一个错误: 我写了 case (‘ commit’| ‘ tree’| ‘ blob’,mode) ,期望它匹配 tuple 的第二个项目是否等于 mode,但是当然它会将 mode 设置为第二个项目。
  2. 类模式: 这些看起来像函数调用,但它们实际上是 isinstance 和 hasattr 测试。它看起来不错,但有时会让人困惑。这也意味着你不能匹配一个实际的函数调用的结果-这将必须在一个 if 守卫。

PEP 的基本原理在“模式”部分承认了这些语法变化:

尽管模式表面上看起来像表达式,但重要的是要记住,它们之间有明确的区别。事实上,没有模式是或包含表达式。将模式看作类似于函数定义中的形式参数的声明性元素更有效率。

这个匹配方案有魔力。在我看来,match args 功能太神奇了,它要求开发人员决定一个类的哪些属性应该是位置匹配的,如果有的话。同样奇怪的是,match args 的顺序可能与类的 init 参数的顺序不同(尽管在实践中你会尽量避免这样做)。我可以理解为什么他们包含了这个特性,因为它使类似 AST 节点匹配非常好,但是不是很明确。

其他实现的成本。CPython 是目前最常用的 Python 解释器,但是还有其他的解释器,比如 PyPy 和 MicroPython,需要决定是否实现这个特性。无论如何,其他解释器总是在追赶,但是在 Python 历史的这个阶段,这种规模的特性将使其他实现更难跟上。

起初,我还担心 match 的类模式不能很好地适应 Python 使用 duck typing 的情况,即只访问属性并调用对象上的方法,而不首先检查其类型(例如,在使用类似文件的对象时)。但是,对于类模式,您指定类型,它执行 isinstance 检查。使用 object ()仍然可以进行 Duck 输入,但是这有点奇怪。

不过,既然我已经使用了这个特性,我认为这主要是一个理论上的问题——您使用类模式的地方与您使用 duck 键入的地方并不完全重叠。

在 PEP 的基本原理中简要讨论了这个鸭子类型关注点:

然而,为了向 Python 的动态特性致敬,我们还添加了一种更直接的方式来指定特定属性的存在或约束。除了 Node (x,y)之外,您还可以编写 object (left = x,right = y) ,有效地消除 isinstance ()检查,从而支持任何左右属性的对象。

结束了

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

也就是说,Python 一直是一种实用的编程语言,而不是纯粹主义者的梦想。正如 c + + 的创造者比雅尼·斯特劳斯特鲁普 · 马丁所说,“世界上只有两种语言: 人们抱怨的语言和没有人使用的语言。”我一直都很喜欢 Python,并且我已经成功地使用它很多年了。我几乎肯定会继续使用它来完成许多任务。它并不完美,但如果它是,没有人会使用它。

我最近也经常使用围棋,围棋语言的改变速度非常缓慢(出于设计) ,这无疑是件好事。大多数发布说明都是以“语言没有变化”开头的——例如,在 Go 1.16中,所有的变化都发生在工具和标准库中。也就是说,在几个月内,Go 将有自己的大型新功能,并且在 Go 1.18中引入了泛型。

总的来说,我对 Python 中的结构/模式匹配有点悲观。这只是一个在游戏后期才添加的大功能(Python 今年已经30岁了)。这种语言是不是在自身的压力下开始崩溃了?

或者,正如我的朋友所预测的那样,它是那些在未来几年内会被过度使用的特性之一,然后社区将安定下来,只在真正改进代码的地方使用它?我们走着瞧!

原创文章,作者:flypython,如若转载,请注明出处:http://flypython.com/advanced-python/490.html