Pythonデコレータの仕組みと自作—functools.wrapsで関数情報を保つ

Pythonデコレータの仕組みと自作—functools.wrapsで関数情報を保つ | mohablog

@hoge を見たときに中で何が起きているか即答できないと、ライブラリのドキュメントを開くたびに読みが止まります。Python 3.14.5 の公式ドキュメントを下敷きに整理します。

目次

デコレータの構文—@は関数を返す関数の糖衣構文

公式 glossary の “decorator” 項は次のように定義しています。

A function returning another function, usually applied as a function transformation using the @wrapper syntax.

関数を引数に取り別の関数を返すだけ。@ はその関数適用を関数定義の上に書ける糖衣構文(syntactic sugar)です。glossary は「以下の2つは意味的に等価」として次のコードを並べています。

def f(arg): ...
f = staticmethod(f)

@staticmethod
def f(arg): ...

デコレータの本体は関数オブジェクトの差し替え。@ で読むと魔法めいて見えますが、糖衣構文を剥がせば代入1行です。

最小のデコレータ実装

def trace(func):
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}({args})")
        result = func(*args, **kwargs)
        print(f"return {result!r}")
        return result
    return wrapper

@trace
def add(a, b):
    return a + b

add(2, 3)

実行結果:

call add((2, 3))
return 5

@を剥がして読む

@trace は次と完全に等価です。

def add(a, b):
    return a + b
add = trace(add)

trace(add) が呼ばれた瞬間に内側の wrapper が新しい関数として返り、add という名前が wrapper に差し替わる。glossary が “merely syntactic sugar” と表現するのはこの構造です。

functools.wrapsを付けないと__name__と__doc__が消える

先ほどの trace を書き換えずに使うと、関数のメタ情報が壊れます。

@trace
def add(a, b):
    """整数を足す"""
    return a + b

print(add.__name__)
print(add.__doc__)

実行結果:

wrapper
None

関数名と docstring が wrapper 側に上書きされている。pytest のトレースバック表示、IDE の補完、Sphinx のドキュメント生成はすべて __name____doc__ を参照します。デコレータを付けた途端に全部 wrapper 表記になると、原因の切り分けが一段遅れます。

functools.wraps を当てた版

from functools import wraps

