1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python】match/case文について。シーケンスとマッピングオブジェクト、およびインスタンスのマッチの違い

Posted at

前書き

Pythonには3.10でmatch/case文が追加されました。他の言語でいうところのswitch/case文に相当する文章です。

なんとなく聞き覚えはあったのですが、今までは、「あぁ、if文を使った条件分岐をすっきり書けるswitch/case的なやつね。そういえば今まで無かったね。」くらいの認識でした。

しかしFluent Pythonにおいて登場したmatch/case文は、想像よりずっと強力で複雑でした。そのため、これを機会に学び直そうと思った次第です。

要点は以下の通りになります。

  • リテラルによるマッチングではswitch/caseと同じような動作をするが、フォールスルーは実装されていない。型によるマッチングも利用可能
  • シーケンスオブジェクトのマッチングでは、要素数と値の組み合わせでマッチングする。リテラル同様に型によるマッチングを併用することも可能
  • マッピングオブジェクトのマッチングでは、要素(部分?)
  • シーケンス、およびマッピングオブジェクトのマッチングでは、条件分岐とバインドが同時に行われる

match/case文とは

概要

まずは例のごとく、公式ドキュメントを見てみます。

Structural pattern matching has been added in the form of a match statement and case statements of patterns with associated actions. Patterns consist of sequences, mappings, primitive data types as well as class instances. Pattern matching enables programs to extract information from complex data types, branch on the structure of data, and apply specific actions based on different forms of data.

構造的パターンマッチングは、単一のmatchの記述と、アクションに紐づけられた複数のcaseの記述という形式で追加されました。パターン群は、シーケンスやマッピングや組み込み型、およびクラスのインスタンスから構成されます。パターンマッチングにより、プログラムは複雑なデータ型から情報を抽出したり、データ構造を分岐させたり、異なるデータの形式に応じて特定の動作を適用させたりすることができるようになります。

A match statement takes an expression and compares its value to successive patterns given as one or more case blocks. Specifically, pattern matching operates by:

matchの記述は式を受け取り、その値を後に続く1つ以上のパターンと比較します。具体的には、パターンマッチングは以下のような操作を行います。

1.) using data with type and shape (the subject)
2.) evaluating the subject in the match statement
3.) comparing the subject with each pattern in a case statement from top to bottom until a match is confirmed.
4.) executing the action associated with the pattern of the confirmed match
5.) If an exact match is not confirmed, the last case, a wildcard _, if provided, will be used as the matching case. If an exact match is not confirmed and a wildcard case does not exist, the entire match block is a no-op.

1.) データを型と形と共にサブジェクトとして利用します。
2.) matchの記述内でサブジェクトを評価します。
3.) caseに記述された各パターンに対して、上から順に比較していきます。
4.) マッチしたパターンに紐づけられたアクションを実行します。
5.) 完全に一致するものがなく、ワイルドカードであるアンダーバー(_)を含む記述が最後にあった場合、そこにマッチングしたものとして扱われます。もし完全に一致するものがなく、ワイルドカードも無かった場合は、matchブロック全体は何の操作も行いません。

基本的な文法

以下のようになっています。

match subject:
    case <pattern_1>:
        <action_1>
    case <pattern_2>:
        <action_2>
    case <pattern_3>:
        <action_3>
    case _:
        <action_wildcard>

まずはsubjectを評価して、そこから順番にマッチングの確認を行っていきます。マッチングした場合、記載されたアクションを行います。最後の_はワイルドカードと呼ばれるパターンで、必ずマッチングするようになっています。これが存在せず、かつどれにもマッチングしなかった場合には、match/caseは何も実行することなく終了する、といった流れになります。

似たような動作は、他の言語ではswitch/caseとしてかなり前から実装されていました。Pythonでの導入は2021年頃と比較的最近だったこともあり、他の言語で先に触れたことがある方の方が多かったりするのではないでしょうか。そのためこの文章では、他の言語との違いについても触れていこうと思います。

リテラルに対するマッチング

まずは一番シンプルなタイプからいきましょう。リテラルに対するマッチングです。リテラルとは特定の文字や数値などの表現方法で、数値リテラル(1, 1.0, -2など)や文字列リテラル('apple', "This is a pen")や、ブールリテラル(True, False)、あとはコレクションリテラル([1, 2, 3], ('a', 'b', 'c')など様々な種類が存在しています。いわゆるお決まりの値の表現方法ですね。

ここではその中でも、特に数値リテラルと文字列リテラルについて取り扱っていきます。初めに、公式ドキュメントを少しだけ改造した関数を動かしてみます。

def http_error_print(status):
    match status:
        case 400:
            print("Bad request")
        case 404:
            print("Not found")
        case 418:
            print("I'm a teapot")
        case _:
            print("Something's wrong with the internet")
            
            
http_error_print(400)
http_error_print(418)
http_error_print(3.14)
Bad request
I'm a teapot
Something's wrong with the internet

見た目や動作は他の言語のswitch/case文に似ていますが、全く同じというわけではありません。違いの一つがフォールスルーの実装の有無です。CやJavaなどのswitch/caseではreturnやbreakを記述しない限り、一度マッチした後は延々とマッチングし続けます。

これに対してPythonでは、一度マッチしたらそれで終わり。他のケースの内容が実行されることはありません。つまり、フォールスルーは実装されていません。

もし仮に、他の言語のswitch/caseのフォールスルー動作をPython内で行うとすると、以下のようなorを駆使したものになります。

def http_error_with_fallthrough(status):
    if status == 400:
        print("Bad request")
    if status == 400 or status == 404:
        print("Not found")
    if status == 400 or status == 404 or status == 418:
        print("I'm a teapot")
    print("Something's wrong with the internet.")

http_error_with_fallthrough(400)
print("------------")
http_error_with_fallthrough(404)
Bad request
Not found
I'm a teapot
Something's wrong with the internet.
------------
Not found
I'm a teapot
Something's wrong with the internet.

マッチング前の式評価

地味に面白いのが2.) evaluating the subject in the match statementの部分です。マッチングの確認に入る前に、式を評価してくれるわけですね。

そのため、たとえば以下のようなコードも問題なく動作します。

http_error_print(410 - 10)
http_error_print(int(404.414))
http_error_print(float(418.00))
Bad request
Not found
I'm a teapot

これを応用したものとして、コンストラクタを呼び出し、形に関係なくマッチングするようにするというものがあります。

def http_error_print(status):
    match int(status):
        case 400:
            print("Bad request")
        case 404:
            print("Not found")
        case 418:
            print("I'm a teapot")
        case _:
            print("Something's wrong with the internet")
            
            
http_error_print('404')
http_error_print(404)
Not found
Not found

