「@って、関数の上に付いてるあれ、なに?」「Flaskやpytestのサンプルでよく見るけど、意味がわからないまま写している…」

こんな経験、ありませんか? デコレータは「なんとなく難しそう」と感じやすい機能ですよね。でも仕組みはシンプルで、一度わかると処理時間の計測・リトライ・計算結果のキャッシュといった「どの関数にも付けたい処理」を、@1行で付け足せるようになります。

この記事は、関数(def)を書いたことがある初〜中級者向けです。5つのサンプルを実際に動かしながら、一緒に見ていきましょう! 😊

  • デコレータの正体(「関数を受け取って関数を返す関数」)
  • 処理時間を測る @timer と functools.wraps の役割
  • 引数付きのデコレータ @retry(times=3) の作り方
  • 標準ライブラリの @lru_cache で計算を速くする方法
  • デコレータを重ねたときの「順番」の落とし穴

動作確認は Python 3.13 で行っています。外部ライブラリは使いません。

デコレータとは?「関数に上着を着せる」イメージ

gift wrapping
gift wrapping / Photo by Chris F via Pexels

イメージとしては、元の関数はそのままに、外側に「上着」を着せて機能を足すものです。上着を着せても中身(元の関数)は変わりません。でも呼び出すと、上着の部分の処理(前後のprintや時間計測など)も一緒に動きます。

これを支えているのが、Pythonでは関数も「値」として扱えるという性質です。変数に入れたり、別の関数に渡したり、関数から返したりできます。まずはここから確かめてみましょう。

サンプル1:いちばん小さなデコレータ

# 関数は「変数に入れたり、引数に渡したり」できる
def greet(name):
    return f"こんにちは、{name}さん"

say = greet              # () を付けない → 関数そのものを代入
print(say("田中"))

# 関数を受け取って「前後に処理を足した新しい関数」を返す
def announce(func):
    def wrapper(*args, **kwargs):
        print(f"--- {func.__name__} を実行します ---")
        result = func(*args, **kwargs)   # 元の関数を呼ぶ
        print(f"--- {func.__name__} が終わりました ---")
        return result
    return wrapper

@announce                # greet2 = announce(greet2) と同じ意味
def greet2(name):
    return f"おはよう、{name}さん"

print(greet2("佐藤"))

実行結果はこうなります。

こんにちは、田中さん
--- greet2 を実行します ---
--- greet2 が終わりました ---
おはよう、佐藤さん

ここが重要です👇

  • say = greet のように ()を付けずに書くと、関数そのものが代入される
  • announce は「関数を受け取って、前後にprintを足した新しい関数 wrapper を返す」関数
  • @announce は greet2 = announce(greet2) の省略記法にすぎない
  • *args, **kwargs で受けると、どんな引数の関数にも付けられる

つまり、デコレータ=関数を受け取って関数を返す関数です。@は「上着を着せる」ための便利な書き方、と覚えておけばOKです。

サンプル2:処理時間を測る @timer と functools.wraps

実用的な例として、関数の実行にかかった時間を表示するデコレータを作ります。「どの処理が遅いのか」を調べるときに、関数の上に1行足すだけで済むのが便利なところです。

import time
from functools import wraps

def timer(func):
    @wraps(func)  # 元の関数の名前や説明文を引き継ぐ
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"[timer] {func.__name__}: {elapsed:.3f}秒")
        return result
    return wrapper

def timer_no_wraps(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@timer
def make_report(n):
    """1〜nの二乗の合計を求める"""
    time.sleep(0.2)  # 重い処理の代わり
    return sum(i * i for i in range(1, n + 1))

@timer_no_wraps
def make_report2(n):
    """1〜nの二乗の合計を求める"""
    return sum(i * i for i in range(1, n + 1))

print("結果:", make_report(1000))
print("wrapsあり:", make_report.__name__, "/", make_report.__doc__)
print("wrapsなし:", make_report2.__name__, "/", make_report2.__doc__)
[timer] make_report: 0.200秒
結果: 333833500
wrapsあり: make_report / 1〜nの二乗の合計を求める
wrapsなし: wrapper / None

ポイントをまとめるとこんな感じです。

  • 時間の計測には time.perf_counter() を使う(短い時間を測るための時計)
  • @wraps(func) を付けないと、関数名が wrapper に、説明文(docstring)が None に変わってしまう
  • エラーメッセージやログに出る関数名がおかしくなるので、自作デコレータには毎回 @wraps を付けるのがおすすめ

これ、実行せずに答えられますか? ── 「@wraps なしのとき、make_report2.__name__ は何になる?」。答えは上の結果のとおり wrapper です。外側の上着の名前が見えてしまうわけですね。

⚠️ 計測した秒数はパソコンの性能や状況で変わります。ここでは time.sleep(0.2) を入れているので、だいたい0.2秒になります。

サンプル3:引数付きデコレータ @retry(times=3)

ネットワーク通信のように「たまに失敗するけど、もう一度やれば成功する」処理はよくありますよね。そこで、失敗したら少し待って、決めた回数まで試し直すデコレータを作ります。

@retry(times=3, delay=0.1) のようにデコレータに引数を渡したいときは、関数を3段重ねにします。

import time
from functools import wraps

def retry(times=3, delay=0.5):
    """失敗したら delay 秒待って、最大 times 回まで試すデコレータ"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    print(f"[retry] {attempt}回目失敗: {e}")
                    if attempt == times:
                        raise  # 最後まで失敗したらエラーをそのまま投げる
                    time.sleep(delay)
        return wrapper
    return decorator

call_count = 0

@retry(times=3, delay=0.1)
def fetch_price():
    """2回目までは通信エラーになる、不安定な処理のまね"""
    global call_count
    call_count += 1
    if call_count < 3:
        raise ConnectionError("サーバーに接続できません")
    return 1280

print("取得した価格:", fetch_price())

@retry(times=2, delay=0.1)
def always_fail():
    raise ConnectionError("ずっとつながりません")

try:
    always_fail()
except ConnectionError as e:
    print("あきらめました:", e)
実行結果:@retryで2回失敗したあと3回目で成功
実行結果:@retryで2回失敗したあと3回目で成功

ここが重要です👇

  • retry(times, delay) → 設定を受け取ってデコレータを返す
  • decorator(func) → 関数を受け取ってwrapperを返す(サンプル2と同じ形)
  • wrapper → 実際に呼ばれる関数。for と try で試し直す
  • キャッチするのは ConnectionError だけ。タイプミスなどのバグまで試し直すと、原因が見えにくくなるからです
  • 最後まで失敗したら raise でエラーをそのまま呼び出し元に返す(失敗を黙って握りつぶさない)

実行すると、1つ目の fetch_price() は2回失敗したあと3回目で価格を返し、2つ目の always_fail() は2回試してあきらめています(下の実行結果画像)。

3段重ねは最初ややこしく感じますが、「外側の関数は設定を覚えておくための箱」と考えると、中の2段はサンプル2と同じだとわかるはずです。

サンプル4:標準ライブラリの @lru_cache で計算を速くする

デコレータは自作するだけでなく、標準ライブラリにも便利なものが入っています。代表例が functools.lru_cache です。一度計算した答えを覚えておき、同じ引数で呼ばれたら計算せずに返す(メモ化)デコレータです。

題材は「n段の階段を、1段か2段ずつ登る方法は何通り?」という問題です。n段目に着く直前は「n-1段目」か「n-2段目」のどちらかなので、stairs(n-1) + stairs(n-2) で求められます。

import time
from functools import lru_cache

calls = 0

def stairs(n):
    """n段の階段を1段か2段ずつ登る方法は何通り?"""
    global calls
    calls += 1
    if n <= 1:
        return 1
    return stairs(n - 1) + stairs(n - 2)

cached_calls = 0

@lru_cache(maxsize=None)  # 一度計算した答えを覚えておく
def stairs_cached(n):
    global cached_calls
    cached_calls += 1
    if n <= 1:
        return 1
    return stairs_cached(n - 1) + stairs_cached(n - 2)

start = time.perf_counter()
print("キャッシュなし:", stairs(30), "通り /", f"{calls:,}回呼び出し",
      f"/ {time.perf_counter() - start:.3f}秒")

start = time.perf_counter()
print("キャッシュあり:", stairs_cached(30), "通り /", f"{cached_calls:,}回呼び出し",
      f"/ {time.perf_counter() - start:.5f}秒")
print(stairs_cached.cache_info())
実行結果:@lru_cacheの有無で呼び出し回数を比較
実行結果:@lru_cacheの有無で呼び出し回数を比較

結果を見ると、答えはどちらも 1,346,269通りで同じです。ところが関数の呼び出し回数は、

  • キャッシュなし:2,692,537回
  • キャッシュあり:31回

と、桁違いの差がつきました。キャッシュなしでは stairs(28) や stairs(27) などを何度も計算し直しているからです。cache_info() の hits=28 は「覚えていた答えを28回使い回した」、misses=31 は「実際に計算したのは31回」という意味です。

⚠️ 注意点もあります。

  • 引数がリスト・辞書など、ハッシュできない型だと使えない(TypeError になる)
  • 同じ引数でも結果が変わる関数(現在時刻や乱数を使う関数、ファイルを読む関数など)に付けると、古い結果が返ってしまう
  • maxsize=None は「無制限に覚える」設定。引数の種類が非常に多い関数ではメモリを使いすぎることがあるので、maxsize=128 のように上限を決める

なお、Python 3.9以降では @functools.cache という短い書き方もあり、lru_cache(maxsize=None) と同じ動きをします。

サンプル5:デコレータを重ねるときは「順番」に注意

デコレータは何個でも重ねられます。ただし、重ねる順番で動きが変わることがあります。@timer と @retry を組み合わせて確かめてみましょう。

@で重ねたとき、関数に近い(下にある)ものから順に上着を着せます。

  • 上に @timer、下に @retry(...) = timer(retry(...)(task)):timerが外側
  • 上に @retry(...)、下に @timer = retry(...)(timer(task)):retryが外側

比べやすいように、ここでは@を使わず、同じ意味の書き方で2パターン作ります。

import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(f"[timer] {func.__name__}: {time.perf_counter() - start:.2f}秒")
    return wrapper

def retry(times=3, delay=0.5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError:
                    print(f"[retry] {attempt}回目失敗")
                    if attempt == times:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

def make_unstable():
    """2回失敗してから成功する関数を作る"""
    count = {"n": 0}
    def task():
        count["n"] += 1
        time.sleep(0.1)
        if count["n"] < 3:
            raise ConnectionError
        return "OK"
    return task

# パターンA: timer が外側 → リトライ全体の時間を測る
task_a = timer(retry(times=3, delay=0.5)(make_unstable()))
print("A:", task_a())

print()

# パターンB: retry が外側 → 1回ごとの時間を測る
task_b = retry(times=3, delay=0.5)(timer(make_unstable()))
print("B:", task_b())
[retry] 1回目失敗
[retry] 2回目失敗
[timer] task: 1.30秒
A: OK

[timer] task: 0.10秒
[retry] 1回目失敗
[timer] task: 0.10秒
[retry] 2回目失敗
[timer] task: 0.10秒
B: OK

ポイントをまとめるとこんな感じです。

  • パターンA(timerが外側):timerは1回だけ表示され、3回の実行(0.1秒×3)+待ち時間(0.5秒×2)を含めた全体の1.30秒を測っている
  • パターンB(retryが外側):timerが試すたびに表示され、1回あたりの0.10秒を測っている
  • 「ユーザーが待たされた時間」を知りたいならA、「1回の処理の速さ」を知りたいならB

どちらが正しいというわけではなく、何を測りたいかで順番を決めるのが大事です。なお timer の中で try/finally を使っているので、エラーで終わった回の時間も表示されます。

デコレータを使うときのチェックリスト

チェック項目 理由
wrapperの引数は *args, **kwargs どんな引数の関数にも付けられるようにする
元の関数の戻り値を return している 忘れると、付けた関数が全部 None を返すようになる
@wraps(func) を付けている 関数名・説明文が wrapper に置き換わるのを防ぐ
引数付きなら3段重ね 引数付きで作ったデコレータは、設定がなくても @retry() と () を付けて使う
重ねる順番を意識している 関数に近いものから順に適用される。測る範囲や試し直す範囲が変わる

特に「returnの書き忘れ」は初心者がつまずきやすいポイントです。デコレータを付けたとたんに結果が None になったら、まずwrapperの中を確認してみてください。

まとめ

  • デコレータは「関数を受け取って、機能を足した関数を返す関数」。@deco は func = deco(func) の省略記法
  • 自作するときは *args, **kwargs・return・@wraps の3点セットを忘れない
  • 設定を渡したいときは3段重ね(@retry(times=3))
  • 標準ライブラリの @lru_cache で、同じ計算のくり返しを大きく減らせる(今回は約269万回→31回)
  • 重ねるときは関数に近いものから順に適用。何を測りたいか・何を試し直したいかで順番を決める

「@が出てくると読めない…」が「自分でも作れそう」に変わったなら嬉しいです。まずは普段書いている関数に @timer を付けて、どこに時間がかかっているか測るところから、ぜひ試してみてください! 🚀

参考:functools — 高階関数と呼び出し可能オブジェクトの操作(Python公式ドキュメント)

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
実践Claude Code入門―現場で活用するためのAIコーディングの思考法

もしも

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

AIコーディングの現場活用法を学ぶ一冊

Amazonで見る
Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

もしも

Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

FastAPIでWebAPI開発を実践的に学ぶ

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。