Pythonのデコレータ:基本から引数付きまで
1. 概要
デコレータはPythonの「最も魔法的な」機能の1つですが、本質を理解せずにコピー&ペーストしているだけでは誤解を招きやすいものです。この記事では、関数がfirst-class citizenという概念から始めて、基本的なデコレータ、functools.wrapsでメタデータを保持するデコレータ、引数を受け取るデコレータ、そしてクラスベースのデコレータまで説明します。読み終わったあと、あなたは自分のプロジェクトのためにlogging、timing、あるいはretryデコレータを自分で書けるようになり、「元の関数名を失う」という問題を避けられるでしょう。
2. 関数はFirst-Class Citizen
デコレータを学ぶ前に、1つの事実を理解する必要があります。Pythonでは、関数もオブジェクトです。関数を変数に代入したり、関数をパラメータとして渡したり、関数から関数を返したりできます。
def greet(name: str) -> str:
return f"Hello, {name}!"
say_hello = greet
print(say_hello("Alice"))
Hello, Alice!
say_helloは同じ関数オブジェクトgreetを指す別の名前です。この特性のおかげで、Pythonでは関数を入力として受け取り、関数を出力として返す関数を書くことができます。これこそがデコレータの基礎です。
3. 基本的なデコレータ:関数が関数をラップする
デコレータは、別の関数を受け取り、元の関数の振る舞いを「ラップ」した新しい関数を返すだけの関数です。
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = log_call(add)
print(add(2, 3))
Calling add with args=(2, 3), kwargs={}
add returned 5
5
log_callはfunc(元の関数add)を受け取り、wrapperを定義します。これはfuncを呼び出しますが、その前後にログを追加する内部関数です。log_call(add)はwrapperを返し、それをaddに割り当て直します。この時点から、add(...)を呼び出すことは実質的にwrapper(...)を呼び出すことになります。
3.1. @構文 — シンタックスシュガー
func = decorator(func)を手動で書くのは冗長です。Pythonは同じことを簡潔に行うために@decorator構文を提供しています:
@log_call
def multiply(a: int, b: int) -> int:
return a * b
print(multiply(4, 5))
Calling multiply with args=(4, 5), kwargs={}
multiply returned 20
20
@log_callをdef multiplyの直上に置くことは、multiply = log_call(multiply)と同じです。これは単なるシンタックスシュガーであり、隠れた魔法はありません。
4. メタデータの喪失とfunctools.wraps
デコレータが元の関数をwrapperに「置き換える」とき、元の関数の__name__、__doc__などの属性を失います。
@log_call
def subtract(a: int, b: int) -> int:
"""Subtract two numbers."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
wrapper
None
これはデバッグ時や自動ドキュメント生成ツールを使用するときの一般的な問題です。subtractを呼び出していると思っていても、Pythonは名前がwrapperだと報告します。これはfunctools.wrapsで修正できます:
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def subtract(a: int, b: int) -> int:
"""Subtract two numbers."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
subtract
Subtract two numbers.
functools.wraps(func)はデコレータのデコレータです。これは__name__、__doc__、__module__…をfuncからwrapperにコピーします。
注意: 実際のデコレータでは常に
functools.wrapsを使用してください。これを省略するとデバッグが困難になり、introspection(例:help()、関数名に基づくフレームワーク)が破壊されます。
5. 引数を受け取るデコレータ
多くの場合、デコレータをカスタマイズしたいでしょう。例えば@retry(times=3)のように。この場合、もう1層の関数が必要です。外側の関数はパラメータを受け取り、実際のデコレータを返し、そのデコレータがfuncを受け取ります。
import functools
import time
def retry(times: int = 3, delay: float = 1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
print(f"Attempt {attempt} failed: {exc}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
raise ConnectionError(f"Timeout for order {order_id}")
try:
call_payment_api(101)
except ConnectionError as exc:
print(f"Giving up: {exc}")
Attempt 1 failed: Timeout for order 101
Attempt 2 failed: Timeout for order 101
Giving up: Timeout for order 101
覚えておくべき3層の構造:
retry(times, delay)— ファクトリ関数で、設定パラメータを受け取り、decoratorを返します。decorator(func)— 実際のデコレータ関数で、元の関数を受け取ります。wrapper(*args, **kwargs)— デコレートされた関数が呼ばれるときに実行される関数です。
@retry(times=2, delay=0)は実際には、まずretry(times=2, delay=0)を呼び出し(decoratorを返す)、その後にdecoratorをcall_payment_apiに適用します。
5.1. パラメータあり・なしの両方で使えるデコレータ
デコレータがパラメータなしでも使用できるようにしたい場合があります(@retry()の代わりに@retry)。クリーンな方法はfunctools.partialとcallableチェックを組み合わせることです:
import functools
def retry(func=None, *, times: int = 3):
if func is None:
return functools.partial(retry, times=times)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
print(f"Attempt {attempt} failed: {exc}")
raise RuntimeError("All attempts failed")
return wrapper
@retry
def task_a():
return "ok"
@retry(times=5)
def task_b():
return "ok"
@retry(関数を呼び出さない)を使用する場合、funcは直接task_a関数を受け取り、if func is Noneブランチは実行されません。@retry(times=5)を使用する場合、funcは最初はNoneで、関数はfunctools.partial(retry, times=5)を返します。これは次のステップでfuncを待つ関数であり、それがtask_bです。
6. クラスで書くデコレータ
関数をネストする代わりに、クラスでデコレータを書き、__call__を実装できます。この方法は、デコレータが複雑な状態を保持する必要がある場合に適しています。
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} called {self.call_count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def process_order(order_id: int) -> None:
print(f"Processing order {order_id}")
process_order(1)
process_order(2)
print(process_order.call_count)
process_order called 1 time(s)
Processing order 1
process_order called 2 time(s)
Processing order 2
2
__init__は元の関数を保存し、call_count = 0を初期化します。functools.update_wrapper(self, func)— クラスインスタンスに対するfunctools.wrapsと同等で、メタデータをオブジェクトselfにコピーします。__call__により、インスタンスprocess_order(この時点ではオブジェクトCountCallsであり、もはや関数ではない)が通常の関数のように呼び出せるようにします。
注意: クラスベースのデコレータは、他のクラス内のメソッドをデコレートするときはうまく機能しません(元のクラスの
selfとデコレータのselfが競合しやすいため)。メソッドの場合は、関数をネストしたものを優先するか、ディスパッチが必要な場合はfunctools.singledispatchmethodを使用してください。
7. 複数のデコレータを同時に積み重ねる(スタッキング)
複数のデコレータを1つの関数にスタックできます。適用の順序は下から上へです(関数に最も近いデコレータが最初に実行されます)。
@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
if amount <= 0:
raise ValueError("Amount must be positive")
return f"Charged {amount}"
print(charge_card(10))
Calling wrapper
Charged 10
ここでretry(times=2, delay=0)が最初に適用され、charge_cardをリトライ機能を持つ関数にラップします。その後、log_callがその外側をラップします。retryでfunctools.wrapsを正しく使用しないと、ログにcharge_cardの代わりにwrapperが出力されます。これはセクション4の重要性を示しています。
8. デコレータのテストを書く
デコレータは通常のPython関数のままなので、他のロジックと同じようにpytestでテストできます。まだインストールしていない場合はライブラリをインストールしてください:
pip install pytest
retryデコレータのテスト例:
import pytest
def test_retry_raises_after_exhausting_attempts():
call_log = []
@retry(times=3, delay=0)
def always_fail():
call_log.append("called")
raise ValueError("boom")
with pytest.raises(ValueError):
always_fail()
assert len(call_log) == 3
def test_retry_succeeds_on_second_attempt():
attempts = {"count": 0}
@retry(times=3, delay=0)
def fail_once_then_succeed():
attempts["count"] += 1
if attempts["count"] < 2:
raise ValueError("boom")
return "ok"
result = fail_once_then_succeed()
assert result == "ok"
assert attempts["count"] == 2
テストを実行する:
pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED
call_logとattemptsはスパイの役割を果たし、元の関数が呼ばれた回数をチェックします。期待されるリトライ動作と一致し、外部ライブラリをモックする必要がありません。
9. よくある間違い
functools.wrapsを忘れる: 関数が名前/docstringを失い、デバッグが困難になり、__name__に基づくフレームワーク(例:Flaskのルーティング)を使用するときにエラーが発生します。- wrapperでmutableデフォルト引数を使用する: Pythonの一般的なエラーと同じで、
args=[]をデフォルトにすることは避けてください。 - デコレータがインポート時に重い副作用を持つ: デコレータが
wrapper内ではなくdecorator(func)内でI/O(API呼び出し、ファイル読み込み)を実行する場合、関数が呼ばれるたびにではなく、モジュールがインポートされるたびに実行されます。 - 複雑なロジックにデコレータを乱用する: デコレータに分岐が多すぎる場合、コードは直接関数を呼び出すよりも読みにくくなります。ロジックが複雑な場合は、デコレータに詰め込むのではなく、コンテキストマネージャーの使用や別のクラスへの分離を検討してください。
デコレータを使用しない場合: 「ラップ」の振る舞いが1回だけ使用され、再利用されない場合、デコレータを書くことは関数を直接呼び出すよりもコードを追跡しにくくするだけです。デコレータは、複数の異なる関数にlogging、caching、retry、authなどの横断的関心事(cross-cutting concern)ロジックを適用するときに価値を発揮します。
10. 結論
Pythonのデコレータには何も神秘的なことはありません。関数がfirst-class citizenであるという事実に基づいて、関数を受け取り関数を返すだけです。基本的なデコレータから始めて、メタデータを保持するためにfunctools.wrapsを追加し、設定パラメータを受け取るために1層の関数を追加するか、複雑な状態を保持する必要があるときはクラスベースに切り替えます。これらのパターンをしっかり理解することで、実際のプロジェクトのための、クリーンでテスト可能なlogging、caching、retry、またはvalidationデコレータを自分で書くことができるようになります。