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層の構造:

  1. retry(times, delay) — ファクトリ関数で、設定パラメータを受け取り、decoratorを返します。
  2. decorator(func) — 実際のデコレータ関数で、元の関数を受け取ります。
  3. 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デコレータを自分で書くことができるようになります。

pythondecoratorclosurefunctional-programmingclean-code