モダンPython中級 #5 デコレータパターン

読了 6分

#4 イテラブルとジェネレータ で見た @contextmanager#1@dataclass、標準ライブラリの @functools.cache、FastAPI の @app.get(...) — 全部デコレータです。今回はそのパターンを自分で作るあらゆる方法を整理します。

デコレータ = 関数を包む関数 #

最も単純なデコレータ
def log_calls(fn):
    def wrapper(*args, **kwargs):
        print(f"呼び出し: {fn.__name__}({args}, {kwargs})")
        result = fn(*args, **kwargs)
        print(f"戻り値: {result}")
        return result
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    return a + b

add(2, 3)
# 呼び出し: add((2, 3), {})
# 戻り値: 5

@log_calls の上に関数を定義したのは、ただ次のシュガーです。

実際の動作
def add(a: int, b: int) -> int:
    return a + b

add = log_calls(add)

デコレータは関数を受け取って新しい関数を返します。 その結果として元の名前が新しい関数に束縛されるのです。

最初の落とし穴 — メタデータの喪失 #

上のコードの add はもう add ではありません。

名前と docstring が消える
@log_calls
def add(a: int, b: int) -> int:
    """2 つの整数を足す。"""
    return a + b

print(add.__name__)   # 'wrapper'  ← !
print(add.__doc__)    # None

これを解いてくれるのが functools.wraps です。

✅ functools.wraps でメタデータを保持
from functools import wraps

