モダンPython中級 #5 デコレータパターン
#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 ではありません。
@log_calls
def add(a: int, b: int) -> int:
"""2 つの整数を足す。"""
return a + b
print(add.__name__) # 'wrapper' ← !
print(add.__doc__) # Noneこれを解いてくれるのが 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 段階のネスト になります。
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 — メモ化
#
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 — 属性のように呼ぶ
#
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
#
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)staticmethod — self も cls も受け取りません。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) # 3greet は今や 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 です。
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)))順序が意味を持つので注意してください。上の例では:
cacheが先 — キャッシュヒットなら retry も回らないretryが次 — キャッシュミスで実際の呼び出しが起きるとき再試行log_callsが最も外側 — 再試行を含むすべての呼び出しをロギング
よく作るパターン — 一ヶ所に #
時間計測 #
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権限チェック #
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 あり) #
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(): ...まとめ #
今回見たパターン:
- デコレータ = 関数を受け取って関数を返す、
@dはf = 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__、キャプチャ変数、ガードの深さを扱います。