Structural pattern matching in Python 3.10

Ben Hoyt

Python 3.10の構造的パターンマッチング

原文は Ben Hoyt により に公開されました。 このブログを購読する

要約:2021年10月上旬にリリース予定のPython 3.10には、構造的パターンマッチングと呼ばれる大規模な新言語機能が含まれる。本記事では、この機能について批判的ではあるが(できれば)有益な形で紹介し、実世界のコードに基づく例を交えて解説する。

先日、地元のPythonミートアップで、友人がPython 3.8と3.9の新機能について発表していた。その後、Python 3.10で導入されるパターンマッチングの話題になり、私はつい軽く愚痴をこぼしてしまった。Pythonは迷走しているのではないか、と。まずは:=を使った代入式、そして今度はこのやたらと肥大化した機能だ、と。

友人は私の愚痴をずいぶん好意的に受け取ってくれて、すぐに「じゃあ次のミートアップでその話をしてくれたら」と言ってきた。まあ……いいだろう!

それまでの間に、この機能をもっとよく知るために、考えたことやコード例を記事の形でまとめてみることにした。お察しのとおり私はかなり偏っているが、批判だけでなく良い点もできるだけ公平に紹介したい。

パターンマッチング機能については、なんと3つのPEP(Python Enhancement Proposal)で解説されている。

特にチュートリアルはこの機能の概要をよくまとめているので、1つだけ読むならこれがおすすめだ。以下でも機能のデモを行う。

皮肉屋の私は、根拠を説明するPEPが群を抜いて長い(約8500語)ことに気づいてしまった。弁解すればするほど怪しい? と。ただ、公平を期して言えば、通常ならPEP内の「却下されたアイデア」セクションに入る内容を、長すぎるために別のPEPに分けただけのようだ。

しかしPEPに欠けているのは、コストと利益を天秤にかけた評価だと思う。コストとは、開発者が学ばなければならない大幅な新言語セマンティクスであり、CPythonや他のPython実装における実装コストでもある。利益については、実世界のコード、つまりPEPの「動機」セクションにあるような作為的な例ではなく、人々が日常的にPythonで書いているようなコードに照らして議論されるべきだ。

本記事で私がやりたいことの一つは、実際のコードを評価して、パターンマッチングがどれだけ(あるいはどれだけ少ししか)改善するのかを見てみることだ。ただ、その前にPythonにおける構造的パターンマッチングがどんなものか、簡単に見ておこう。

それは何か

パターンマッチングを、switch文の強化版と考えるのは自然だ。しかし根拠を説明するPEPが指摘するように、むしろ「イテラブルのアンパックの一般化された概念」と考えた方がよい。長年にわたって多くの人がPythonにswitchを求めてきたが、導入されなかったのも理解できる。if ... elifの羅列と比べて、わざわざ導入するほどの価値がなかったのだ。新しいmatch ... case機能は、switchの基本に加えて、「構造的」なマッチング部分――そしてさらに多くのものを提供する。

基本的な構文は、次のようなswitch的な例で示せる(自分たちで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式を評価し、上から順に各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という変数名で受けることがほとんどだ。

真価を発揮するところ

前述したように、単なる気の利いたswitchとして使うだけでは、matchは元が取れないと思う。では、どこで元が取れるのだろうか。

チュートリアルのPEPには、matchが真価を発揮する例がいくつかある。シンプルなテキストベースのゲームで、コマンドとその引数をマッチさせる例だ。それらの例をいくつかまとめて、以下に引用する。

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]のような手動でのインデックス操作を避けて変数束縛ができる点でも優れている。純粋な読みやすさという点では、この例はパターンマッチングの明確な勝利と言ってよいだろう。

チュートリアルでは、クラスベースのマッチングの例も紹介されている。おそらくゲームのイベントループの一部だろう。

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の方が確かに少しは良いが、大差はない。matchcaseがきれいに並ぶのが利点であり、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のケースをまとめるだろう。ネストしたifブロックは、matchcase節のために二重にネストが必要なせいで、実際にはインデントレベルが増えないことにも注意してほしい。

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のフィールドを手動でアンパックする分、2行増えている。私だけかもしれないが、こちらもmatch版と同じくらい読みやすく、より明示的だと感じる。

HTTPリクエストからのJSONの構造を検証する場合も、matchが役立つかもしれない(これは私が考えた架空の例だ)。

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ブロックを探して、変換する意味があるかを見ていく。まずは私自身が書いたコードの例から始めよう。

最初の2つの例は、pygitからのものだ。これはGitのサブセットを玩具として実装したもので、リポジトリの作成、コミット、そして自分自身をGitHubにプッシュするのに十分なGitクライアントになっている(完全なソースコード)。