def log_calls(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"呼び出し: {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    """2 つの整数を足す。"""
    return a + b

print(add.__name__)   # 'add'
print(add.__doc__)    # '2 つの整数を足す。'

@wraps(fn) の一行が 名前、docstring、アノテーション、シグネチャ を元から複製してくれます。ユーザー定義のデコレータを作るならほぼ常に付ける と考えてよいです。

引数を受け取るデコレータ #

@retry(times=3) のようにデコレータ自体に引数を渡すには、もう一段階追加されます。

🚫 引数なしのデコレータ
@retry
def request(): ...

# 動作: request = retry(request)
✅ 引数ありのデコレータ
@retry(times=3)
def request(): ...

# 動作: request = retry(times=3)(request)
#                  ^^^^^^^^^^^^^^^^ これがデコレータを返す必要がある

つまり retry(...)デコレータを返す関数 になる必要があります。3 段階のネスト になります。

3 段階ネスト
from functools import wraps

def retry(times: int):                  # 1) 引数を受け取る
    def decorator(fn):                   # 2) 関数を受け取る
        @wraps(fn)
        def wrapper(*args, **kwargs):    # 3) 呼び出しを受け取る
            last_error = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_error = e
            raise last_error
        return wrapper
    return decorator

@retry(times=3)
def fetch():
    ...

読み方:

  • 最も外側 retry(times) — デコレータの引数
  • 中間 decorator(fn) — デコレートする関数
  • 最も内側 wrapper(*args, **kwargs) — 実際の呼び出し

3 段階は不慣れに見えますが すべての引数ありデコレータの標準形 です。

functools.partial のトリック #

@retry() のように引数なしでも、@retry(3) のように引数ありでも動くようにしたいとき。

どちらもサポート
from functools import wraps, partial

def retry(fn=None, *, times: int = 3):
    if fn is None:
        return partial(retry, times=times)
    @wraps(fn)
    def wrapper(*args, **kwargs):
        for _ in range(times):
            try:
                return fn(*args, **kwargs)
            except Exception:
                continue
        raise
    return wrapper

@retry
def a(): ...

@retry(times=5)
def b(): ...

複雑なのでよく使うわけではありません。たいていは どちらかに統一 する方がすっきりします。

よく使う標準デコレータ #

@functools.cache / @lru_cache — メモ化 #

cache
from functools import cache

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

print(fib(100))   # 354224848179261915075 — 即座に返る

同じ引数で再び呼ばれると計算せずに結果をそのまま返します。純粋関数にだけ使いましょう (副作用のある関数には不可)。

@lru_cache(maxsize=128) はキャッシュサイズに上限がある版です。メモリが無制限に増えるのを防ぎたいときに。

@property — 属性のように呼ぶ #

property
from dataclasses import dataclass

@dataclass
class Rectangle:
    width: float
    height: float

    @property
    def area(self) -> float:
        return self.width * self.height

r = Rectangle(3, 4)
print(r.area)    # 12.0  ← メソッド呼び出しではなく属性アクセス

@staticmethod / @classmethod #

static / class method
class Math:
    @staticmethod
    def add(a: int, b: int) -> int:
        return a + b

    @classmethod
    def from_pair(cls, pair: tuple[int, int]):
        return cls(*pair)

staticmethodselfcls も受け取りません。classmethod — 第一引数にクラス自体を受け取る (代替コンストラクタによく使う)。

クラスデコレータ #

デコレータは クラスにも付けられます。 関数と同じく クラスを受け取ってクラスを返す 関数です。

クラスデコレータの例
def add_repr(cls):
    def __repr__(self):
        attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({attrs})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class User:
    def __init__(self, id: int, name: str):
        self.id = id
        self.name = name

print(User(1, "カーティス"))
# User(id=1, name='カーティス')

@dataclass がまさにこのパターンです — クラスを受け取って __init__ / __repr__ / __eq__ を追加したクラスを返します。

デコレータクラス — 呼び出し時にオブジェクトにする方式 #

関数の代わりに __call__ を持つクラス をデコレータとして使うこともできます。状態が必要なときに合います。

クラス形のデコレータ
class CallCounter:
    def __init__(self, fn):
        self.fn = fn
        self.count = 0

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

@CallCounter
def greet():
    print("hi")

greet()
greet()
greet()
print(greet.count)   # 3

greet は今や CallCounter のインスタンスです。.count のような属性アクセスが自然にできます。短所: 関数のシグネチャ / メタデータの保持が難しく、静的解析機が厳しく見ます。状態が要らないなら関数デコレータ の方が良いです。

メソッドにデコレータ — self まで流れる #

デコレータ内の wrapper が *args, **kwargs の形で受け取るので メソッドの self はそのまま args[0] として入ってきます。

メソッドにも自然に
def log_calls(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"呼び出し: {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

class Service:
    @log_calls
    def fetch(self, url: str):
        return f"data from {url}"

s = Service()
s.fetch("https://api.example.com")
# 呼び出し: fetch

特別な処理は要りません。そのままちゃんと動きます。

デコレータの型ヒント — ParamSpec #

デコレータに型を書くと静的解析が正確になります。ところがデコレータは 任意の関数 を包むので、型をそのまま保持するのが難しいです。これを解いてくれるツールが ParamSpec です。

ParamSpec (3.10+)
from typing import Callable
from collections.abc import Awaitable

def log_calls[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"呼び出し: {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

[**P, R] (3.12+) の意味:

  • P — 関数のパラメータシグネチャをそのまま
  • R — 戻り値の型

こう書くと デコレートされた関数のシグネチャがそのまま維持 されます。呼び出し側の自動補完と検証が正確になります。旧方式は:

旧方式 — 新コードでは使わない
from typing import TypeVar, ParamSpec, Callable
from functools import wraps

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

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

新コードはメソッドの横の [**P, R] の形で。

合成 — デコレータを複数積む #

複数のデコレータ
@log_calls
@retry(times=3)
@cache
def fetch(url): ...

上のコードは 下から上に 適用されます。

等価変換
fetch = log_calls(retry(times=3)(cache(fetch)))

順序が意味を持つので注意してください。上の例では:

  1. cache が先 — キャッシュヒットなら retry も回らない
  2. retry が次 — キャッシュミスで実際の呼び出しが起きるとき再試行
  3. log_calls が最も外側 — 再試行を含むすべての呼び出しをロギング

よく作るパターン — 一ヶ所に #

時間計測 #

@timing
import time
from functools import wraps

def timing[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__}: {elapsed:.3f}s")
        return result
    return wrapper

権限チェック #

@require_admin
def require_admin(fn):
    @wraps(fn)
    def wrapper(user, *args, **kwargs):
        if not user.is_admin:
            raise PermissionError("管理者のみ可能")
        return fn(user, *args, **kwargs)
    return wrapper

@require_admin
def delete_user(actor, target_id): ...

Web フレームワークの権限処理は通常この形です。

結果のキャッシュ (TTL あり) #

TTL cache
from functools import wraps
import time

def ttl_cache(seconds: float):
    def decorator(fn):
        cache_data: dict = {}
        @wraps(fn)
        def wrapper(*args, **kwargs):
            key = (args, tuple(sorted(kwargs.items())))
            now = time.monotonic()
            if key in cache_data:
                value, ts = cache_data[key]
                if now - ts < seconds:
                    return value
            value = fn(*args, **kwargs)
            cache_data[key] = (value, now)
            return value
        return wrapper
    return decorator

@ttl_cache(seconds=60)
def get_config(): ...

まとめ #

今回見たパターン:

  • デコレータ = 関数を受け取って関数を返す、@df = d(f) のシュガー
  • functools.wraps — 名前 / docstring / シグネチャを保持、ユーザー定義デコレータにはほぼ常に
  • 引数ありのデコレータ = 3 段階ネスト (引数 → 関数 → 呼び出し)
  • @cache / @lru_cache (メモ化)、@property (属性のように)、@staticmethod / @classmethod
  • クラスデコレータ — クラスを受け取ってクラスを返す、@dataclass がその例
  • クラス形のデコレータ__call__ を持つインスタンス、状態が必要なとき
  • メソッドにデコレータを付けても *args, **kwargs で自動的に処理される
  • [**P, R] (3.12+) — ParamSpec + TypeVar でシグネチャを保持
  • 複数のデコレータは下から上に適用 — 順序が意味を持つ

次回 (#6 パターンマッチングの深さ) では、基礎 #3 で軽く見た match-case のあらゆるパターン — クラスパターン、__match_args__、キャプチャ変数、ガードの深さを扱います。

X