Python 裝飾器 · 通俗詳解

從「函式也是東西」講起,一步步推導出裝飾器,不用死背語法

一、一句話說清裝飾器

裝飾器就是「幫函式穿一件外套」。

手機裝個保護殼:手機還是那支手機,能打電話能上網,但摔下去不心疼了。
裝飾器也一樣:函式還是那個函式,邏輯一行沒改,但額外多了計時、寫日誌、查權限的能力。

關鍵理解

裝飾器不修改原函式的程式碼,而是把原函式打包起來,換成一個功能更強的版本

它的完整原理其實只有一句話,看完第二部分你就能自己推出來。真正讓人懵的從來不是原理,而是 @ 這個符號到底做了什麼——我們留到第四部分拆穿它。

二、前提:函式是「東西」

在 Python 裡,函式和數字、字串一樣,都是一種可以被傳來傳去的「東西」。這一點想通了,裝飾器就沒有難度了。

2.1 函式可以賦值給變數

def say_hello():
    print("你好")

f = say_hello     # 注意:沒有括號!
f()               # 這才真正呼叫
你好
這裡是全篇最重要的一處區別

say_hello函式本身(那個東西)。
say_hello()呼叫它之後的結果

就像 遙控器按下遙控器之後電視轉台 的區別。裝飾器從頭到尾打交道的都是「遙控器」,不是「轉台」。

2.2 函式可以定義在函式裡面

def outer():
    def inner():
        print("我是內層的")
    inner()        # 在內部呼叫它

outer()
我是內層的

2.3 函式可以作為回傳值

def make_greeter():
    def greeter(name):
        print(f"你好,{name}")
    return greeter        # 回傳函式本身,沒有呼叫它

hi = make_greeter()       # hi 現在就是那個 greeter 函式
hi("小明")
hi("小紅")
你好,小明 你好,小紅

2.4 函式可以當作參數傳進去

def run_twice(func):
    def wrapper():
        func()        # 第一次
        func()        # 第二次
    return wrapper

def say_hi():
    print("嗨")

say_hi = run_twice(say_hi)    # 把 say_hi 塞進去,拿回來一個加強版
say_hi()
嗨 嗨
恭喜,你已經寫出裝飾器了

上面這段 run_twice 就是一個不折不扣的裝飾器。它接受一個函式,回傳一個功能更強的新函式,原函式一行沒改。

唯一的毛病是 say_hi = run_twice(say_hi) 這行寫起來又長又容易忘。所以 Python 給了一個簡寫——就是 @

三、手寫第一個裝飾器

把 2.4 的例子整理一下,加上註解,形狀就固定下來了:

def run_twice(func):           # ① 裝飾器接收一個函式
    def wrapper():             # ② 在裡面造一個新函式
        func()                 # ③ 新函式裡可以隨便加料
        func()
    return wrapper             # ④ 把新函式交出去

@run_twice                     # ⑤ 這行 = say_hi = run_twice(say_hi)
def say_hi():
    print("嗨")

say_hi()
嗨 嗨

裝飾器永遠是這個四步結構:收進來一個函式 → 造一個新函式 → 在新函式裡幹活並呼叫原函式 → 把新函式交出去。

四、@ 語法糖登場

@ 做的事情只有一件

@裝飾器名 寫在函式定義上面,意思是:把下面這個函式整個塞進裝飾器裡,用裝飾器的回傳值,把函式名頂替掉。

所以這兩段程式碼完全等價,一個字母都不差:

# 寫法 A:手動包裝
def say_hi():
    print("嗨")

say_hi = run_twice(say_hi)
# 寫法 B:語法糖
@run_twice
def say_hi():
    print("嗨")

就這麼簡單。@ 不是新的魔法,它只是把你手動寫的那行賦值,搬到函式定義的上方,順便省掉「函式名要寫兩遍」的麻煩。

這樣記最不容易忘

看到 @run_twice,就在腦子裡念出來:
「把下面這個函式,裝進 run_twice 裡,然後名字還叫原來那個。」

一個容易被忽略的點:裝飾器什麼時候執行?

這是新手最容易懵的地方,看這段程式碼:

def my_decorator(func):
    print(f"→ 正在裝飾 {func.__name__}")     # 注意這行
    def wrapper():
        print("  wrapper 被呼叫")
        func()
    return wrapper

@my_decorator
def say_hi():
    print("  嗨")

print("--- 分隔線 ---")
say_hi()
say_hi()
→ 正在裝飾 say_hi --- 分隔線 --- wrapper 被呼叫 嗨 wrapper 被呼叫 嗨
注意看順序

「正在裝飾」這行在「分隔線」之前就印出來了——也就是說,裝飾器在函式被定義的那一刻就執行了,而不是等你呼叫它的時候。

記住這條,以後看到「為什麼裝飾器裡的程式碼在我還沒呼叫函式時就在跑」就知道答案了。

五、能上戰場的完整版

上面的版本有兩個致命缺陷:不能傳參數、丟掉回傳值。真實專案裡的裝飾器長這樣:

import functools
import time

def timer(func):
    @functools.wraps(func)              # ① 保留原函式的身分資訊
    def wrapper(*args, **kwargs):       # ② 萬用接參數的寫法
        start = time.time()
        result = func(*args, **kwargs)  # ③ 原函式真正的回傳值
        cost = time.time() - start
        print(f"{func.__name__} 耗時 {cost:.4f} 秒")
        return result                   # ④ 把回傳值傳出去
    return wrapper

@timer
def slow_add(a, b):
    time.sleep(1)
    return a + b

print(slow_add(1, 2))
slow_add 耗時 1.0005 秒 3

四個新東西,逐個說清楚。

5.1 *args, **kwargs —— 萬用接參數

裝飾器在寫的時候,根本不知道將來會裝飾什麼函式。可能一個參數,可能五個,可能有關鍵字參數。所以要用「照單全收」的寫法:

寫法 收什麼 例子
*args 所有按位置傳的參數,收成一個元組 f(1, 2)args = (1, 2)
**kwargs 所有按名字傳的參數,收成一個字典 f(a=1)kwargs = {'a': 1}

然後原樣轉交給原函式:func(*args, **kwargs)。那個 * 是「拆開」的意思,一收一拆,參數分毫不差地送了過去。

好記的說法

*args, **kwargs 就像快遞代收站:不管送來多少件、什麼形狀,先全收下,再原樣轉給收件人。

5.2 return result —— 別把回傳值弄丟了

如果 wrapper 裡只是 func(*args, **kwargs) 而沒有 return,那麼原函式算出來的結果就人間蒸發了,呼叫方拿到的是 None

這是新手寫裝飾器最常見的翻車點,症狀是:加了裝飾器之後,函式全變成回傳 None。

5.3 @functools.wraps —— 幫包裝器貼上原標籤

不加它會發生什麼?

@timer
def slow_add(a, b):
    """把兩個數相加"""
    return a + b

print(slow_add.__name__)    # 期望 "slow_add"
print(slow_add.__doc__)     # 期望 "把兩個數相加"
wrapper ← 名字變成了 wrapper! None ← 說明文件也沒了!

原因是 slow_add 這個名字,早就被替換成了裝飾器裡那個 wrapper 函式。加上 @functools.wraps(func) 之後,它會把原函式的 __name____doc__ 等身分資訊抄一份到 wrapper 身上,結果就恢復正常了。

為什麼這個不能省

不加 wraps,報錯訊息會變成 wrapper() takes 0 arguments 這種鬼話,你根本不知道是哪個函式出的問題。除錯時會非常痛苦。寫裝飾器請無腦加上它。

六、為什麼要有裝飾器

看一個真實痛點。假設你要給 3 個函式都加上「記錄耗時」:

不用裝飾器

def download():
    start = time.time()
    # ... 下載的業務程式碼 ...
    print(f"耗時 {time.time() - start}")

def upload():
    start = time.time()
    # ... 上傳的業務程式碼 ...
    print(f"耗時 {time.time() - start}")

def process():
    start = time.time()
    # ... 處理的業務程式碼 ...
    print(f"耗時 {time.time() - start}")