以下のコードブロックはデフォルトで折りたたんであります。矢印または要約段落をクリックすると展開されます。
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))

ケース自体は少し良くなっている。特にobjobjects[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()からの例。2通りの方法で示す。わずかな勝利のように思える。
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からの例を2つ紹介しよう。

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()呼び出しの明示性の方が私は好きだ。加えて、様々なケースにある空の括弧は少し奇妙だ――位置引数も属性もないのに不要に見えるが、これがないとmatchstrdictという名前の新しい変数を束縛してしまう。

最初は、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:で囲めば回避できるが、そうするとネストが3段階になり、あまり理想的ではない。

ガード付きのデフォルトケースcase _ if not hasattr(stdin, 'read')も、元のelif版より少し分かりにくい。もちろんcase _を使ってからif not hasattrをネストさせることもできる。

もしかすると、私がたまたまこの機能の恩恵を受けるようなコードをあまり書いていないだけかもしれないが、この部類に入る人はかなり多いのではないかと思う。とはいえ、人気のあるPythonプロジェクトのコードをいくつか見て、何が見つかるか見てみよう。

他のプロジェクトで使ってみる

ライブラリのコード(標準ライブラリから)、フレームワークのコード(Djangoウェブフレームワークから)、そしてアプリケーションコード(Python Package Indexを支えるWarehouse、Mercurial、Ansibleから)という、3つの異なるタイプのコードから例を選ぶことにする。

公平を期すため、単なる気の利いたswitch以上のもの(そういうものはたくさんあったが、パターンマッチングの構造的な部分を使っていないので、変換しても大きな勝利にはならない)で、本当にmatchの恩恵を受けそうな例を探すようにした。データの構造をテストしているように見えるelifブロックを探した。ifだけでelifを使っていないコードの中にもmatchが有効な例があるかもしれないが、それは稀だろうと思う。

標準ライブラリ

Pythonの標準ライブラリは、テストを含めて約70万9000行のコードがある(sccを使って計測)。ripgrepという検索ツール(rg --type=py 'elif ' | wc)によると、そのうち2529行がelif文であり、0.4%に相当する。コメント中の「elif 」も拾ってしまうことは承知しているが、おそらく稀だろう。

ast.literal_eval()からの例。_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モジュール以外でも探してみたい。ここでは載せないが、do_command()curses/textpad.py内)に、長い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)

ちょっとした改善だが、最初のガード付きcase _は少し奇妙だ。最後のcase _内の通常のif文に移せるかもしれないが、コードをよく知らないので、その順序でも要件を満たすかは分からない。

email.utilsからの例。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()メソッドはこのような場合によく役立つ。ただし、セパレータを挟んだ2つの要素がある場合に限られる。

興味深いことに、parsedate_tz()をテストしている際に、このコードに無効なユーザー入力で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と、このバグを修正しテストケースを追加するプルリクエストを作成した。

Django

Djangoはテストを含めて32万7000行のコードがある。そのうちelifは905箇所、つまり0.3%だ。

Django管理チェックからの例。_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でない」ことを意味する[_, _]の後の[*_]も、決して明示的とは言えない。

Warehouse

Warehouse、PyPIのサーバーコードは、テストを含めて5万9000行のPythonコードがある。elifは35箇所で、0.06%だ。興味深いことに、標準ライブラリやDjangoの10分の1の割合であり、これは「通常の」コードではmatchがあまり報われないという私の推測と一致する。

BigQuery同期からの例。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

しかしよく見てみると、これらの構造的テストは3つの異なる値(filereleaseproject)に対して行われており、テストしている構造は動的だ。最初はobject(name=name)が求めていることをしてくれるかと思ったが、このコードは実際にはsch.nameの値である名前を持つ属性にマッチしているのだ。ややこしい!

Warehouseは決してmatchを切望していたわけではないようだ。それでも対比として良い例だと思うので、ここに残しておく。さらに2つの大規模アプリケーション、MercurialとAnsibleをざっと見て、あと2つほど例を探してみよう。

Mercurial

Mercurial、バージョン管理システムは、テストを含めて26万8000行のPythonコードがある。elifは1941箇所で、0.7%――これまでで最も高い割合だ。

context.pyからの例。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]

これと似たようなケースはかなり多く、大きな勝利ではないかもしれないが、開発者にとってはちょっとした「QOL」向上にはなる。

Ansible

Ansibleは、Pythonで書かれた広く使われている構成管理システムだ。テストを含めて21万7000行のPythonコードがある。elifは1594箇所で、こちらも0.7%だ。以下は、パターンマッチングの恩恵を受けそうだと私が見たケースの2つだ。

module_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)
    ...
utils/version.pyからの例。_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が2段階のインデントを必要とすることなどがある。PEPの著者たちは様々な代替案を検討し、正しい選択をしたと思う――それは些細な不便でしかない。しかし、より大きな問題はどうだろうか。

学習曲線と表面積。仕様PEPの大きさからも分かるように、この機能には約10のサブ機能が1つに詰め込まれており、盛りだくさんだ。Pythonは常に学びやすい言語だったが、この機能は一見するときれいに見えるかもしれないが、そのセマンティクスには多くの複雑さがある。

やり方の多様化。Pythonの禅には「一つのことを成し遂げる明白な方法は、できれば一つだけあるべきだ」とある。実際には、Pythonには常に多くのやり方がある。しかし今、この機能によって開発者の認知負荷がかなり増えることになる。多くの例で示したように、開発者はしばしばmatchありとなし両方を試す必要があり、それでもどちらがより「明白」か議論が残るのだ。

稀な領域でのみ有用。上述したように、matchが本当に輝くケースもある。しかしそれらは稀で、主に構文木を扱ったりパーサーを書いたりする場合だ。多くのコードには確かにif ... elifチェーンがあるが、これらはしばしば単なる値によるswitchであり、そこではelifでもほぼ同様に機能するか、あるいはテストしている条件がより複雑な組み合わせで、(ぎこちないcase _ if cond節を使わない限り)caseパターンには収まらないものだ。だがそれはelifより明らかに劣る。

私の直感では、PEPの著者(ともにPythonコア開発者であるBrandt BucherとGuido van Rossum)は、パターンマッチングの恩恵を受けるようなコードを日常的に書いているが、ほとんどのアプリケーション開発者やスクリプトを書く人にとってmatchが必要になることははるかに少ない。特にGuido van RossumはしばらくMypy型チェッカーに取り組んでおり、現在はCPythonの高速化に取り組んでいる――コンパイラの仕事には間違いなくASTが絡む。

構文の挙動が異なる。この機能には、通常のPythonではあるものに見える構文が、パターンの中では異なる動作をする部分が少なくとも2つある。

  1. 変数名:case節内の変数は通常のコードのように値を返さず、名前として束縛される。つまりcase REDは期待通りに動作せず、REDという新しい変数を設定してしまう。定数にマッチさせるには、ドットを含める必要がある――つまりcase Colors.REDなら動作する。上記のコードを書いている際にも実際にこの間違いを犯した。case ('commit' | 'tree' | 'blob', mode)と書き、タプルの2番目の要素がmodeと等しければマッチするつもりだったが、もちろんmodeを2番目の要素に設定してしまうことになる。
  2. クラスパターン:これは関数呼び出しのように見えるが、実際にはisinstancehasattrのテストだ。見た目は良いが、時に混乱を招く。また、実際の関数呼び出しの結果にマッチさせることはできない――それはifガードの中に書く必要がある。

根拠を説明するPEPは、これらの構文の違いについて「パターン」セクションで認めている。

パターンは表面的には式のように見えるかもしれないが、明確な区別があることを心に留めておくことが重要だ。実際、パターンは式ではなく、式を含むこともない。パターンを関数定義における仮引数のような宣言的な要素と考える方が生産的だ。

__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が言ったように、「言語には2種類しかない。文句を言われる言語と、誰にも使われない言語だ」。私は常にPythonが好きで、長年うまく使い続けてきた。これからも多くのタスクで使い続けるだろう。完璧ではないが、もし完璧だったら誰も使わないだろう。

最近はGoもよく使っているが、言語の変化が(設計上)ゆっくりであることの良さは確かにある。ほとんどのリリースノートは「言語に変更はありません」から始まる――例えばGo 1.16では、すべての変更がツールや標準ライブラリに関するものだった。とはいえ、Goも数ヶ月後にGo 1.18で導入されるジェネリクスという大きな新機能を控えている。

全体として、私はPythonの構造的パターンマッチングについて少し悲観的だ。ゲームのこの段階(Pythonは今年で30歳になる)で追加するには、あまりにも大きな機能だ。言語はその重みで潰れ始めているのだろうか。

あるいは、友人が予測したように、これは2、3年の間あらゆる場面で過剰に使われ、その後コミュニティが落ち着いて、本当にコードを改善する場合にだけ使われるようになる類の機能なのだろうか。どうなるか見てみよう!

LobstersHacker Newsでコメントをどうぞ。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント