Pythonをデイリータスクのほぼすべてに使っています。デプロイスクリプトからモニタリングアラートまで。しばらく使っていると、コードを読みにくくしているのは複雑なロジックではなく、複数のケースを処理するためにネストしたif-elif-elseの連鎖だということに気づきました。
Python 3.10はStructural Pattern Matchingをmatch-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 handling:
case _により残りのすべてのケースを処理することが強制され、見落としが減ります
デメリット
- 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ヶ月後にコードを読み返したとき、問題の構造がより明確に見えるからです。これが実際に使い続けている本当の理由です。
