2024年1月: 構造的パターンマッチングのさらなるパターンの紹介(takanory)

鈴木たかのり(@takanory)です。 今月の「Python Monthly Topics」では、第1回で紹介したPython 3.10の新機能「構造的パターンマッチング(Structural Pattern Matching)」の続きをお届けします。

前回は構造的パターンマッチング全体の説明、いくつかのパターンをコード例を交えて紹介しました。 今回はその続きとして、前回紹介できなかった他のパターンについても、紹介します。

構造的パターンマッチングとは

前回の繰り返しになりますが、この記事で初めて構造的パターンマッチングを知った人に向けて、簡単に紹介します。詳細は上記の記事を参照してください。

構造的パターンマッチングはPython 3.10で新しく導入された文法です。 Python 3.10は2021年10月にリリースされました。

新しい ソフトキーワード として matchcase_ が増えました。 これらのソフトキーワードを使用して、match の後ろの式の結果に合致する case にマッチするという文法です。 case の後ろにさまざまな パターン が書けることが特徴です。

構造的パターンマッチングの例
match beer_style:  # Pilsner, IPA, Hazy IPA and others
    case "Pilsner":
        result = "First drink"
    case "IPA":
        result = "I like it"
    case "Hazy IPA":
        result = "Cloudy and cloudy"
    case _:  # ワイルドカードパターン
        result = "I like most beers"

さまざまなパターン

上の例では文字列(リテラルパターン)と ワイルドカードパターン_)のみを使用していますが、ほかにもさまざまなパターンが存在します。 前回の記事では キャプチャーパターンクラスパターン を紹介しました。 今回はそれ以外のパターンについて紹介します。

シーケンスパターン

シーケンスパターン はリストやタプルなどにマッチするパターンです。 前回の例と同様にビールやフードを注文したいと思います。 以下のように注文情報がタプルに格納されるとします。

注文のタプルの例
order = ("bill",)  # 会計
order = ("food", "pizza")  # フード注文
order = ("water", 3)  # 水
order = ("beer", "IPA", "Pint")  # ビールの種類とサイズ

シーケンスパターンでは以下のように書くと、任意の長さのシーケンスにマッチします。

シーケンスパターンで注文を分岐
match order:
    case [order_type]:  # 会計
        print("会計します")
    case [order_type, param]:  # フードまたは水の注文
        if order_type == "food":
            print(f"フード「{param}」を作ります")
        elif order_type == "water":
            print(f"水を{param}杯運びます")
    case [order_type, style, size]:  # ビールの注文
        print(f"{style}のビールを{size}サイズで注ぎます")
    case _:
        print("正しく注文してください")

先ほどの「注文タプルの例」を order 変数に代入した場合、結果は以下の様になります。

order = ("bill",)
# 会計します
order = ("food", "pizza")
# フード「pizza」を作ります
order = ("water", 3)
# 水を3杯運びます
order = ("beer", "IPA", "Pint")
# IPAのビールをPintサイズで注ぎます

ただ、このままだと ("food", "nuts", "pizza") のような注文が、シーケンスの長さが3のためビールの注文とみなされてしまいます。 以下のようにシーケンスパターンとリテラルパターンを組み合わせると、「最初の要素が "food" で長さ2のシーケンス」のように、より厳密に指定できます。

シーケンスパターンとリテラルパターンを組み合わせる
match order:
    case ["bill"]:
        # 会計
    case ["food", food]:
        order_food(food)  # フードの注文
    case ["water", number]:
        order_water(number)  # 水の注文
    case ["beer", style, size]:
        order_beer(style, size)  # ビールの注文
    case _:
        print("正しく注文してください")

任意の長さのシーケンスにマッチ

("food", "nuts", "fries", "pizza") のように一度に複数のフードを注文したいとします。 その場合はキャプチャ対象の変数に * を付けることで、任意の長さのシーケンスにマッチできます。

foods 変数を for 文で処理することによって、フードを1つずつに分割して order_food() 関数で注文できます。

シーケンスパターンとリテラルパターンを組み合わせる
order = ("food", "nuts", "fries", "pizza")

match order:
    case ["food", *foods]:  # 複数のフードに対応
        for food in foods:  # foods変数には("nuts", "fries", "pizza")が代入される
            order_food(food)  # フードの注文

ORパターンとASパターン

この店ではビールのサイズは "Pint""HalfPint" しか指定できないとします。 その場合はリテラルパターンと ORパターン|)を組み合わせて以下のように指定します。

ORパターンで任意のビールのサイズにのみ対応
order = ("beer", "IPA", "Pint")
# order = ("beer", "IPA", "Mass")  # 無効なサイズ

match order:
    case ["beer", style, "Pint" | "HalfPint"]:  # ビールのサイズをORパターンで定義
        pass # ビールを注文
    case ["beer", style, size]:  # それ以外のサイズ
        print(f"{size}は無効なサイズです。PintかHalfPintのみです")

しかし上記のコードでは、ビールのサイズがどちらかわかりません。 そのような場合は、ASパターン を使用してサブパターン(ここでは "Pint" | "HalfPint")にマッチした値を変数に代入します。 以下のように書くと size 変数にビールのサイズが代入されます。