def trace(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return wrapper

@trace
def add(a, b):
    """整数を足す"""
    return a + b

print(add.__name__)
print(add.__doc__)

実行結果:

add
整数を足す

公式ドキュメント functools.wraps は内部で update_wrapper() を呼び、WRAPPER_ASSIGNMENTS(__module__, __name__, __qualname__, __annotations__, __type_params__, __doc__)を wrapper に転写します。自作デコレータには無条件で @wraps(func) を付ける

__wrapped__で素の関数に戻る

@wraps が当たっていれば wrapper.__wrapped__ で元の関数を取り出せます。

add.__wrapped__(2, 3)

実行結果:

5

ログ出力・認可チェック・リトライなど副作用付きのデコレータを、ユニットテストでは素の関数だけ呼びたい。__wrapped__ を経由すればテスト対象を純粋なロジックに絞れます。

引数を取るデコレータの三層関数構造

@retry(times=3) のように引数を取るデコレータは関数が3層になります。

import time
from functools import wraps

def retry(times: int, backoff: float = 0.5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exc = e
                    time.sleep(backoff * (2 ** i))
            raise last_exc
        return wrapper
    return decorator

@retry(times=3, backoff=0.2)
def fetch():
    raise RuntimeError("network")

呼び出し時の評価は次の順番です。

関数名受け取る引数返すもの
1層目retry引数 (times, backoff)decorator 関数
2層目decorator関数 (fetch)wrapper 関数
3層目wrapper呼び出し引数 (*args, **kwargs)結果 or 例外

@retry(times=3)retry(times=3) が先に評価され、その戻り値 decorator@ の対象になる。@ の右側は関数でなければならないため、引数を取る形は1段ラップが要る、という構造です。

引数なし・引数あり両対応にする

@retry でも @retry(times=3) でも動かしたいときは、第一引数が呼び出し可能(callable)かどうかで分岐させます。

def retry(_func=None, *, times=3, backoff=0.5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for i in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    time.sleep(backoff * (2 ** i))
        return wrapper
    if _func is None:
        return decorator
    return decorator(_func)

@retry / @retry() / @retry(times=5) のどれでも動く。functools.lru_cache も同じ判定パターンで両対応しています。

クラスベースのデコレータで状態を持たせる

関数では持ちにくい「呼び出し回数」「キャッシュ」のような状態を持たせるならクラスを使う。__call__ を実装したクラスは「呼び出すと関数のように振る舞うオブジェクト」になり、そのままデコレータとして機能します。

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

@CountCalls
def ping():
    return "ok"

ping(); ping(); ping()
print(ping.count)

実行結果:

3

ただしクラスデコレータはメソッドに当てると self 束縛が外れます。インスタンスメソッドへ付ける場合は __call__ に加えて __get__ をディスクリプタとして実装するか、最初から関数ベースで書き直すのが無難です。

複数デコレータのスタック順序と評価タイミング

複数のデコレータを重ねた場合、適用は下から上、実行は上から下です。

from functools import wraps

def log_a(func):
    @wraps(func)
    def w(*a, **k):
        print("A enter"); r = func(*a, **k); print("A exit"); return r
    return w

def log_b(func):
    @wraps(func)
    def w(*a, **k):
        print("B enter"); r = func(*a, **k); print("B exit"); return r
    return w

@log_a
@log_b
def hello():
    print("hello")

hello()

実行結果:

A enter
B enter
hello
B exit
A exit

@log_a の方が外側のラッパーになる。これを書き間違えると、認可チェックがキャッシュよりも後に走るような順序事故が起きます。

認可とキャッシュの順序事故

@cache@require_auth を重ねるとき、@cache が外側だと権限のないユーザーに対しても結果が返り続けます。実行は外側から走るので、外側にキャッシュを置くと内側の認可ロジックが呼ばれません。

# NG: 認可がキャッシュの内側に隠れて呼ばれない
@cache
@require_auth
def get_secret(user_id): ...

# OK: 認可を毎回通してからキャッシュを引く
@require_auth
@cache
def get_secret(user_id): ...

認可・ロギング・キャッシュは「外側ほど先に走る」順序で並ぶ、と覚えておきます。

functools.cache・cached_propertyの使いどころ

自作する前に標準ライブラリで済む場面は多い。functools 公式ドキュメントから抜粋すると、頻出は次の3種類です。

デコレータ用途引数の制約追加バージョン
@cache引数→戻り値の単純メモ化hashable な引数のみ3.9
@lru_cache(maxsize=N)サイズ上限付きキャッシュhashable な引数のみ3.2
@cached_propertyインスタンス属性のキャッシュself を持つメソッド3.8

公式ドキュメントの functools.cache の説明は “Returns the same as lru_cache(maxsize=None)” の1文。サイズ上限が要らない用途は @cache、メモリ上限を切りたいときは @lru_cache(maxsize=128) を使います。

from functools import cache

@cache
def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(50))
print(fib.cache_info())

実行結果:

12586269025
CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)

hits=48 は再帰の途中で同じ引数を48回再利用した、という意味。fib(50) を素朴に書くと2^50 オーダーの呼び出しになりますが、@cache を付けると51回で済みます。

デコレータが向かない場面と型安全な書き方

使い所を外しやすいパターン。

  • 関数1個にしか付かず、ロジックが10行未満: 直接書く方が早く読める。共通化する利点が出ない
  • 横断的関心事だが組み合わせパターンが3種以上: FastAPI の Depends のようなミドルウェア構造の方が、組み合わせを表現しやすい
  • 戻り値の型が条件次第で変わる: 静的型チェッカーが追えなくなる。typing.overload か明示関数で受ける

最後の型問題は mypy/pyright のチェックを抜けてしまう実害があります。型ヒントを保ちたいなら ParamSpecTypeVar で wrapper の型を残します。

from typing import Callable, ParamSpec, TypeVar
from functools import wraps

P = ParamSpec("P")
R = TypeVar("R")

def trace(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

ParamSpec は Python 3.10 で追加された機能で、デコレータ越しの引数・キーワード引数の型をひとまとめに渡せます。これがないと wrapper の戻り値が Any に落ちて、呼び出し側の型情報が失われます。

まとめ

  • @ は関数を返す関数の糖衣構文。仕組みは代入1行で読み下せる
  • functools.wraps を付けないと __name__ / __doc__ / アノテーションが wrapper 側に上書きされる
  • 引数付きデコレータは外側関数 → decoratorwrapper の三層構造
  • 複数デコレータは下から適用・上から実行。認可とキャッシュの並びを誤らない
  • 標準の @cache / @lru_cache / @cached_property で足りる場面では自作しない
  • 型ヒントを保つには ParamSpec + TypeVar を組み合わせる

関連: Pythonのruff入門 - pyproject.toml設定とCI組み込みの書き方 はコード品質チェック側の話。pytestでPythonテストを効率化する と組み合わせれば、デコレータを当てた関数のテスト戦略までつながります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次