Python 3.10のmatch-case:ネストしたif-elseをStructural Pattern Matchingで置き換える

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Pythonをデイリータスクのほぼすべてに使っています。デプロイスクリプトからモニタリングアラートまで。しばらく使っていると、コードを読みにくくしているのは複雑なロジックではなく、複数のケースを処理するためにネストしたif-elif-elseの連鎖だということに気づきました。

Python 3.10はStructural Pattern Matchingmatch-case構文で導入しました。本番環境で約6ヶ月使ってみた正直なレビューです。ドキュメントのデモではなく、実際の現場での評価をお伝えします。

2つのアプローチを比較する

実際の例を見てみましょう。モニタリングAPIからのHTTPレスポンスを処理する場合です。従来のif-elif-elseを使うと:

def handle_response(status_code, data):
    if status_code == 200:
        return process_success(data)
    elif status_code == 201:
        return process_created(data)
    elif status_code == 400:
        log_error('Bad request', data)
        return None
    elif status_code == 401:
        refresh_token()
        return None
    elif status_code == 500:
        alert_team('Server error!', data)
        return None
    else:
        log_error(f'Unknown status: {status_code}', data)
        return None

match-caseで書き直すと:

def handle_response(status_code, data):
    match status_code:
        case 200:
            return process_success(data)
        case 201:
            return process_created(data)
        case 400:
            log_error('Bad request', data)
        case 401:
            refresh_token()
        case 500:
            alert_team('Server error!', data)
        case _:
            log_error(f'Unknown status: {status_code}', data)
    return None

このシンプルな例では、match-caseは少しスッキリして見えます。しかし、まだその真価を発揮する場面ではありません。本当の威力はデータ構造に基づくマッチングが必要な場合に現れます。

本当の強み:データ構造によるマッチング

WebhookのDictイベントを処理する

私のmonitoringスクリプトはwebhookからJSONイベントをかなり頻繁に受け取ります。毎日3〜4つの異なるサービスから数百のイベントが届きます。これがmatch-caseが本当に違いを発揮する場面です:

def process_event(event):
    match event:
        case {'type': 'deploy', 'env': 'production', 'service': service_name}:
            notify_team(f'Production deploy: {service_name}')
        case {'type': 'deploy', 'env': env}:
            log_info(f'Deploy to {env}')
        case {'type': 'alert', 'severity': 'critical', 'message': msg}:
            page_oncall(msg)
        case {'type': 'alert', 'message': msg}:
            log_warning(msg)
        case {'type': unknown_type}:
            log_info(f'Unknown event type: {unknown_type}')
        case _:
            log_error('Malformed event', event)

従来のif-elifで書いた場合との比較:

def process_event_old(event):
    event_type = event.get('type')
    if event_type == 'deploy':
        env = event.get('env')
        if env == 'production':
            service_name = event.get('service')
            notify_team(f'Production deploy: {service_name}')
        else:
            log_info(f'Deploy to {env}')
    elif event_type == 'alert':
        severity = event.get('severity')
        msg = event.get('message')
        if severity == 'critical':
            page_oncall(msg)
        else:
            log_warning(msg)
    elif event_type:
        log_info(f'Unknown event type: {event_type}')
    else:
        log_error('Malformed event', event)

match-caseは一度に二つのことをします。dictの構造チェックと変数への値の抽出です。あちこちに.get()を書く必要もなく、余分なネストのレイヤーも不要です。

dataclassとguard conditionによるマッチング

from dataclasses import dataclass

@dataclass
class ServerStatus:
    host: str
    cpu: float
    memory: float
    status: str

def check_server(s: ServerStatus):
    match s:
        case ServerStatus(status='down'):
            alert_critical(f'{s.host} is DOWN!')
        case ServerStatus(cpu=cpu, memory=mem) if cpu > 90 or mem > 90:
            alert_warning(f'{s.host}: CPU={cpu}%, MEM={mem}%')
        case ServerStatus(status='up'):
            log_info(f'{s.host} OK')
        case _:
            log_debug(f'Unknown state: {s.host}')

パターンの後のif cpu > 90 or mem > 90の部分はguard conditionと呼ばれます。Pythonはstructural patternがマッチした後にのみguardを評価します。case内にifをネストするよりはるかにスッキリします。これが実際に最もよく使うコンボです。

メリットとデメリットの分析

メリット

  • コードが読みやすくなる:異なるデータ構造を持つケースが多い場合、問題の構造が一目で明確になります
  • 手動でextractする必要がない:Pythonがパターン内で直接変数に値をbindしてくれるため、多くのボイラープレートを省けます
  • Guard conditionにより、structural checkとvalue checkを同じcaseに組み合わせられます
  • Exhaustive handlingcase _により残りのすべてのケースを処理することが強制され、見落としが減ります

デメリット

  • Python 3.10+のみ対応:多くのサーバーはまだPython 3.8や3.9を使っています。使用前にpython --versionを確認してください
  • パターン内の変数はbindingであり、comparisonではない:これが最も混乱しやすいポイントです(詳細は後述)
  • 計算ロジックには向いていないif value > thresholdのような場合は、if-elseの方が自然です
  • Learning curve:慣れていないチームメンバーは**restやguard conditionを初めて見たとき「これは何の構文?」と必ず聞いてきます。実際、ジュニアをonboardするのにペアコーディングで約30分かかりました

match-caseをいつ使うべきか、いつ使わないべきか?

6ヶ月を経て、かなり実用的な原則にたどり着きました:

match-caseを使う場面:

  • dictやdataclass、sequenceの構造に基づく4つ以上のケースを処理する場合
  • CLIコマンド、キューのメッセージ、またはwebhookのイベントをparseする場合
  • 多くの状態を持つstate machineを実装する場合
  • 構造のバリアントが多いAPIレスポンスを処理する場合

if-elseを維持する場面:

  • シンプルな2〜3のケースで、直線的なロジックの場合
  • 条件が計算やrangeに基づく場合:if value > threshold
  • Python < 3.10をサポートする必要がある場合
  • チームがmatch-case構文に慣れておらず、onboardする時間がない場合

実装ガイド:よく使うPattern

Pattern 1:1つのcaseに複数の値(OR)

match command:
    case 'quit' | 'exit' | 'q':
        sys.exit(0)
    case 'help' | 'h' | '?':
        show_help()

Pattern 2:Sequenceのマッチング

CLIの引数やメッセージキューをparseする際によく使います:

def handle_command(args: list):
    match args:
        case ['deploy', env]:
            deploy_to(env)
        case ['deploy', env, '--dry-run']:
            dry_run(env)
        case ['rollback', env, version]:
            rollback(env, version)
        case ['status']:
            show_status()
        case [cmd, *rest]:
            print(f'Unknown: {cmd} {rest}')
        case []:
            print('No command provided')

Pattern 3:ワイルドカード付きDict(**rest)

def route_message(msg: dict):
    match msg:
        case {'action': 'create', 'resource': resource, **rest}:
            create_resource(resource, **rest)  # restは残りのキーを含む
        case {'action': 'delete', 'id': resource_id}:
            delete_resource(resource_id)
        case {'action': action}:
            raise ValueError(f'Unsupported action: {action}')

よくある落とし穴:BindingとComparison

match-caseを学び始めた人が最も躓くポイントです。パターンに変数名を書くと、Pythonは値をその変数にbindします。既存の変数と比較するわけではありません:

target_env = 'production'

match event:
    case {'env': target_env}:  # 間違い!これはbindingであり、comparisonではない
        ...                    # すべての値がmatchし、target_envを上書きする

既存の変数と比較したい場合は、guardを使います:

target_env = 'production'

match event:
    case {'env': env} if env == target_env:  # 正しい
        ...

またはdotted nameを使います。Pythonはdotted nameをbindせず、compareします:

class Env:
    PRODUCTION = 'production'

match event:
    case {'env': Env.PRODUCTION}:  # Compare、bindではない
        ...

Python 3.10+を使っていて、複雑なイベント処理やコマンドパース関数があれば、その1つをmatch-caseにリファクタリングしてみてください。「より現代的だから」ではありません。3ヶ月後にコードを読み返したとき、問題の構造がより明確に見えるからです。これが実際に使い続けている本当の理由です。

Share: