從「函式也是東西」講起,一步步推導出裝飾器,不用死背語法
裝飾器就是「幫函式穿一件外套」。
手機裝個保護殼:手機還是那支手機,能打電話能上網,但摔下去不心疼了。
裝飾器也一樣:函式還是那個函式,邏輯一行沒改,但額外多了計時、寫日誌、查權限的能力。
裝飾器不修改原函式的程式碼,而是把原函式打包起來,換成一個功能更強的版本。
它的完整原理其實只有一句話,看完第二部分你就能自己推出來。真正讓人懵的從來不是原理,而是 @ 這個符號到底做了什麼——我們留到第四部分拆穿它。
在 Python 裡,函式和數字、字串一樣,都是一種可以被傳來傳去的「東西」。這一點想通了,裝飾器就沒有難度了。
def say_hello():
print("你好")
f = say_hello # 注意:沒有括號!
f() # 這才真正呼叫
say_hello 是函式本身(那個東西)。
say_hello() 是呼叫它之後的結果。
就像 遙控器 和 按下遙控器之後電視轉台 的區別。裝飾器從頭到尾打交道的都是「遙控器」,不是「轉台」。
def outer():
def inner():
print("我是內層的")
inner() # 在內部呼叫它
outer()
def make_greeter():
def greeter(name):
print(f"你好,{name}")
return greeter # 回傳函式本身,沒有呼叫它
hi = make_greeter() # hi 現在就是那個 greeter 函式
hi("小明")
hi("小紅")
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()
「正在裝飾」這行在「分隔線」之前就印出來了——也就是說,裝飾器在函式被定義的那一刻就執行了,而不是等你呼叫它的時候。
記住這條,以後看到「為什麼裝飾器裡的程式碼在我還沒呼叫函式時就在跑」就知道答案了。
上面的版本有兩個致命缺陷:不能傳參數、丟掉回傳值。真實專案裡的裝飾器長這樣:
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))
四個新東西,逐個說清楚。
*args, **kwargs —— 萬用接參數裝飾器在寫的時候,根本不知道將來會裝飾什麼函式。可能一個參數,可能五個,可能有關鍵字參數。所以要用「照單全收」的寫法:
| 寫法 | 收什麼 | 例子 |
|---|---|---|
*args |
所有按位置傳的參數,收成一個元組 | f(1, 2) → args = (1, 2) |
**kwargs |
所有按名字傳的參數,收成一個字典 | f(a=1) → kwargs = {'a': 1} |
然後原樣轉交給原函式:func(*args, **kwargs)。那個 * 是「拆開」的意思,一收一拆,參數分毫不差地送了過去。
*args, **kwargs 就像快遞代收站:不管送來多少件、什麼形狀,先全收下,再原樣轉給收件人。
return result —— 別把回傳值弄丟了如果 wrapper 裡只是 func(*args, **kwargs) 而沒有 return,那麼原函式算出來的結果就人間蒸發了,呼叫方拿到的是 None。
這是新手寫裝飾器最常見的翻車點,症狀是:加了裝飾器之後,函式全變成回傳 None。
@functools.wraps —— 幫包裝器貼上原標籤不加它會發生什麼?
@timer
def slow_add(a, b):
"""把兩個數相加"""
return a + b
print(slow_add.__name__) # 期望 "slow_add"
print(slow_add.__doc__) # 期望 "把兩個數相加"
原因是 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 是分兩步執行的:
repeat(3)——把 3 交給最外層,它回傳中間的 decorator 函式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)
@CountCalls 這裡沒有括號——因為它直接收下了 say_hi,建立了一個實例,用這個實例頂替了原函式。
好處是能保存狀態(例如這裡的 count 會一直累加)。要用函式實作同樣的效果,得借助閉包或者全域變數,麻煩一些。
你其實早就在用裝飾器了,只是沒意識到。下面是標準函式庫裡最常見的幾個:
| 裝飾器 | 作用 | 用法 |
|---|---|---|
@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()) # 這樣寫會報錯
@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)) # 不加快取會卡死,加了瞬間出結果
wrapper 裡呼叫了 func() 卻沒 return,結果就丟了。
症狀:加了裝飾器之後,所有函式都回傳 None。
函式名全變成 wrapper,報錯訊息完全看不懂。
症狀:print(func.__name__) 輸出 wrapper。
wrapper 寫成 def wrapper():,結果一裝飾帶參數的函式就報錯。
症狀:TypeError: wrapper() takes 0 positional arguments but 2 were given
裝飾器在函式定義時就執行了。所以裝飾器裡如果有耗時的初始化操作,會在程式一啟動就跑完,而不是等到呼叫函式。
症狀:程式剛啟動就變慢,但還沒呼叫任何函式。
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 d(參數): def deco(f): ... return deco |
看到 @d 怎麼讀 |
f = d(f) |
看到 @d(3) 怎麼讀 |
f = d(3)(f) |
| 多個裝飾器疊加的順序 | 包裝從下往上;執行從上往下 |
| 裝飾器何時執行 | 函式定義時,不是呼叫時 |
看一百遍不如敲一遍。建議這樣練:
test_decorator.py(注意副檔名是 .py)run_twice 的例子複製進去cd 到檔案所在目錄,執行 python test_decorator.py學習順序建議:第三節 → 第四節 → 第五節。這三節吃透了,裝飾器就算過關了,後面都是變體。
@d 只是 f = d(f) 的簡寫,沒有別的魔法。