辞書型でキーの型を限定する動作と同じノリで動かせそうです。

from collections import UserDict


class ErrorMessageDict(UserDict):
    
    def __setitem__(self, key, error_message):
        self.data[int(key)] = error_message
    
    def __contains__(self, key):
        return int(key) in self.data
    
    def __missing__(self, key):
        if isinstance(key, int):
            raise KeyError(key)
        return self[int(key)]

e_dict = ErrorMessageDict({400: "Bad request."})
print(e_dict[400])
e_dict["404"] = "Not found."
print(e_dict[404])
print(e_dict["109"])
Bad request.
Not found.
...
KeyError: 109

caseの制約

matchが比較的自由に操作できる一方で、caseの方で同じようなことはできません。

def http_error_print(status):
    match status:
        case 401 - 1:
            print("Bad request")
        case 404:
            print("Not found")
        case 418:
            print("I'm a teapot")
        case _:
            print("Something's wrong with the internet")
            
            
http_error_print(400)
SyntaxError: imaginary number required in complex literal

caseは自分の後ろに来るものが、リテラルやシーケンス、マッピングなどの決まったパターンであることを想定しています。そのため、401 - 1はa+biのような複素数リテラルであると解釈される→それなのに虚数単位が存在していないというエラーを吐いた、という状態です。

その証拠に、複素数として正しい表記に変更するとエラーを吐かなくなります。もちろんマッチングはしないのですが。

def http_error_print(status):
    match status:
        case 401 - 1j:
            print("Bad request")
        case 404:
            print("Not found")
        case 418:
            print("I'm a teapot")
        case _:
            print("Something's wrong with the internet")
            
            
http_error_print(400)
Something's wrong with the internet

ただし、caseの方でも複数のパターンを一つにまとめることは可能です。

def http_error_print(status):
    match status:
        case 400:
            print("Bad request")
        case 404:
            print("Not found")
        case 418:
            print("I'm a teapot")
        case 401 | 403 | 404:
            print("Not allowed")
        case _:
            print("Something's wrong with the internet")

        
http_error_print(401)
http_error_print(403)
Not allowed
Not allowed

型タイプによるチェック

using data with type and shape (the subject)とあったように、match/caseは型の判別も行ってくれます。

def number_identifier(number):
    match number:
        case str():
            return "This is 'str' number."
        case int():
            return "This is 'int' number."
        case float():
            return "This is 'float' number."
        case complex():
            return "This is 'complex' number."
        case _:
            return "Not Match!"

        
print(number_identifier("10"))
print(number_identifier(10))
print(number_identifier(10.0))
print(number_identifier(1 + 3j))
print(number_identifier([3]))
This is 'str' number.
This is 'int' number.
This is 'float' number.
This is 'complex' number.
Not Match!

これはmatch/case文の強みの一つです。後述するシーケンス・マッピングオブジェクトでの利用にもつながってきます。見た目だけだと一瞬コンストラクタの呼び出しにも見えるのですが、この文脈では型のチェックになります。

ただし、少し書き方を間違えると誤ったマッチングにしてしまうので注意が必要になります。

def number_identifier(number):
    match number:
        case str:
            return "This is 'str' number."

        
print(number_identifier("10"))
print(number_identifier(10))
print(number_identifier(10.0))
print(number_identifier(1 + 3j))
print(number_identifier([3]))
This is 'str' number.
This is 'str' number.
This is 'str' number.
This is 'str' number.
This is 'str' number.

この謎の動作は、strが変数名と見なされてしまったことから発生しています。

def number_identifier(number):
   match number:
       case str:
           return f"'str' is {str}." 
       
print(number_identifier("10"))
print(number_identifier(10))
print(number_identifier(10.0))
print(number_identifier(1 + 3j))
print(number_identifier([3]))
'str' is 10.
'str' is 10.
'str' is 10.0.
'str' is (1+3j).
'str' is [3].

match/caseにおいては、組み込み関数であることよりも、バインドのほうが優先されるため、このような状況が発生します。全てのパターンをキャッチしてしまうため、2つ以上のcaseを記述していると、以下のようになります。

def number_identifier(number):
    match number:
        case str:
            return "This is 'str' number."
        case int:
            return "This is 'int' number."
        case float:
            return "This is 'float' number."
        case complex:
            return "This is 'complex' number."
        case _:
            return "Not Match!"

        
print(number_identifier("10"))
print(number_identifier(10))
print(number_identifier(10.0))
print(number_identifier(1 + 3j))
print(number_identifier([3]))
SyntaxError: name capture 'str' makes remaining patterns unreachable

「strと書いた名前のキャプチャーが、残ったパターン全てにマッチングしないようにしてしまっている」というエラーです。型チェックだけが必要なときはstr(), バインドまで同時に行いたいときはstr(arg)で記述することを忘れないようにしたいものです。

ちなみに、このくらいシンプルな型チェックであれば、match/caseよりは標準ライブラリのデコレータであるsingledispatchを用いる方が適切です。以下に折りたたんでおきます。

functools.singledispatchについて

singleなdispatchという名前の通り、ある関数をジェネリック関数とし、そこから型ごとの関数へ割り当てを行うデコレータです。これで先ほどのnumber_identifierを実装し直すと、以下のようになります。

from functools import singledispatch


@singledispatch
def number_identifier(arg: object) -> str:
    return "Not Match!"

@number_identifier.register
def _(number: str) -> str:
    return "This is 'str' number."

@number_identifier.register
def _(number: int) -> str:
    return "This is 'int' number."

@number_identifier.register
def _(number: float) -> str:
    return "This is 'float' number."

@number_identifier.register
def _(number: complex) -> str:
    return "This is 'complex' number."

print(number_identifier('10'))
print(number_identifier(10))
print(number_identifier(10.0))
print(number_identifier(1 + 3j))
print(number_identifier([3]))
This is 'str' number.
This is 'int' number.
This is 'float' number.
This is 'complex' number.
Not Match!

初めのsingledispatchを記述した部分がジェネリック関数で、match/caseにおいてはワイルドカードに相当します。以降、number_identifier.registerによってデコレートされた関数は、型アノテーションで示された型にマッチし、決まった動作を行うことになります。

singledispatchには様々な利点があります。ジェネリック関数側の動作を変更しなくて良いというのがその一つです。他の方が管理されているコードでも@singledispatchさえ記述してもらっていれば、あとはこちらで好きに動作を定義可能です。

さらに拡張性が高いという点も強みの一つです。singledispatchは記述場所を問いません。どのモジュールのどのような場所で定義していても、問題なく動作してくれます。

