0
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 文で謎のバグに遭遇したので共有します。

コードレビューで「マジックナンバーやめて定数使おうぜ」って言われて素直に書き換えたら、まさかの動かなくなって焦った😇

TL;DR

  • match case で定数を直接パターンに書くとキャプチャパターン扱いになって意図しない動作をする
  • 定数と比較したいなら if 文使うか、ガード (case x if x == 定数:) を使うか、Enum にまとめよう
  • リテラル(数字そのもの)やドット記法(Enum.VALUE)は値パターンとして機能する

何が起きたか

こんなコードがあったとします。ビットフラグで権限管理してる感じのやつです。

from enum import IntEnum

class Permission(IntEnum):
    READ = 1
    WRITE = 2
    DELETE = 4

# ビットフラグで複数指定
READ_AND_DELETE = Permission.READ | Permission.DELETE  # 5
WRITE_AND_DELETE = Permission.WRITE | Permission.DELETE  # 6

def check_permission(permission_code: int):
    match permission_code:
        case 5:  # READ + DELETE
            return "読み取りと削除が許可されています"
        case 6:  # WRITE + DELETE
            return "書き込みと削除が許可されています"
        case Permission.READ:
            return "読み取りのみ許可"
        case Permission.WRITE:
            return "書き込みのみ許可"
        case _:
            return "権限なし"

レビューで「5とか6とかマジックナンバーやめて」って言われたので、こう書き換えました。

def check_permission(permission_code: int):
    match permission_code:
        case READ_AND_DELETE:  # 5 の代わり
            return "読み取りと削除が許可されています"
        case WRITE_AND_DELETE:  # 6 の代わり
            return "書き込みと削除が許可されています"
        case Permission.READ:
            return "読み取りのみ許可"
        case _:
            return "権限なし"

一見よさそうですよね?

これが動きません。

何が問題だったのか

Python の match caseパターンマッチングなので、case の後ろに書いたものの扱いが特殊です。

値パターン vs キャプチャパターン

PEP 634(構造的パターンマッチング)の仕様により、Python は以下のように判断します

書き方 扱い 意味
case 5: 値パターン(リテラル) 「値が5のとき」にマッチ
case "hello": 値パターン(リテラル) 「値が"hello"のとき」にマッチ
case Enum.VALUE: 値パターン(属性アクセス) 「Enum.VALUE の値」にマッチ
case SOME_CONSTANT: キャプチャパターン 「どんな値でも受け取ってSOME_CONSTANTに束縛」

つまり、case READ_AND_DELETE: と書くと

  • 「READ_AND_DELETEという定数の値(5)と比較する」のではなく
  • 「どんな値でも受け取って、READ_AND_DELETEという変数名に束縛する

という意味になってしまうんです。

これは、単純な名前(ドットを含まない識別子)は常にキャプチャパターンとして扱われるという PEP 634 の仕様によるものです。

実際の動作

def check_permission(permission_code: int):
    match permission_code:
        case READ_AND_DELETE:  # ← ここで何でもマッチしてしまう
            return "読み取りと削除が許可されています"
        case WRITE_AND_DELETE:  # ← ここには絶対到達しない
            return "書き込みと削除が許可されています"

# どんな値を渡しても最初のケースにマッチする
print(check_permission(1))   # "読み取りと削除が許可されています"
print(check_permission(999)) # "読み取りと削除が許可されています"

全部最初のケースで拾われます。完全にバグってますね。

正しい書き方

方法1: 素直に if 文を使う(推奨)

一番シンプルで分かりやすいのはこれ。

def check_permission(permission_code: int):
    if permission_code == READ_AND_DELETE:
        return "読み取りと削除が許可されています"
    elif permission_code == WRITE_AND_DELETE:
        return "書き込みと削除が許可されています"
    
    # 残りは match で分岐
    match permission_code:
        case Permission.READ:
            return "読み取りのみ許可"
        case Permission.WRITE:
            return "書き込みのみ許可"
        case _:
            return "権限なし"

複雑な分岐が必要ないなら、全部 if-elif でもいいと思います。

def check_permission(permission_code: int):
    if permission_code == READ_AND_DELETE:
        return "読み取りと削除が許可されています"
    elif permission_code == WRITE_AND_DELETE:
        return "書き込みと削除が許可されています"
    elif permission_code == Permission.READ:
        return "読み取りのみ許可"
    elif permission_code == Permission.WRITE:
        return "書き込みのみ許可"
    else:
        return "権限なし"

方法2: ガード条件を使う

どうしても match を使いたい場合は、ガードif による条件)をつけます。

def check_permission(permission_code: int):
    match permission_code:
        case x if x == READ_AND_DELETE:  # ガードで等値判定
            return "読み取りと削除が許可されています"
        case x if x == WRITE_AND_DELETE:
            return "書き込みと削除が許可されています"
        case Permission.READ:
            return "読み取りのみ許可"
        case Permission.WRITE:
            return "書き込みのみ許可"
        case _:
            return "権限なし"

case x if x == READ_AND_DELETE: の部分がガード条件です。

  1. まず x に値を束縛(キャプチャ)
  2. その後 if 条件で実際の判定

という2段階になっています。

方法3: Enum にまとめる

複数の定数を管理する場合、Enum にまとめるのもいい選択肢です。

from enum import IntEnum

class Permission(IntEnum):
    READ = 1
    WRITE = 2
    DELETE = 4

class CombinedPermission(IntEnum):
    READ_AND_DELETE = 5
    WRITE_AND_DELETE = 6

def check_permission(permission_code: int):
    match permission_code:
        case CombinedPermission.READ_AND_DELETE:  # Enum ならOK
            return "読み取りと削除が許可されています"
        case CombinedPermission.WRITE_AND_DELETE:
            return "書き込みと削除が許可されています"
        case Permission.READ:
            return "読み取りのみ許可"
        case Permission.WRITE:
            return "書き込みのみ許可"
        case _:
            return "権限なし"

Enum にすることで、ドット記法(CombinedPermission.READ_AND_DELETE)が使えるようになり、値パターンとして正しく動作します。

なぜ IntEnum は直接使えるのか?

ちなみに Permission.READ は問題なく動きます。

match permission_code:
    case Permission.READ:  # これはOK
        ...

これは、Permission.READ属性アクセス(ドット記法) を含んでいるため、PEP 634 の仕様により「値パターン」として扱われるからです。

単純な名前(READ_AND_DELETE)だと「キャプチャパターン」と解釈されますが、ドット記法を使うことで「この値と比較する」という意味になります。

まとめ

  • Python の match case で定数を使うときは注意が必要
  • 単純な名前(case 定数名:)はキャプチャパターン扱いになるので、そのままだと動かない
  • 実務では素直に if 文使うのが一番わかりやすい
  • どうしても match 使いたいなら以下の方法がある
    • ガード条件: case x if x == 定数:
    • Enum にまとめる: case Enum.VALUE:

コードレビューで「マジックナンバーやめろ」って言われたときは、無理に match で解決しようとせず、普通の条件分岐にリファクタリングするか、Enum にまとめるのが良いですね。

参考

0
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
0
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?