Pythonデコレータの仕組みと実践パターン

functools.wraps・functools.lru_cache・time.perf_counter で学ぶPythonデコレータの仕組みと実践パターン。関数がオブジェクトである原理から、引数付きデコレータ・クラスデコレータの作り方、functools.update_wrapper の内部動作、ロギング・実行時間計測・キャッシュ・リトライの実装例までコード付きで解説。*args/**kwargs転送忘れ・デコレータのスタック順序・引数付きデコレータのネスト忘れといった実行検証済みの落とし穴と、フィボナッチのキャッシュ高速化ベンチマークも収録します。

はじめに

デコレータは、既存の関数やクラスの動作を変更せずに機能を追加するPythonの強力な仕組みです。@ 構文で宣言的に適用でき、ログ記録、実行時間計測、リトライ処理、キャッシュなど横断的関心事の実装に広く使われています。

本記事では、デコレータの基礎から実践的なパターンまでをコード例とともに解説します。

前提知識:第一級関数とクロージャ

Pythonでは関数はオブジェクトであり、変数に代入したり、他の関数に渡したり、関数から返したりできます。

def greet(name):
    return f"Hello, {name}"

say_hello = greet  # 関数を変数に代入
print(say_hello("Alice"))  # "Hello, Alice"

クロージャは、外側の関数のスコープにある変数を参照する内部関数です。外側の関数が終了した後も、その変数にアクセスできます。

def make_multiplier(factor):
    def multiplier(x):
        return x * factor  # factorを参照(クロージャ)
    return multiplier

double = make_multiplier(2)
print(double(5))  # 10

基本的なデコレータ

デコレータは「関数を受け取り、関数を返す関数」です。

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished {func.__name__}")
        return result
    return wrapper

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

# @my_decorator は add = my_decorator(add) と等価
print(add(3, 4))
# 出力:
# Calling add
# Finished add
# 7

重要なのは、@my_decorator が何か特別なランタイム機構を呼び出しているわけではないという点です。デコレータ構文は次のコードの**糖衣構文(syntactic sugar)**に過ぎません。

def add(a, b):
    return a + b
add = my_decorator(add)  # デコレータ適用の正体:関数を受け取り、関数を返す代入

@my_decorator の直後に書かれた関数定義は、定義された瞬間に my_decorator(元の関数) の戻り値で同じ名前が上書きされます。デコレータが「高階関数の呼び出し + 再代入」以上でも以下でもないことを理解しておくと、後述するスタック順序や引数付きデコレータの挙動も機械的に読み解けるようになります。

エッジケース:*args, **kwargs の転送忘れ

wrapper*args, **kwargs を受け取っても、内部で func を呼び出す際に転送し忘れると、キーワード引数がエラーも出さずに静かに失われます。呼び出し自体は成功するため、テストで気づきにくい典型的なバグです。

import functools

# バグ入り: wrapper は **kwargs を受け取るが、func 呼び出し時に渡し忘れている
def buggy_logger(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[buggy_logger] calling {func.__name__} with args={args}, kwargs={kwargs}")
        return func(*args)  # バグ: kwargs がここで捨てられる
    return wrapper

# 正しい実装: *args, **kwargs をそのまま転送する
def correct_logger(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[correct_logger] calling {func.__name__} with args={args}, kwargs={kwargs}")
        return func(*args, **kwargs)
    return wrapper

@buggy_logger
def greet_buggy(name, greeting="Hello"):
    return f"{greeting}, {name}!"

@correct_logger
def greet_correct(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print("=== correct_logger: キーワード引数が反映される ===")
print("result:", greet_correct("Alice", greeting="Hi"))

print()
print("=== buggy_logger: キーワード引数が静かに消える ===")
print("result:", greet_buggy("Alice", greeting="Hi"))

実行結果:

=== correct_logger: キーワード引数が反映される ===
[correct_logger] calling greet_correct with args=('Alice',), kwargs={'greeting': 'Hi'}
result: Hi, Alice!

=== buggy_logger: キーワード引数が静かに消える ===
[buggy_logger] calling greet_buggy with args=('Alice',), kwargs={'greeting': 'Hi'}
result: Hello, Alice!

wrapper のログ出力を見ると kwargs={'greeting': 'Hi'} が確かに受け取られているのに、最終的な結果は greetingデフォルト値"Hello")になっています。func(*args)kwargs を渡していないため、greet_buggy 内部では greeting 引数が渡されなかった扱いになるのです。デコレータを書くときは func(*args, **kwargs) のように必ず両方を転送することが鉄則です。

functools.wraps の重要性

デコレータを適用すると、元の関数のメタデータ(__name____doc____wrapped__、シグネチャ)がラッパー関数のものに置き換わります。functools.wraps はこれを防ぎ、__wrapped__ 属性経由で元の関数への参照も残します。何が失われ、何が保持されるのかを inspect.signature まで含めて実際に確認します。

import functools
import inspect

def logger_no_wraps(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

def logger_with_wraps(func):
    @functools.wraps(func)  # 元の関数のメタデータを保持
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logger_no_wraps
def add(a, b):
    """二つの数を足す"""
    return a + b

@logger_with_wraps
def add2(a, b):
    """二つの数を足す"""
    return a + b

print("=== functools.wraps なし ===")
print("__name__      :", add.__name__)
print("__doc__       :", add.__doc__)
print("__wrapped__   :", getattr(add, "__wrapped__", None))
print("signature     :", inspect.signature(add))

print()
print("=== functools.wraps あり ===")
print("__name__      :", add2.__name__)
print("__doc__       :", add2.__doc__)
print("__wrapped__   :", add2.__wrapped__)
print("signature     :", inspect.signature(add2))

実行結果:

=== functools.wraps なし ===
__name__      : wrapper
__doc__       : None
__wrapped__   : None
signature     : (*args, **kwargs)

=== functools.wraps あり ===
__name__      : add2
__doc__       : 二つの数を足す
__wrapped__   : <function add2 at 0x105721c70>
signature     : (a, b)

functools.wraps なしでは __name__"wrapper" に化け、docstring は消え、__wrapped__ も存在せず、inspect.signature は元の (a, b) ではなく (*args, **kwargs) を返してしまいます。これはデバッガのスタックトレース表示、help()、IDE の補完、inspect を使うフレームワーク(FastAPI や Click など、シグネチャからパラメータを解析するライブラリ)すべてに悪影響を与えます。functools.wraps(func) を付けるだけで、__module____name____qualname____doc____dict__ をコピーし、__wrapped__ に元の関数への参照を設定してくれるため、デコレータを書く際は常に付けるべきです。

引数付きデコレータ

デコレータ自体に引数を渡したい場合、3重のネスト構造になります。

import functools

def repeat(n):
    """関数をn回繰り返すデコレータ"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(n):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    return f"Hello, {name}"

print(greet("Alice"))  # ["Hello, Alice", "Hello, Alice", "Hello, Alice"]

repeat(n)decorator(func)wrapper(*args, **kwargs) という3段のネストのうち、外側の呼び出し1段を書き忘れるのが典型的なミスです。

エッジケース:呼び出しレベルの数え忘れ

import functools

# 正しいデコレータファクトリ:3段のネスト
def repeat(n):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            return [func(*args, **kwargs) for _ in range(n)]
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    return f"Hello, {name}"

print("=== 正しい使い方: @repeat(3) ===")
print(greet("Alice"))

print()
print("=== ありがちな間違い: 呼び出しレベルを1段忘れる ===")

# ミス: 2段しかない普通のデコレータ(repeat_broken 自体が「func を受け取り wrapper を返す」形)
def repeat_broken(func):
    def wrapper(*args, **kwargs):
        return [func(*args, **kwargs) for _ in range(3)]
    return wrapper

try:
    @repeat_broken(3)  # 誤り: ファクトリのつもりで引数付きで呼んでいる
    def greet_broken(name):
        return f"Hello, {name}"
except TypeError as e:
    print(f"TypeError(デコレート時に発生): {e}")

実行結果:

=== 正しい使い方: @repeat(3) ===
['Hello, Alice', 'Hello, Alice', 'Hello, Alice']

=== ありがちな間違い: 呼び出しレベルを1段忘れる ===
TypeError(デコレート時に発生): 'int' object is not callable

何が起きているかを func = decorator(func) の等価変換で追うと分かります。@repeat_broken(3) は「repeat_broken(3) の戻り値を greet_broken に適用する」という意味です。ところが repeat_broken の仮引数名は func であるにもかかわらず、実際に渡されたのは整数 3 です。repeat_broken(3) はエラーなく wrapperfunc=3 を閉じ込めたクロージャ)を返しますが、続けて wrapper(greet_broken) が呼ばれた瞬間に func(*args, **kwargs) すなわち 3(greet_broken) が実行され、「整数は呼び出せない」という TypeError がデコレート時点(関数呼び出し時ではなく)で発生します。呼び出しレベルの数え間違いは、実行前の定義段階で例外になることが多いので、トレースバックの発生箇所(デコレート行かどうか)を手がかりに気づけます。

実践パターン

実行時間計測

import functools
import time

def timer(func):
    """関数の実行時間を計測するデコレータ"""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}: {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(1)
    return "done"

slow_function()  # "slow_function: 1.0012s"

リトライ(指数バックオフ付き)

import functools
import time

def retry(max_attempts=3, base_delay=1.0):
    """失敗時にリトライするデコレータ(指数バックオフ)"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    delay = base_delay * (2 ** attempt)
                    print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, base_delay=0.5)
def unreliable_api_call():
    import random
    if random.random() < 0.7:
        raise ConnectionError("API unavailable")
    return {"status": "ok"}

簡易キャッシュ

import functools

def simple_cache(func):
    """結果をキャッシュするデコレータ"""
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@simple_cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # キャッシュなしだと非現実的な計算時間

実用的には functools.lru_cache が同等の機能を提供します。

@functools.lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

キャッシュの効果を実測してみます。単純な再帰版フィボナッチは呼び出し回数が指数関数的に増えるため、n が大きくなるほど functools.lru_cache の効果が劇的になります。

import functools
import time

def fib_naive(n):
    if n < 2:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

@functools.lru_cache(maxsize=None)
def fib_cached(n):
    if n < 2:
        return n
    return fib_cached(n - 1) + fib_cached(n - 2)

for n in (25, 28, 30, 32):
    start = time.perf_counter()
    fib_naive(n)
    t_naive = (time.perf_counter() - start) * 1000

    start = time.perf_counter()
    fib_cached(n)
    t_cached = (time.perf_counter() - start) * 1000

    print(f"fib_naive({n}):  {t_naive:8.1f} ms")
    print(f"fib_cached({n}): {t_cached:8.4f} ms   (speedup: {t_naive / t_cached:,.0f}x)")

実行結果(実行環境により数値は変動しますが、桁の差は再現します):

fib_naive(25):       8.3 ms
fib_cached(25):   0.0055 ms   (speedup: 1,518x)
fib_naive(28):      35.7 ms
fib_cached(28):   0.0022 ms   (speedup: 16,456x)
fib_naive(30):     219.7 ms
fib_cached(30):   0.0033 ms   (speedup: 65,903x)
fib_naive(32):     253.0 ms
fib_cached(32):   0.0085 ms   (speedup: 29,910x)

Recursive Fibonacci: naive recursion vs functools.lru_cache execution time on a log scale, showing 1000x-65000x speedup

素朴な再帰は n が2増えるごとに呼び出し回数がおよそ黄金比の2乗(約2.6倍)ずつ増えるため、n=32 では 100万回近い重複呼び出しが発生します。lru_cache は一度計算した fibonacci(k) の結果を辞書に保持し、以降は O(1) のルックアップで返すため、計算量が指数時間 O(φ^n) から線形時間 O(n) に変わります。グラフの縦軸は対数スケールで、素朴な再帰(青)が n に応じて数十〜数百ミリ秒かかる一方、lru_cache版(緑)はマイクロ秒オーダーで頭打ちになっている様子が分かります。

クラスベースのデコレータ

__call__ メソッドを実装したクラスもデコレータとして使えます。状態を保持したい場合に有用です。

import functools

class CountCalls:
    """関数の呼び出し回数をカウントするデコレータ"""
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0

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

@CountCalls
def say_hello():
    print("Hello!")

say_hello()
say_hello()
print(f"Called {say_hello.count} times")  # "Called 2 times"

デコレータのスタック

複数のデコレータは下から上に適用され、実行時は上から下に呼ばれます。

@timer
@retry(max_attempts=2)
def api_call():
    pass

# 等価: api_call = timer(retry(max_attempts=2)(api_call))
# 実行時: timer → retry → api_call

これは見た目上の慣習ではなく、「基本的なデコレータ」の節で確認した func = decorator(func) という代入の連鎖そのものです。@A @B の順で重ねた関数は A(B(func)) になるため、一番下のデコレータが最初に func をラップし、一番上のデコレータが最も外側の呼び出しを担当します。この順序は美観の問題ではなく、実際の計測値やキャッシュの効き方を変えてしまいます。

エッジケース:スタック順序でキャッシュの計測結果が変わる

@timer@functools.lru_cache を重ねる例で、順序を入れ替えるとタイマーが何を測っているかが根本的に変わることを確認します。

import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}({args[0]}): {elapsed * 1000:.4f} ms")
        return result
    return wrapper

def slow_square(n):
    time.sleep(0.05)  # 重い計算のシミュレーション
    return n * n

# パターンA: @timer が外側、@lru_cache が内側 -> timer はキャッシュ参照も含め毎回計測する
@timer
@functools.lru_cache(maxsize=None)
def square_timer_outer(n):
    return slow_square(n)

# パターンB: @lru_cache が外側、@timer が内側 -> キャッシュヒット時は timer 自体が呼ばれない
@functools.lru_cache(maxsize=None)
@timer
def square_cache_outer(n):
    return slow_square(n)

print("=== パターンA: @timer → @lru_cache(timer が毎回の呼び出しを見る) ===")
square_timer_outer(5)  # キャッシュミス: 実際の計算時間を計測
square_timer_outer(5)  # キャッシュヒット: timer は動くが、辞書参照のみでほぼ0ms
square_timer_outer(5)  # 同上

print()
print("=== パターンB: @lru_cache → @timer(timer がキャッシュの内側にいる) ===")
square_cache_outer(5)  # キャッシュミス: timer が実際の計算時間を出力
square_cache_outer(5)  # キャッシュヒット: lru_cache が timer を呼ぶ前に結果を返す -> 出力なし
square_cache_outer(5)  # 同上: 出力なし

実行結果:

=== パターンA: @timer → @lru_cache(timer が毎回の呼び出しを見る) ===
square_timer_outer(5): 55.0224 ms
square_timer_outer(5): 0.0016 ms
square_timer_outer(5): 0.0003 ms

=== パターンB: @lru_cache → @timer(timer がキャッシュの内側にいる) ===
square_cache_outer(5): 50.4704 ms

パターンBでは3回呼び出しているにもかかわらず、timer の出力は1行しか表示されません@functools.lru_cache が一番外側にあるため、2回目・3回目の呼び出しはキャッシュ済みの結果を即座に返し、内側の timer(square_cache_outer) はそもそも一度も実行されないからです。一方パターンAでは timer が一番外側にあるため、キャッシュヒットの場合でも「辞書ルックアップだけなのでほぼ0msだった」という事実まで含めて毎回観測できます。「キャッシュのヒット/ミスも含めてレイテンシを監視したいか」「キャッシュ内部の詳細を隠して純粋な呼び出し回数だけ数えたいか」で、正しいスタック順序は変わります。監視目的なら @timer を外側に置くのが定石です。

最近のPythonにおける関連する変更

functools.wrapsfunctools.update_wrapper は Python 3.12 から __type_params__ 属性(PEP 695 のジェネリック関数構文 def f[T](x: T) -> T が持つ型パラメータ)もコピーするようになりました。ジェネリック関数をデコレートする際にも型パラメータ情報が失われなくなっています。また Python 3.14 では functools.partialfunctools.Placeholder センチネルが追加され、位置引数の途中の穴埋めができるようになりました(デコレータそのものの仕様変更ではありませんが、デコレータと組み合わせて部分適用を行う場面で有用です)。いずれもここで見た「メタデータの保持」「関数合成」という functools の基本的な役割を拡張するものであり、functools.wraps を使うべきだという結論そのものには影響しません。

組み込みデコレータ

Python標準の代表的なデコレータです。

デコレータ用途
@propertyメソッドをプロパティとしてアクセス
@staticmethodインスタンス不要のメソッド
@classmethodクラスを第一引数に受けるメソッド
@functools.lru_cache結果のメモ化キャッシュ
@functools.wrapsデコレータ内でメタデータを保持
@dataclasses.dataclassデータクラスの自動生成

関連記事

参考文献