これは後述する内容ではありますが、match/caseはあくまでシーケンスオブジェクトやマッピングオブジェクトなど、複雑な構造を持つオブジェクトに対して真価を発揮する構文です。この程度のシンプルな型チェックであれば、ドンピシャで用意されているsingledispatchの方が適切と言えるでしょう。

以上の内容は、Fluent Pythonの第9章:Decorators and Closuresに記載されています。こちらも盛りだくさんの内容になっていますので、そのうち記事でまとめます。

---------------折りたたみここまで---------------

シーケンスオブジェクトに対するマッチング

……と、ここまでの内容であれば、if文でも代用可能です。というか、正確には以降の内容もif文で代用可能なのですが。それにしても、そこまでの差は感じられないです。

def http_error_print(status):
    match status:
        case 400:
            print("Bad request")
        case 404:
            print("Not found")
        case 418:
            print("I'm a teapot")
        case _:
            print("Something's wrong with the internet")
        

def http_error_print_synonym(status):
    if status == 400:
        print("Bad request")
    elif status == 404:
        print("Not found")
    elif status == 418:
        print("I'm a teapot")
    else:
        print("Something's wrong with the internet")
        

http_error_print(418)
http_error_print_synonym(418)
I'm a teapot
I'm a teapot
def number_identifier(number):
    match number:
        case str():
            return "This is 'str' number."
        case int():
            return "This is 'int' number."
        case float():
            return "This is 'float' number."
        case complex():
            return "This is 'complex' number."
        case _:
            return "Not Match!"


def number_identifier_synonym(number):
    if isinstance(number, str):
        return "This is 'str' number."
    elif isinstance(number, int):
        return "This is 'int' number."
    elif isinstance(number, float):
        return "This is 'float' number."
    elif isinstance(number, complex):
        return "This is 'complex' Number."
    else:
        return "Not Match!"


print(number_identifier(3.14))
print(number_identifier_synonym(3.14))
This is 'float' number.
This is 'float' number.

しかし、本当にif文で十分事足りるようであれば、そもそもmatch/case文自体が実装されていないはずです。個人的にはmatch/case文の真髄はシーケンスオブジェクトとマッピングオブジェクトへの適用にあると思います。

ここではシーケンスオブジェクトへの適用から始めていきます。FluentPythonさん曰く、

  • The subject is a sequence and;
  • The subject and the pattern have the same number of item and;
  • Each corresponding item matches, including nested items.
  • サブジェクトがシーケンスで、
  • サブジェクトとパターンが同じ数のアイテムを持っていて、
  • それぞれの対応するアイテムが、ネストされたものも含めてマッチする

といった具合の条件になっています。

とはいえ、文章だけだと分かりづらいので、まずはコードの方を適当に作ってみます。ここではRPGにおけるアビリティ全般の効果処理を考えてみましょう。設定は以下のようなものです。

  • アビリティには物理技と魔法がある
  • 物理技はSP、魔法はMPを消費する
  • いずれのアビリティも対象を指定する必要がある

これを処理できるようにしたのが以下のコードです。

magic = ['Magic', 'Fire', 10, 'Enemy1']
physical = ['Physical', 'Jab', 3, 'Enemy2']
incorrect_magic = ['Magic', 10, 'Enemy1']


def ability_deal(ability_sequence):
    match ability_sequence:
        case ['Magic', spell, consumed_mp, target]:
            print(f"Consumed {consumed_mp} MP.")
            print(f"{spell} to {target}!")
        case ['Physical', move, consumed_sp, target]:
            print(f"Consumed {consumed_sp} SP.")
            print(f"{move} to {target}!")
        case _:
            print("incorrect ability sequence.")
            
ability_deal(magic)
print("--------")
ability_deal(physical)
print("--------")
ability_deal(incorrect_magic)
Consumed 10 MP.
Fire to Enemy1!
--------
Consumed 3 SP.
Jab to Enemy2!
--------
incorrect ability sequence.

まずは第1要素が'Magic'なのか'Physical'なのかで分岐します。その後、指定された個数の要素が含まれていればマッチし、そうでない場合はワイルドカードの方にマッチするという流れです。

さらにここでは、特徴の一つであるバインドがよく現れています。指定された形式でマッチングするだけでなく、同時に各要素を変数にバインドしてくれています。

たとえばmagicに関していえば、'Fire'にspellが、10にconsumed_mpが、'Enemy1'にtargetがそれぞれバインドされています。スムーズに中での利用に入れるのは嬉しいところです。

型マッチとの併用

上の例には欠点があります。それは第1要素が一致して、なおかつシーケンス内の要素数さえ一致していればマッチしてしまうということです。たとえば以下のように、アクション名と消費SPが逆になったシーケンスも受け入れてしまいます。

incorrect_physical = ['Physical', 3, 'Jab', 'Enemy2']
...
ability_deal(incorrect_physical)
Consumed Jab SP.
3 to Enemy2!

ここで活躍するのが、リテラルのところで取り扱った型によるマッチングです。

incorrect_physical = ['Physical', 3, 'Jab', 'Enemy2']


def ability_deal_with_type_check(ability_sequence):
    match ability_sequence:
        case ['Magic', str(spell), int(consumed_mp), str(target)]:
            print(f"Consumed {consumed_mp} MP.")
            print(f"{spell} to {target}!")
        case ['Physical', str(move), int(consumed_sp), str(target)]:
            print(f"Consumed {consumed_sp} SP.")
            print(f"{move} to {target}!")
        case _:
            print("not expected ability.")

            
ability_deal_with_type_check(incorrect_physical)
not expected ability.

moveやspellはstr型で、かつ消費MPとSPは整数型でなければならないというチェックを行います。そのため、順序が逆転したincorrect_physicalはマッチしなくなり、よりコードの安全性が高まります。

ネストされたシーケンスへの対応

リスト内にリストのように、渡されたシーケンスオブジェクトがネストされている状態でもしっかりと対応してくれます。

ここでは例として、先程のコードをもう少し詳細にしてみます。具体的には、アビリティは対象のHP, MP, SPのうちいずれかを変動させるという設定を追加し、それを(種類, 変動値)というタプルにした上で組み込んでみます。

fire = ['Magic', 'Fire', 10, 'Enemy1', ('HP', -25)]
heal = ['Magic', 'Heal', 20, 'Ally1', ('HP', 40)]
kick = ['Physical', 'Kick', 5, 'Enemy1', ('HP', -20)]
meditate = ['Physical', 'Meditate', 0, 'self', ('MP', 10)]