ASパターンでサイズを変数に代入
order = ("beer", "IPA", "Pint")
# order = ("beer", "IPA", "Mass")  # 無効なサイズ

match order:
    case ["beer", style, "Pint" | "HalfPint" as size]:  # サイズをORパターンで定義
        order_beer(style, size)  # ビールを注文

組み込みクラスにマッチ

フードの注文をするときにはフードの名前(文字列)を指定し、水の注文をするときにはグラスの数(整数)のみを指定できるようにしたいです。 その場合クラスパターンとASパターンを使用すると以下のように書けます。

ASパターンで組み込みクラスを指定
order = ("food", "pizza")
# order = ("food", True)  # フードの注文としてマッチしない
# order = ("water", 5)
# order = ("water", "ebian")  # 水の注文としてマッチしない

match order:
    case ["food", str() as food]:
        order_food(food)
    case ["water", int() as number]:
        order_water(number)

組み込みクラスの場合は str() as food の部分を str(food) のように書けます。 よりコンパクトになるのでおすすめです。

組み込みクラスにマッチ
match order:
    case ["food", str(food)]:
        order_food(food)
    case ["water", int(number)]:
        order_water(number)

マッピングパターン

マッピングパターン は辞書のようなマッピング型にマッチします。 JSONをパースした辞書オブジェクトをマッチするのに便利です。

たとえば、辞書形式で注文をする場合は以下のような形式になります。

マッピングパターン
order = {"food": "pizza"}
# order = {"water": 3}
# order = {"beer": "IPA", "size": "Pint"}

match order:
    case {"food": str(food)}:
        order_food(food)  # フードの注文
    case {"water": int(number])}
        order_water(number)  # 水の注文
    case {"beer": style, "size": ("Pint" | "HalfPint") as size}:
        order_beer(style, size)  # ビールの注文
    case _:
        print("正しく注文してください")

マッピングパターンはシーケンスパターンと異なり、辞書データに余分な要素が存在してもマッチします。 以下の例では余分なピザの種類が指定してありますが、フードの注文としてマッチします。

辞書の余分な要素は無視される
order = {"food": "pizza", "type": "margherita"}
# order = {"food": "fries", "size": "large"}

match order:
    case {"food": str(food)}:
        order_food(food)  # フードの注文

また辞書の残りの要素をキャプチャーして変数に代入もできます。 その場合は変数名の前に ** を付けます。 以下のコード例では rest 変数には辞書 {"type": "margherita"} が代入されます。 なお、余分な要素がない場合は rest には空の辞書({})が代入されます。

辞書の余分な要素をキャプチャーする
order = {"food": "pizza", "type": "margherita"}
# order = {"food": "pizza"}  # rest は {} となる

match order:
    case {"food": str(food), **rest}:
        order_food(food, rest)  # rest = {"type": "margherita"}

ガード

最後に ガード について説明します。 ガードはパターンの後ろに if 文を書くことで、その if 文の結果が True となるときだけパターンにマッチします。

たとえば水の注文で、0以下 の数が指定できるのは適切ではありません。 また、あまりたくさん水を注文されても困るので、上限を 8 とします。 その場合、ガードを使うと以下の様に書けます。

ガードで水の数を制限
order = ("water", 3)
# order = ("water", 10)  # 範囲外
# order = ("water", -1)  # 範囲外
# order = ("water", "ebian")  # 整数じゃない

match order:
    case ["water", int(number)] if 0 < number < 9:
        order_water(number)
    case ["water", int(_)]:  # 範囲外の場合
        print("水は1〜8杯の範囲で注文してください")
    case ["water", _]:  # 整数以外の場合
        print("水の数は整数で指定してください")

また、ビールのスタイルとしてお店に限られたスタイルのみを指定できる場合、ORパターンでも実現可能ですが、ガードを使うとシンプルに書けます。

ガードでビールのスタイルを制限
STYLES = ("IPA", "Pilsner", "Pale Ale", "Sour")
SIZES = ("Pint", "HalfPint")

order = ("beer", "IPA", "Pint")  # 正しい組み合わせ
# order = ("beer", "Pale Ale", "HalfPint")  # 正しい組み合わせ
# order = ("beer", "Hazy IPA", "Pint")  # スタイルが対象外
# order = ("beer", "Pilsner", "Mass")  # サイズが対象外

match order:
    case ["beer", style, size] if style in STYLES and size in SIZES:
        order_beer(style, size)
    case ["beer", style, size] if style not in STYLES:  # スタイルが対象外
        print(f"スタイルは{STYLES}のみです")
    case ["beer", style, size] if size not in SIZES:  # サイズが対象外
        print(f"サイズは{SIZES}のみです")

まとめ

構造的パターンマッチングについて、前回の記事で紹介しなかったパターンやガードを中心に解説しました。 いろいろなパターンがあり、強力な機能だということが伝わったでしょうか?

Pythonでプログラムを書いているときに、複雑でわかりにくい if 文を見たときには、ぜひパターンマッチングで書き直すことに挑戦してみてください。 if 文の条件で len()isinstance()hasattr()"key" in dct などを見かけたら、パターンマッチングに書き換えるチャンスです!!

参考資料