計時那三行,抄了 3 遍。100 個函式就抄 100 遍。哪天要改成記錄到日誌檔,得改 100 個地方。

用裝飾器

@timer
def download():
    # ... 下載的業務程式碼 ...

@timer
def upload():
    # ... 上傳的業務程式碼 ...

@timer
def process():
    # ... 處理的業務程式碼 ...

每個函式只多了一行 @timer,業務程式碼乾淨得像什麼都沒發生。要改計時邏輯,只改 timer 一個地方。

這就是裝飾器的真正價值

它把「和業務無關的雜事」——計時、日誌、權限、快取、重試——從業務程式碼裡抽出來,集中管理。

行話叫「切面導向程式設計(AOP)」,但說白了就是四個字:雜事外掛

實際專案裡裝飾器都用來幹嘛

用途典型場景
計時 / 效能監控找出哪個介面慢
日誌記錄自動記下誰在什麼時候呼叫了什麼
權限檢查Web 框架裡 @login_required,沒登入就跳轉
快取算過的結果存起來,下次直接回傳
失敗重試網路請求失敗自動重試 3 次
參數驗證進函式前先檢查參數合不合法

七、進階:帶參數的裝飾器

有時候裝飾器自己也想要參數,例如「重複執行 3 次」:

@repeat(3)          # 注意這裡有括號和參數
def say(msg):
    print(msg)

這比之前的版本多了一層,變成三層巢狀

import functools

def repeat(times):                        # 最外層:收裝飾器的參數
    def decorator(func):                  # 中間層:收被裝飾的函式
        @functools.wraps(func)
        def wrapper(*args, **kwargs):     # 最內層:真正幹活的
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say(msg):
    print(msg)

say("你好")
你好 你好 你好

為什麼是三層的?

關鍵在 @repeat(3) 這一行,Python 是分兩步執行的:

  1. 先算 repeat(3)——把 3 交給最外層,它回傳中間的 decorator 函式
  2. 再用這個 decorator 去裝飾 say——也就是 say = decorator(say)
一句話理解

帶參數的裝飾器,就是「先拿參數去訂製一個裝飾器,再用它」。

所以要多包一層——最外層是工廠,收到參數後生產出真正的裝飾器。

八、進階:多個裝飾器疊加

@timer
@logger
def work():
    print("幹活中")

疊加時等於什麼

從下往上包,所以上面這段等於:

work = timer(logger(work))

離函式最近的 @logger 先動手,包一層;然後 @timer 再把結果包一層。

執行時是什麼順序

想像穿衣服:穿的時候先穿內衣再穿外套;脫的時候先脫外套再脫內衣。裝飾器一模一樣:

記憶口訣

包裝:從下往上。執行:從上往下(進去時外層先,出來時內層先)。

用一個例子看清全過程:

def outer_dec(func):
    def wrapper():
        print("① 外層 進入")
        func()
        print("④ 外層 離開")
    return wrapper

def inner_dec(func):
    def wrapper():
        print("② 內層 進入")
        func()
        print("③ 內層 離開")
    return wrapper

@outer_dec
@inner_dec
def work():
    print("   ★ 幹正事")

work()
① 外層 進入 ② 內層 進入 ★ 幹正事 ③ 內層 離開 ④ 外層 離開

是不是很像俄羅斯套娃,或者洋蔥——一層層鑽進去,再一層層鑽出來

九、進階:類別裝飾器

除了函式,類別也能當裝飾器。只要這個類別實作了 __call__ 方法,它的實例就可以像函式一樣被呼叫。

class CountCalls:
    def __init__(self, func):          # 裝飾時執行:把函式存下來
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):   # 呼叫時執行
        self.count += 1
        print(f"第 {self.count} 次呼叫 {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hi():
    print("嗨")

say_hi()
say_hi()
print("總呼叫次數:", say_hi.count)
第 1 次呼叫 say_hi 嗨 第 2 次呼叫 say_hi 嗨 總呼叫次數: 2
和函式版有什麼區別

@CountCalls 這裡沒有括號——因為它直接收下了 say_hi,建立了一個實例,用這個實例頂替了原函式。

好處是能保存狀態(例如這裡的 count 會一直累加)。要用函式實作同樣的效果,得借助閉包或者全域變數,麻煩一些。

十、Python 自帶的裝飾器

你其實早就在用裝飾器了,只是沒意識到。下面是標準函式庫裡最常見的幾個:

裝飾器作用用法
@staticmethod 宣告這是靜態方法,不需要 self 類別裡寫工具函式時用
@classmethod 第一個參數是類別本身(cls)而不是實例 寫「從別的方式建立實例」的工廠方法
@property 把方法變成屬性,呼叫時不用寫括號 obj.name 而不是 obj.name()
@functools.lru_cache 自動快取函式結果,重複呼叫直接回傳 遞迴、耗時的計算
@functools.wraps 保留原函式的名字和說明文件 寫裝飾器時幾乎必加

@property 的例子

class Circle:
    def __init__(self, r):
        self.r = r

    @property
    def area(self):
        return 3.14159 * self.r ** 2

c = Circle(2)
print(c.area)      # 注意:沒有括號!
# print(c.area())  # 這樣寫會報錯
12.56636

@lru_cache 的例子

import functools

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

print(fib(100))     # 不加快取會卡死,加了瞬間出結果
354224848179261915075

十一、四個最常見的坑

坑 1:忘了 return,函式全部回傳 None

wrapper 裡呼叫了 func() 卻沒 return,結果就丟了。
症狀:加了裝飾器之後,所有函式都回傳 None

坑 2:忘了 functools.wraps

函式名全變成 wrapper,報錯訊息完全看不懂。
症狀:print(func.__name__) 輸出 wrapper

坑 3:忘了 *args, **kwargs

wrapper 寫成 def wrapper():,結果一裝飾帶參數的函式就報錯。
症狀:TypeError: wrapper() takes 0 positional arguments but 2 were given

坑 4:以為裝飾器是「呼叫時」才執行

裝飾器在函式定義時就執行了。所以裝飾器裡如果有耗時的初始化操作,會在程式一啟動就跑完,而不是等到呼叫函式。
症狀:程式剛啟動就變慢,但還沒呼叫任何函式。

避坑版範本(直接抄)

import functools

def my_decorator(func):
    @functools.wraps(func)              # 坑 2
    def wrapper(*args, **kwargs):       # 坑 3
        # 呼叫前想做的事
        result = func(*args, **kwargs)
        # 呼叫後想做的事
        return result                   # 坑 1
    return wrapper                      # 別忘了回傳 wrapper

把這段當成範本,每次寫裝飾器先抄它,四個坑一次全避開。

十二、速查表

你想做的事怎麼寫
最簡單的裝飾器 def d(f):
    def w():
        f()
    return w
標準寫法(推薦) 用上面的「避坑版範本」
帶參數的裝飾器 在外面再包一層:def d(參數): def deco(f): ... return deco
看到 @d 怎麼讀 f = d(f)
看到 @d(3) 怎麼讀 f = d(3)(f)
多個裝飾器疊加的順序 包裝從下往上;執行從上往下
裝飾器何時執行 函式定義時,不是呼叫時

最後,自己動手跑一遍

看一百遍不如敲一遍。建議這樣練:

  1. 新增一個文字檔案,改名為 test_decorator.py(注意副檔名是 .py
  2. 把第三節那個 run_twice 的例子複製進去
  3. 打開命令列,cd 到檔案所在目錄,執行 python test_decorator.py
  4. 看到「嗨」印出兩次,就代表成功了
  5. 然後自己改一改:把兩次改成三次?加上參數?加上回傳值?

學習順序建議:第三節 → 第四節 → 第五節。這三節吃透了,裝飾器就算過關了,後面都是變體。

三句話總結

  1. 裝飾器 = 把函式裝進另一個函式,用回傳值替換掉原來那個。
  2. @d 只是 f = d(f) 的簡寫,沒有別的魔法。
  3. 它的價值是把「計時、日誌、權限」這類雜事從業務程式碼裡抽出來。