def ability_deal_with_type_check(ability_sequence):
    match ability_sequence:
        case ['Magic', str(spell), int(consumed_mp), str(target), (str(targeted_value), int(delta))]:
            print(f"Consumed {consumed_mp} MP.")
            print(f"{spell} to {target}!")
            print(f"{target}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case ['Physical', str(move), int(consumed_sp), str(target), (str(targeted_value), int(delta))]:
            print(f"Consumed {consumed_sp} SP.")
            print(f"{move} to {target}!")
            print(f"{target}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case _:
            print("not expected ability.")

ability_deal_with_type_check(fire)
print("--------")
ability_deal_with_type_check(heal)
print("--------")
ability_deal_with_type_check(kick)
print("--------")
ability_deal_with_type_check(meditate)
Consumed 10 MP.
Fire to Enemy1!
Enemy1's HP: -25
--------
Consumed 20 MP.
Heal to Ally1!
Ally1's HP: +40
--------
Consumed 5 SP.
Kick to Enemy1!
Enemy1's HP: -20
--------
Consumed 0 SP.
Meditate to self!
self's MP: +10

もちろん型チェックもバッチリ働いていますので、たとえば種類と変動値が逆になっていると反応しなくなります。

incorrect_kick = ['Physical', 'Kick', 5, 'Enemy1', (-20, 'HP')]
.
.
.
ability_deal_with_type_check(incorrect_kick)
not expected ability.

ただ、このネスト構造のマッチング、少しだけ癖があります。以下にシンプルなコードを示してみます。

l1 = [[1, 2]]
l2 = [(1, 2)]
t1 = ((1, 2))
t2 = ([1, 2])

def nest_dealer(sequence):
    match sequence:
        case [[int(num1), int(num2)]]:
            print('pattern1')
        case [(int(num1), int(num2))]:
            print('pattern2')
        case ((int(num1), int(num2))):
            print('pattern3')
        case ([int(num1), int(num2)]):
            print('pattern4')
        case _:
            print("Not Match!")

nest_dealer(l1)
nest_dealer(l2)
nest_dealer(t1)
nest_dealer(t2)
pattern1
pattern1
pattern3
pattern3

この結果だけを見ると、リストならリスト、タプルならタプルを内に含むものが優先的にマッチングしているかのように見えなくもないのですが、

l1 = [[1, 2]]
l2 = [(1, 2)]
t1 = ((1, 2))
t2 = ([1, 2])

def nest_dealer(sequence):
    match sequence:
        case [(int(num1), int(num2))]:
            print('pattern2')
        case [[int(num1), int(num2)]]:
            print('pattern1')
        case ([int(num1), int(num2)]):
            print('pattern4')
        case ((int(num1), int(num2))):
            print('pattern3')
        case _:
            print("Not Match!")

nest_dealer(l1)
nest_dealer(l2)
nest_dealer(t1)
nest_dealer(t2)
pattern2
pattern2
pattern4
pattern4

実際にはシンプルに区別されていないだけで、先に来る方にマッチングしているだけでした。match/caseは、内部にネストされたものの型までは問わないというのは覚えておきたいところです。

さらにここで思いつくのが「では、リテラルではなくlist()のような形で呼び出すとどうなるか?」というお話です。結論から言うとTypeErrorで怒られてしまいます。

l1 = [[1, 2]]
l2 = [(1, 2)]
t1 = ((1, 2))
t2 = ([1, 2])

def nest_dealer(sequence):
    match sequence:
        case [list(int(num1), int(num2))]:
            print('pattern1')
        case [tuple(int(num1), int(num2))]:
            print('pattern2')
        case (tuple(int(num1), int(num2))):
            print('pattern3')
        case (list(int(num1), int(num2))):
            print('pattern4')
        case _:
            print("Not Match!")

nest_dealer(l1)
nest_dealer(l2)
nest_dealer(t1)
nest_dealer(t2)
TypeError: list() accepts 1 positional sub-pattern (2 given)

「list()は1つの位置的なサブパターンのみ受け入れる(2つが与えられている)」というエラーメッセージです。

このように、一見するとintやfloatのように型を使ったマッチングが出来そうな気もするのですが、やはりネスト構造では出来ないことがわかります。

可変長形式の受け取り

アビリティの対象は必ずしも単体とは限りません。家庭用RPGゲームの祖である(諸説あり)ウィザードリィでも敵全体やグループを対象とした魔法は実装されていました。そこで、任意の人数に対してアビリティを適用できるようにしてみたいと思います。このような時に役に立つのが、可変長形式の受け取りです。

# fixing from -10 to 10

fire = ['Magic', 'Fire', 10, 'Enemy1', ('HP', -25)]
hurricane_kick = ['Physical', 'Kick', 10, 'Enemy1', 'Enemy2', 'Enemy3', ('HP', -20)]


def ability_deal_with_type_check(ability_sequence):
    match ability_sequence:
        case ['Magic', str(spell), int(consumed_mp), *targets, (str(targeted_value), int(delta))]:
            print(f"Consumed {consumed_mp} MP.")
            print(f"{spell} to {targets}!")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case ['Physical', str(move), int(consumed_sp), *targets, (str(targeted_value), int(delta))]:
            print(f"Consumed {consumed_sp} SP.")
            print(f"{move} to {targets}!")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case _:
            print("not expected ability.")
            

ability_deal_with_type_check(fire)
print('--------')
ability_deal_with_type_check(hurricane_kick)
Consumed 10 MP.
Fire to ['Enemy1']!
['Enemy1']'s HP: -25
--------
Consumed 10 SP.
Kick to ['Enemy1', 'Enemy2', 'Enemy3']!
['Enemy1', 'Enemy2', 'Enemy3']'s HP: -20

*targetsで消費SP/MPと変動値の間にある対象を全て補足しています。ある程度の変更を柔軟に受け入れてくれるのは助かりますね。

ちなみに、同じような処理はネスト構造でも実現可能です。

fire = ['Magic', 'Fire', 10, ['Enemy1'], ('HP', -25)]
hurricane_kick = ['Physical', 'Kick', 10, ['Enemy1', 'Enemy2', 'Enemy3'], ('HP', -20)]


def ability_deal_with_type_check(ability_sequence):
    match ability_sequence:
        case ['Magic', str(spell), int(consumed_mp), list(targets), (str(targeted_value), int(delta))]:
            print(f"Consumed {consumed_mp} MP.")
            print(f"{spell} to {targets}!")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case ['Physical', str(move), int(consumed_sp), list(targets), (str(targeted_value), int(delta))]:
            print(f"Consumed {consumed_sp} SP.")
            print(f"{move} to {targets}!")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case _:
            print("not expected ability.")
            

ability_deal_with_type_check(fire)
print('--------')
ability_deal_with_type_check(hurricane_kick)
Consumed 10 MP.
Fire to ['Enemy1']!
['Enemy1']'s HP: -25
--------
Consumed 10 SP.
Kick to ['Enemy1', 'Enemy2', 'Enemy3']!
['Enemy1', 'Enemy2', 'Enemy3']'s HP: -20

リストで渡すことがはっきりしている場合はこちらを利用すると良いでしょう。

シーケンスオブジェクトに対するマッチングのまとめ

シーケンスオブジェクトは一連の処理の前準備や結果の保存に利用されることがありますが、その中では異なる型のオブジェクトが含まれていることがあります。このような状況で、条件分岐とバインドを同時にこなしてくれるmatch/caseは強力な構文といえるでしょう。

一方で、ネスト構造には少し注意が必要です。というのも途中で示した通り、内側の型までは判別してくれないからです。これを忘れていると、例えば本来は区別されないパターン同士を区別されると勘違いしてしまい、予期せぬ結果を招く可能性があります。

マッピングオブジェクトに対するマッチング

次はマッピングオブジェクトに対するマッチングです。マッピングオブジェクトとはキーと値のペアを保持しているオブジェクトの総称です。実装の観点から言うのであれば、collections.abc.Mappingを実際に継承、あるいは仮想的に継承しているオブジェクトであるといえるでしょう。

dictによる基本的なマッチング

マッピングオブジェクトには、defaultdictやOrderedDict、CounterやChainMapなど様々なオブジェクトがありますが、ここでは最も一般的であるdictを元に確認していきましょう。例として、先程のアクション処理の関数を書き換えてみます。

fire = dict(type='Magic', name='Fire', consumed_mp=10, targets=['Enemy1'], targeted_value_and_delta=('HP', -25))
hurricane_kick = dict(type='Physical', name='Kick', consumed_sp=10, targets=['Enemy1', 'Enemy2', 'Enemy3'], targeted_value_and_delta=('HP', -20))

def ability_deal(ability_mapping):
    match ability_mapping:
        case {'type': 'Magic', 'name': spell_name, 'consumed_mp': consumed_mp, 'targets': targets, 'targeted_value_and_delta': (targeted_value, delta)}:
            print(f"Consumed {consumed_mp} MP.")
            print(f"{spell_name} to {targets}!")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case {'type': 'Physical', 'name': move_name, 'consumed_sp': consumed_sp, 'targets': targets, 'targeted_value_and_delta': (targeted_value, delta)}:
            print(f"Consumed {consumed_sp} SP.")
            print(f"{move_name} to {targets}.")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case _:
            print("Not match!")

ability_deal(fire)
print('---------------')
ability_deal(hurricane_kick)
Consumed 10 MP.
Fire to ['Enemy1']!
['Enemy1']'s HP: -25
---------------
Consumed 10 SP.
Kick to ['Enemy1', 'Enemy2', 'Enemy3'].
['Enemy1', 'Enemy2', 'Enemy3']'s HP: -20

マッピングオブジェクトらしく、key: valueのペアでマッチングが行われています。また、シーケンスオブジェクト同様、変数のバインドも同時に行われています。

さらに、型チェックを併用することも可能です。

incorrect_fire1 = dict(type='Magic', name='Fire', consumed_mp=10, targets=['Enemy1'], targeted_value_and_delta=(-25, 'HP'))
incorrect_fire2 = dict(type='Magic', name='Fire', consumed_mp=10, targets=('Enemy1'), targeted_value_and_delta=('HP', -25))
incorrect_hurricane_kick1 = dict(type='Physical', name='Kick', consumed_sp=10.0, targets=['Enemy1', 'Enemy2', 'Enemy3'], targeted_value_and_delta=('HP', -20))

def ability_deal_with_type_check(ability_mapping):
    match ability_mapping:
        case {'type': 'Magic', 'name': str(spell_name), 'consumed_mp': int(consumed_mp), 'targets': list(targets), 'targeted_value_and_delta': (str(targeted_value), int(delta))}:
            print(f"Consumed {consumed_mp} MP.")
            print(f"{spell_name} to {targets}!")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case {'type': 'Physical', 'name': str(move_name), 'consumed_sp': int(consumed_sp), 'targets': list(targets), 'targeted_value_and_delta': (str(targeted_value), int(delta))}:
            print(f"Consumed {consumed_sp} SP.")
            print(f"{move_name} to {targets}.")
            print(f"{targets}'s {targeted_value}: {'+' if delta > 0 else ''}{delta}")
        case _:
            print("Not match!")


ability_deal_with_type_check(incorrect_fire1)
print('---------------')
ability_deal_with_type_check(incorrect_fire2)
print('---------------')
ability_deal_with_type_check(incorrect_hurricane_kick1)
Not match!
---------------
Not match!
---------------
Not match!

少し分かりづらいですが、上から「対象とその変化値が逆になっている」「ターゲットの指定がリストではなくタプルになっている」「消費SPがintではなくfloatになっている」という点でそれぞれマッチしないようになっています。

部分マッチング

シーケンスオブジェクトにおけるマッチングとマッピングオブジェクトにおけるマッチングには、key: valueのペアによるマッチングであることの他に、もう一つ大きな違いがあります。それがシーケンスオブジェクトは完全マッチングであるのに対し、マッピングオブジェクトは部分マッチングであるという点です。

シンプルな具体例を用いて確認してみましょう。

l1 = [1, 2, 'a']
l2 = [1, 2, 'a', 'b']
l3 = [1, 2, 'a', 'b', 0.1]

def checker(list_to_check):
    match list_to_check:
        case [arg1, arg2, arg3]:
            print(arg1, arg2, arg3)
        case [arg1, arg2, arg3, arg4]:
            print(arg1, arg2, arg3, arg4)        
        case _:
            print("Not Match")

checker(l1)
checker(l2)
checker(l3)
1 2 a
1 2 a b
Not Match

要素数が完全に一致していないとマッチングしません。これは型においても同様です。

# complete mathing

l1 = [1, 2, 'a']
l2 = [1, 2, 3]

def checker(list_to_check):
    match list_to_check:
        case [int(arg1), int(arg2), str(arg3)]:
            print(arg1, arg2, arg3)        
        case _:
            print("Not Match")

checker(l1)
checker(l2)
1 2 a
Not Match

一方で、マッピングオブジェクトではこちらのように動作します。

d1 = {'number1': 1, 'number2': 2, 'character1': 'a'}
d2 = {'number1': 1, 'number2': 2, 'character1': 'a', 'character2': 'b'}

def checker(dict_to_check):
    match dict_to_check:
        case {'number1': num1, 'number2': num2, 'character1': char1}:
            print(num1, num2, char1)
        case _:
            print("Not Match")

checker(d1)
checker(d2)
1 2 a
1 2 a

d2側には追加でcharacter2のキーが入っていますが、問題なくマッチングしています。

ちなみに、残りの部分もキャッチしたいときはアンパックを利用可能です。

d = {'number1': 1, 'number2': 2, 'character1': 'a', 'character2': 'b', 'foo': 'foo', 'bar': 'bar'}


def checker(dict_to_check):
    match dict_to_check:
        case {'number1': num1, 'number2': num2, 'character1': char1, **others}:
            print(num1, num2, char1)
            print(others)
        case _:
            print("Not Match")

checker(d)
1 2 a
{'character2': 'b', 'foo': 'foo', 'bar': 'bar'}

なお、同じようなことはシーケンスオブジェクトでも可能です。これを用いると部分マッチングを行わせることが可能となります。

l1 = [1, 2, 3]
l2 = [1, 2, 3, 'a']

def checker(list_to_check):
    match list_to_check:
        case [1, num2, num3, *others]:
            print(num2, num3)
            print(others)
        case _:
            print("Not match")

checker(l1)
checker(l2)
2 3
[]
2 3
['a']

マッピングオブジェクトによるマッチングまとめ

マッピングオブジェクトではkey: valueのセットでマッチします。valueの方でリテラルなどを指定した場合は単純なマッチング条件として扱われて、変数名を指定した場合はバインドを行ってくれます。さらに型マッチングを行うことも可能ですので、より細かなマッチング条件を制御することが可能です。

シーケンスオブジェクトによるマッチングとの大きな違いは、基本が部分マッチングであるということです。パターンの中で言及されていないものは、含まれていても無視されます。

自作クラスのオブジェクトによるマッチング

最後は自作したクラスのオブジェクトによるマッチングです。ここでは例として、ゲーム中のアイテム利用を想定してみます。アイテムには名前とグレード、利用数があるという設定です。名前でどの効果を持つものかを判別し、グレードと利用数でステータスの変化量が決定することにします。

class Item:
    def __init__(self, item_name, grade, usage):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage

item1 = Item('Healing Potion', 1, 1)
item2 = Item('Mana Potion', 2, 4)
item3 = Item('Poison Potion', 3, 2)

def item_use_checker(item):
    match item:
        case Item(item_name='Healing Potion', grade=grade, usage=usage):
            healing_amount = 20 * grade * usage
            print(f"Regain {healing_amount} HP.")
        case Item(item_name='Mana Potion', grade=grade, usage=usage):
            healing_amount = 5 * grade * usage
            print(f"Regain {healing_amount} MP.")        
        case _:
            print("Not Match!")

item_use_checker(item1)
item_use_checker(item2)
item_use_checker(item3)

型チェックも併用することが出来ます……多分。

class Item:
    def __init__(self, item_name, grade, usage):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage

item1 = Item('Healing Potion', 1, 1)
item2 = Item('Mana Potion', 2, 4)
item3 = Item('Mana Potion', 2, 0.5)
item4 = Item('Mana Potion', 2, '3')
item5 = Item('Healing Potion', 1, 'abcd')

def item_use_checker(item):
    match item:
        case Item(item_name='Healing Potion', grade=int(grade), usage=int(usage)):
            healing_amount = 20 * grade * usage
            print(f"Regain {healing_amount} HP.")
        case Item(item_name='Mana Potion', grade=int(grade), usage=int(usage)):
            healing_amount = 5 * grade * usage
            print(f"Regain {healing_amount} MP.")        
        case _:
            print("Not Match!")

item_use_checker(item1)
item_use_checker(item2)
item_use_checker(item3)
item_use_checker(item4)
item_use_checker(item5)
Regain 20 HP.
Regain 40 MP.
Not Match!
Not Match!
Not Match!

アイテムの利用数は整数になっているのが自然なので、0.5や'3'などの値を弾いています。他にも'abcd'という明らかに型変換が行えないものも問題なく弾いています。そのため、クラスマッチングパターンにおけるusage=int(usage)のような表記は、型変換ではなく型チェックが行われていると考えるのが妥当だと思います。

"思う"だの、"多分"だの言っているのには理由がありまして、いつものようにchatgptさんにコードや説明の的確さを聞いてきたところ、何故か頑なに後述するガードパターンによるisinstanceを用いたチェックを勧められたのです。「型変換には確実性がない」という旨のことを言われてしまいました。

「いや、'3'だけでなく、'abcd'が問題なく弾かれてるのだから、ガードパターンによる記述は過剰だよね?」と聞いても、めちゃめちゃ頑なでした。もしかすると、自分の考え方が間違っているのかもしれません。なので、もし誤りを発見された方はご報告お願い致します。

部分マッチング

マッピングオブジェクトによるマッチングと同じく、部分マッチングとなっているのも一つの特徴です。つまり、case部分に記述していない属性は無視してマッチングが行われます。ここでは後でアイテムの売買処理を追加することを想定して、基準価格を表すbase_priceを追加してみましょう。

class Item:
    def __init__(self, item_name, grade, usage, base_price):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage
        self.base_price = base_price

item1 = Item('Healing Potion', 1, 1, 100)
item2 = Item('Mana Potion', 2, 3, 600)

def item_use_checker(item):
    match item:
        case Item(item_name='Healing Potion', grade=int(grade), usage=int(usage)):
            healing_amount = 20 * grade * usage
            print(f"Regain {healing_amount} HP.")
        case Item(item_name='Mana Potion', grade=int(grade), usage=int(usage)):
            healing_amount = 5 * grade * usage
            print(f"Regain {healing_amount} MP.")        
        case _:
            print("Not Match!")

item_use_checker(item1)
item_use_checker(item2)
Regain 20 HP.
Regain 30 MP.

見ての通り、priceに関する条件はcaseに含まれていませんが、問題なくマッチングしています。考えてみるとインスタンス変数は(__slots__が利用されていない限り)辞書で格納されているため、マッピングオブジェクトのようなマッチングを行うのは自然と言えるでしょう。

ガードパターン

先程のアイテム利用の例ですが、こちらには少し問題となりうる部分が含まれています。それは型のチェックはやっているものの、値のチェックは行われていないという点です。利用数が0のときはそもそも呼び出す必要がないですし、マイナスの値に至ってはよくわからないことになりそうな予感がします。またグレードについても、同様に自然数である方が自然だと言えます。

ということで、これを防止するのがガードパターンです。感覚としては内包表記の条件部と似ている気がします。case部分の後ろにifブロックを追加すればOKです。

class Item:
    def __init__(self, item_name, grade, usage, base_price):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage
        self.base_price = base_price

item1 = Item('Healing Potion', 1, 1, 100)
item2 = Item('Mana Potion', 2, -2, 400)
item3 = Item('Healing Potion', -1, 3, 300)

def item_use_checker(item):
    match item:
        case Item(item_name='Healing Potion', grade=int(grade), usage=int(usage), base_price=int(base_price)) if (usage > 0) and (grade > 0):
            healing_amount = 20 * grade * usage
            print(f"Regain {healing_amount} HP.")
            print(f"base_price: {base_price}")
        case Item(item_name='Mana Potion', grade=int(grade), usage=int(usage), base_price=int(base_price)) if (usage > 0) and (grade > 0):
            healing_amount = 5 * grade * usage
            print(f"Regain {healing_amount} MP.")
            prin(f"base_price: {base_price}")
        case _:
            print("Not Match!")

item_use_checker(item1)
item_use_checker(item2)
item_use_checker(item3)
Regain 20 HP.
base_price: 100
Not Match!
Not Match!

ちなみに、同じ動作をif文で実装しようとすると、以下のようになります。

class Item:
    def __init__(self, item_name, grade, usage, base_price):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage
        self.base_price = base_price


item1 = Item('Healing Potion', 1, 1, 100)
item2 = Item('Mana Potion', 2, -2, 400)
item3 = Item('Healing Potion', -1, 3, 600)


def item_use_checker(item):
    item_name, grade, usage, base_price = item.item_name, item.grade, item.usage, item.base_price
    if not(isinstance(grade, int) and isinstance(usage, int)):
        print("Not Match!")
        return
    if (grade <= 0) or (usage <= 0):
        print("Not Match!")
        return
    if item_name == 'Healing Potion':
        healing_amount = 20 * grade * usage
        print(f"Regain {healing_amount} HP.")
        print(f"base_price: {base_price}")
    elif item_name == 'Mana Potion':
        healing_amount = 5 * grade * usage
        print(f"Regain {healing_amount} MP.")
        print(f"base_price: {base_price}")

item_use_checker(item1)
item_use_checker(item2)
item_use_checker(item3)
Regain 20 HP.
base_price: 100
Not Match!
Not Match!

条件に合わないものを早期リターンで弾いていることもあり、意外と読めないことも無いのですが、若干目が滑ります。

ポジションによるマッチング

ここまで触れてきませんが、実はデフォルトの状態ではポジションによるマッチングは行うことが出来ません。

class Item:
    def __init__(self, item_name, grade, usage, base_price):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage
        self.base_price = base_price

item1 = Item('Healing Potion', 1, 1, 100)

def item_use_checker(item):
    match item:
        case Item('Healing Potion', int(grade), int(usage), int(base_price)) if (usage > 0) and (grade > 0):
            healing_amount = 20 * grade * usage
            print(f"Regain {healing_amount} HP.")
            print(f"base_price: {base_price}")
        case Item('Mana Potion', int(grade), int(usage), int(base_price)) if (usage > 0) and (grade > 0):
            healing_amount = 5 * grade * usage
            print(f"Regain {healing_amount} MP.")
            prin(f"base_price: {base_price}")
        case _:
            print("Not Match!")

item_use_checker(item1)
...
case Item('Healing Potion', int(grade), int(usage), int(base_price)) if (usage > 0) and (grade > 0):
...
TypeError: Item() accepts 0 positional sub-patterns (4 given)

これを解決するためには、クラスに特殊属性を定義する必要があります。それが__match_args__です。

class Item:
    __match_args__ = ('item_name', 'grade', 'usage', 'base_price')
    
    def __init__(self, item_name, grade, usage, base_price):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage
        self.base_price = base_price

タプルで属性名を与えると、その順番通りに与えられた値を解釈してくれるようになります。

item1 = Item('Healing Potion', 1, 1, 100)

def item_use_checker(item):
    match item:
        case Item('Healing Potion', int(grade), int(usage), int(base_price)) if (usage > 0) and (grade > 0):
            healing_amount = 20 * grade * usage
            print(f"Regain {healing_amount} HP.")
            print(f"base_price: {base_price}")
        case Item('Mana Potion', int(grade), int(usage), int(base_price)) if (usage > 0) and (grade > 0):
            healing_amount = 5 * grade * usage
            print(f"Regain {healing_amount} MP.")
            prin(f"base_price: {base_price}")
        case _:
            print("Not Match!")

item_use_checker(item1)
Regain 20 HP.
base_price: 100

ただ、キーワードによる指定のときにあった明確性が無くなっているので、属性が多い時にマッチング対象をむやみに増やすのは少し危ないかもしれません。全ての属性を指定しなければならないということはないので、主要な部分だけに絞ってマッチングを設定するのが良いでしょう。

今回の例であれば、アイテムの利用に焦点を当てるのであれば、base_priceはいったん脇に置いておくという選択肢が出てきます。

class Item:
    # __match_args__ = ('item_name', 'grade', 'usage', 'base_price')
    __match_args__ = ('item_name', 'grade', 'usage')
    
    def __init__(self, item_name, grade, usage, base_price):
        self.item_name = item_name
        self.grade = grade
        self.usage = usage
        self.base_price = base_price

item1 = Item('Healing Potion', 1, 1, 100)

def item_use_checker(item):
    match item:
        case Item('Healing Potion', int(grade), int(usage)) if (usage > 0) and (grade > 0):
            healing_amount = 20 * grade * usage
            print(f"Regain {healing_amount} HP.")
            # print(f"base_price: {base_price}")
        case Item('Mana Potion', int(grade), int(usage)) if (usage > 0) and (grade > 0):
            healing_amount = 5 * grade * usage
            print(f"Regain {healing_amount} MP.")
            # print(f"base_price: {base_price}")
        case _:
            print("Not Match!")

item_use_checker(item1)
Regain 20 HP.

上で確認した通り、クラスオブジェクトによるマッチングは部分マッチングであるため、問題なく動作しています。

少し注意したいのが、この特殊属性はインスタンス全体に共通であるという点です。例えば、アイテム利用と売却を同じItemクラスで処理しようとすると、利用と売却の両方を共通の__match_args__で扱わなければならなくなります。

両方の処理に等しく全てのステータスが重要であればよいのですが、そうでない場合はキーワードによるマッチングに切り替えるか、コード全体の構造を切り替える必要が出てきそうです。また、将来的に拡張が行われる可能性が高い場合にも、__match_args__の設定は見送るべき可能性が高いです。

クラスオブジェクトによるマッチングまとめ

自作したクラスオブジェクトでmatch/caseを行うときは、部分マッチングかつキーワードによるマッチングになり、型チェックの併用やバインドも可能です。ポジションによるマッチングを行うためには、別途__match_args__を設定する必要があります。これは全インスタンス共通の特殊属性となるので、異なる処理を必要する場合は何かしらの工夫が必要なるでしょう。

考えうる応用例

ここまでマッチングの動作を確認してきました。最後に、これらを踏まえたある程度具体的な応用例を考えてみます。味方と敵が交互に動くタイプのシンプルなRPGで、アクションやアイテムの利用を行ってみます。

from collections import defaultdict

class Character:
    def __init__(self, name, hp, sp, mp, skills=None, inventory=None):
        self.name = name
        self.hp = hp
        self.sp = sp
        self.mp = mp
        self.skills = skills or {}
        self.inventory = inventory or defaultdict(Item)

    def __str__(self):
        return f"{self.name}({self.hp}HP, {self.sp}SP, {self.mp}MP)"

    def action(self, action_name, targets):
        if action_name in self.skills:
            self.skills[action_name].execute(self, targets)
        else:
            print(f"No such action: {action_name}")

    def use_item(self, item_name, targets):
        if item_name in self.inventory:
            self.inventory[item_name].use(targets)
        else:
            print(f"No such item in inventory: {item_name}")


class Action:
    def __init__(self, action_type, action_name, cost, effect):
        self.action_type = action_type
        self.action_name = action_name
        self.cost = cost
        self.effect = effect

    def execute(self, character, targets):
        match (self.action_type, self.action_name, self.cost):
            case ('Physical', str(action_name), int(cost)):
                if character.sp < cost:
                    print("Not enough SP!")
                    return
                character.sp -= cost
                print(f"{character.name} consumed {cost} SP.")
                print(f"{character.name} executed {action_name}!")

                for target in targets:
                    self.apply_effect(target, self.effect[0], self.effect[1])

            case ('Magic', str(action_name), int(cost)):
                if character.mp < cost:
                    print("Not enough MP!")
                    return
                character.mp -= cost
                print(f"{character.name} consumed {cost} MP.")
                print(f"{character.name} executed {action_name}!")

                for target in targets:  # 複数のターゲットに効果を適用
                    self.apply_effect(target, self.effect[0], self.effect[1])

            case _:
                print("Unexpected ability")

    def apply_effect(self, target, target_value, delta):
        match target_value:
            case 'HP':
                target.hp += delta
            case 'MP':
                target.mp += delta
            case 'SP':
                target.sp += delta
            case _:
                print('Invalid target value.')
                return
        print(f"{target.name}'s {target_value} {'+' if delta > 0 else ''}{delta}")

class Item:
    def __init__(self, item_name, effect, stock):
        self.item_name = item_name
        self.effect = effect  # effect should be a tuple of (target_value, delta)
        self.stock = stock

    def use(self, targets):
        match self.item_name:
            case 'Healing Portion':
                if self.stock >= len(targets):
                    for target in targets:
                        self.apply_effect(target, self.effect[0], self.effect[1])
                    self.stock -= len(targets)
                else:
                    print(f"Not enough items. Only {self.stock} {self.item_name} remaining.")
            case _:
                print(f"Unknown item: {self.item_name}")

    def apply_effect(self, character, target_value, effect_value):
        print(f"{self.item_name} is used.")
        match target_value:
            case 'HP':
                character.hp += effect_value
                print(f"{character.name}'s HP increased by {effect_value}. New HP: {character.hp}")
            case 'MP':
                character.mp += effect_value
                print(f"{character.name}'s MP increased by {effect_value}. New MP: {character.mp}")
            case 'SP':
                character.sp += effect_value
                print(f"{character.name}'s SP increased by {effect_value}. New SP: {character.sp}")
            case _:
                print(f"Unknown target value: {target_value}")


charge = Action(action_type='Physical', action_name='Charge', cost=5, effect=('HP', -5))
e1 = Character('Goblin', hp=20, sp=10, mp=0, skills={'charge': charge})
mega_swing = Action(action_type='Physical', action_name='Mega Swing', cost=10, effect=('HP', -10))
e2 = Character('Orc', hp=40, sp=20, mp=0, skills={'mega_swing': mega_swing})
fire_ball = Action(action_type='Magic', action_name='Fire Ball', cost=5, effect=('HP', -10))
p = Character('Player', hp=100, sp=40, mp=50, skills={'fire_ball': fire_ball})


print('-----player turn-----')
p.action('fire_ball', [e1, e2])
print(e1)
print(e2)


print('-----enemy turn-----')
e1.action('charge', [p])
e2.action('mega_swing', [p])
print(p)


print('-----player turn-----')
healing_potion = Item('Healing Portion', ('HP', 10), 5)
p.inventory['Healing Portion'] = healing_potion
p.use_item('Healing Portion', [p])

print('-----debug-----')
p.action('punch', [e1])
e1.action('charge', [p])
e1.action('charge', [p])
invalid_action = Action(action_type='physical', action_name='false mega swing', cost=10, effect=(-10, 'HP'))
e2.skills['false mega swing'] = invalid_action
e2.action('false mega swing', [p])
出力
-----player turn-----
Player consumed 5 MP.
Player executed Fire Ball!
Goblin's HP -10
Orc's HP -10
Goblin(10HP, 10SP, 0MP)
Orc(30HP, 20SP, 0MP)
-----enemy turn-----
Goblin consumed 5 SP.
Goblin executed Charge!
Player's HP -5
Orc consumed 10 SP.
Orc executed Mega Swing!
Player's HP -10
Player(85HP, 40SP, 45MP)
-----player turn-----
Healing Portion is used.
Player's HP increased by 10. New HP: 95
-----debug-----
No such action: punch
Goblin consumed 5 SP.
Goblin executed Charge!
Player's HP -5
Not enough SP!
Unexpected ability

全体的にそこそこ複雑な作りになっていますが、match/caseで分岐の可読性をキープしつつ、型チェックで堅固なシステムを目指した形です。

まとめ

初めに述べた通り、Pythonのmatch/caseはとてもパワフルな文法です。一方で多機能である事に由来するややこしさも持ち合わせています。具体的には、単純な条件分岐だけでなく、変数へのバインドや構造分解の役割も担っていることが原因です。

ただ、その分しっかりと使いこなした時の威力は凄まじいものがあり、特に複雑な構造に対処する力は抜群です。フォールスルー(matchしたものへの継続的な処理)やgo to文など、他の言語にあってPythonにない機能ももちろんあります。ですが、それらを犠牲にしても複雑な構造への対応力を高めている、と私は感じました。

今回は以上になります。何か補足等あればコメントへよろしくお願いします。

参考サイト

公式ドキュメント: PEP 634: Structural Pattern Matching

参考書籍

  • Luciano Ramalho "Fluent Python (English Edition) Kindle版" O'Reilly Media 2022/3/31 p.39~p.43, p.81~p.83,
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?