踐)
裝飾器不是Python里的炫技玩具它是函數(shù)式編程思維在語言層面的一次優(yōu)雅落地。當(dāng)你寫下login_required或time_it時真正發(fā)生的事是目標(biāo)函數(shù)被當(dāng)作參數(shù)傳入裝飾器裝飾器返回一個新的可調(diào)用對象然后這個名字被重新綁定。這條簡單規(guī)則解釋了一切——裝飾器是函數(shù)替換不是代碼注入也不是魔法。透徹理解這句話才能避開書寫裝飾器時最常見的那些坑。閉包是裝飾器的地基任何裝飾器都離不開閉包。內(nèi)層函數(shù)捕獲外層函數(shù)中的變量比如被裝飾的函數(shù)對象或者裝飾器工廠傳入的參數(shù)并延長這些變量的生命周期。沒有閉包裝飾器就是個空殼。寫一個會記錄日志的裝飾器最樸素的形式長這樣def log(func): def wrapper(args, kwargs): print(f調(diào)用 {func.__name__}) return func(args, kwargs) return wrapper內(nèi)層的wrapper引用了外層的func于是func在log返回后依然存在。這就是閉包的價值。但問題立刻浮現(xiàn)wrapper沒有func的名字和文檔字符串很多調(diào)試工具和框架會因此困惑。幾乎所有裝飾器缺陷都源于對閉包變量作用域和函數(shù)元信息的忽視。用functools.wraps保住函數(shù)的靈魂上面那個log裝飾器雖然能用但它毀掉了原函數(shù)的簽名信息。在交互環(huán)境里輸入help(wrapper)你只能看到一串沒意義的內(nèi)容。更糟的是像inspect.signature這樣的內(nèi)省工具會誤判函數(shù)參數(shù)導(dǎo)致ORM映射或Web路由框架工作失常。解決之道是標(biāo)準(zhǔn)庫里的functools.wrapsimport functools def log(func): functools.wraps(func) def wrapper(args, kwargs): print(f調(diào)用 {func.__name__}) return func(args, kwargs) return wrapperwraps做了件很漂亮的事它把原函數(shù)的__name__、__doc__、__module__、__qualname__等屬性拷貝到wrapper上并嘗試更新__dict__。這相當(dāng)于給裝飾器穿上了一層“官方馬甲”。永遠(yuǎn)不要在一個不調(diào)用functools.wraps的裝飾器上談?wù)撟罴褜?shí)踐。除非你有極其特殊的原因否則它就是一條鐵律。裝飾器工廠讓行為可配置有時你希望裝飾器能接受參數(shù)比如指定日志級別或者設(shè)定重試次數(shù)。直接寫retry辦不到因?yàn)閞etry收到的不是函數(shù)而是一個參數(shù)。此時需要再包一層函數(shù)——裝飾器工廠。它返回真正的裝飾器由后者接收被裝飾的函數(shù)。def retry(max_attempts3): 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 time.sleep(0.5) return None return wrapper return decorator retry(max_attempts5) def connect(): ...這層嵌套結(jié)構(gòu)常讓人頭暈但拆解后很清晰retry(5)先執(zhí)行返回decorator接著decorator(connect)執(zhí)行返回wrapper。裝飾器工廠的本質(zhì)是把參數(shù)綁定推遲到真正裝飾的時刻。實(shí)現(xiàn)的時候別忘了給最內(nèi)層wrapper加上functools.wraps否則你的重試裝飾器本身就需要重試。類裝飾器與可調(diào)用實(shí)例函數(shù)不是唯一的裝飾器來源。類也可以扮演裝飾器因?yàn)轭愂强烧{(diào)用的。當(dāng)你寫register時register可以是一個類它的構(gòu)造函數(shù)接收被裝飾的函數(shù)。這種方式的優(yōu)勢在于可以利用類的狀態(tài)保存更多上下文。class CountCalls: def __init__(self, func): self.func func self.calls 0 def __call__(self, args, kwargs): self.calls 1 return self.func(args, kwargs) CountCalls def f(): return hi這里f會變成CountCalls的實(shí)例每次調(diào)用f()觸發(fā)的其實(shí)是__call__。類裝飾器把“狀態(tài)”和“行為”捆綁在一起比閉包變量更清晰。但要注意類裝飾器實(shí)例一旦創(chuàng)建原函數(shù)信息同樣需要復(fù)制最好在__init__里調(diào)用functools.update_wrapper(self, func)。另外如果類裝飾器還需要參數(shù)那就要構(gòu)造一個返回裝飾器類的工廠那層嵌套就不會比函數(shù)版本更輕松。堆疊裝飾器的順序陷阱裝飾器可以疊加代碼讀起來像自上而下的流水線。但執(zhí)行順序恰恰相反離函數(shù)最近的裝飾器最先執(zhí)行然后逐層向外包裹。舉個例子auth log def view(): pass實(shí)際過程是view auth(log(view))。請求到達(dá)view時先經(jīng)過最外層的auth它通過后才會進(jìn)入log包裝的調(diào)用鏈。這個方向很容易被人記反導(dǎo)致權(quán)限校驗(yàn)實(shí)際發(fā)生在日志記錄之后產(chǎn)生安全漏洞。理解裝飾器堆疊順序的唯一可靠方法是記住“洋蔥模型”。畫一張橫截面圖最里層是原函數(shù)每貼一個裝飾器就包一圈。現(xiàn)在再看裝飾器代碼從下往上讀就是執(zhí)行順序。裝飾器與參數(shù)簽名偽造的邊界有些裝飾器會修改函數(shù)簽名比如添加可選參數(shù)或者去除某個參數(shù)。這讓inspect.signature變得棘手。即使有functools.wraps它也只是復(fù)制原簽名不會自動反應(yīng)新加的參數(shù)。如果你的裝飾器改變了調(diào)用約定你需要通過__wrapped__屬性暴露原函數(shù)讓需要真實(shí)簽名的框架鉆到內(nèi)部去。標(biāo)準(zhǔn)庫functools提供了update_wrapper來設(shè)置這個屬性wraps默認(rèn)也會做。當(dāng)裝飾器打算改變函數(shù)簽名時請務(wù)必明確設(shè)置__wrapped__否則內(nèi)省工具將被誤導(dǎo)。一些靈活的參數(shù)控制通過args, kwargs輕松實(shí)現(xiàn)但這會讓IDE的自動補(bǔ)全失效。要同時保持外部簽名不變Python 3.10以后出現(xiàn)了一個利器inspect.signature的follow_wrapped參數(shù)以及函數(shù)上的__signature__屬性。你可以給wrapper設(shè)置一個自定義的__signature__對象強(qiáng)行校準(zhǔn)簽名。這是很高階的用法多數(shù)項(xiàng)目不必涉及。如果真到了這一步先停下來問自己是否應(yīng)該用其他手段替代裝飾器裝飾器常被濫用的地方裝飾器天生適合橫切關(guān)注點(diǎn)比如計時、緩存、重試、權(quán)限校驗(yàn)、事務(wù)邊界。但很多開發(fā)者習(xí)慣用它去修改業(yè)務(wù)邏輯內(nèi)部的計算方式這就走偏了。曾有同事寫了一個裝飾器把返回值里的所有字符串自動轉(zhuǎn)成大寫結(jié)果下游模塊全都跟著遭殃。裝飾器應(yīng)當(dāng)關(guān)注函數(shù)之外的“切面”而不是改變函數(shù)本身的核心語義。一個黃金法則是如果裝飾器的目的不能用一個動詞短語簡明描述比如“記錄耗時”“驗(yàn)證權(quán)限”那它很可能在強(qiáng)行塞職責(zé)。此外裝飾器執(zhí)行順序帶來的副作用也容易被忽略。裝飾器在模塊加載時立即執(zhí)行而不是在函數(shù)調(diào)用時。這意味著裝飾器內(nèi)部任何頂層代碼——甚至只是print——都會在import階段觸發(fā)。在裝飾器工廠里執(zhí)行I/O或網(wǎng)絡(luò)請求是災(zāi)難性的即使只是在模塊頂層創(chuàng)建裝飾器對象如果執(zhí)行代價高也會拖慢導(dǎo)入速度。用裝飾器建立可組合的管線裝飾器強(qiáng)大在于能夠把多個橫切邏輯優(yōu)雅地組合起來。想象一個Web服務(wù)一個視圖函數(shù)可能需要被限流、需要記錄慢查詢、需要做冪等控制。與其把一堆try/except堆進(jìn)函數(shù)體不如把每一條職責(zé)做成獨(dú)立裝飾器按需堆疊。這樣函數(shù)體干凈得就像一篇散文每個裝飾器又都經(jīng)過獨(dú)立測試。這種設(shè)計的哲學(xué)是把重復(fù)的樣板代碼提升為聲明式的元數(shù)據(jù)讓函數(shù)忠于業(yè)務(wù)。但組合越多調(diào)用鏈越長性能損耗和調(diào)試難度也會隨之積累。濫用裝飾器組合會讓調(diào)用棧深得令人窒息。對經(jīng)驗(yàn)尚淺的團(tuán)隊與其設(shè)計一個靈活的“裝飾器框架”不如限制裝飾器的數(shù)量。可以在代碼評審中約定同一個函數(shù)上堆疊的裝飾器不超過3個。超出時考慮把多個職責(zé)合成為一個裝飾器或者改用其他模式比如中間件。畢竟裝飾器的嵌套表達(dá)力雖不至于像lambda那么難讀但五六層包裹后閱讀者只能靠猜來還原執(zhí)行流程。最佳實(shí)踐清單讓裝飾器健康長壽綜合來看寫出“不會害人”的裝飾器需要遵守一些樸素原則。第一條永遠(yuǎn)用functools.wraps保留原函數(shù)元信息這是最低成本的保險。第二條裝飾器的進(jìn)出都要保持同一個接口接受任意參數(shù)返回被裝飾函數(shù)的調(diào)用結(jié)果不要擅自吞掉異常或修改返回值類型除非職責(zé)明確。第三條裝飾器的名稱要能準(zhǔn)確揭示行為避免取名process或handle這樣模糊的名字。第四條優(yōu)先使用類裝飾器表達(dá)帶狀態(tài)邏輯但讓類實(shí)現(xiàn)__call__后返回類裝飾器更容易維護(hù)內(nèi)部可變狀態(tài)。還要謹(jǐn)慎對待裝飾器中的異常。一個計時裝飾器成功運(yùn)行后如果原函數(shù)拋了異常你是打印日志后繼續(xù)拋出還是記錄完后靜默吞掉吞異常會讓最嚴(yán)重的問題消失得無聲無息。裝飾器應(yīng)當(dāng)在無副作用地記錄失敗后原樣拋出原異常。類似地如果你想在裝飾器里做緩存一定要考慮可變對象的拷貝問題別讓緩存對象被業(yè)務(wù)代碼修改否則下一次調(diào)用就會讀到臟數(shù)據(jù)。另一個關(guān)鍵點(diǎn)是文檔。裝飾器自身要有docstring但裝飾器包裝后的函數(shù)也可能因?yàn)閣raps帶上原函數(shù)docstring。這會導(dǎo)致help顯示混亂。業(yè)界傾向在裝飾器工廠的docstring中寫下明確的“簽名說明”和“行為變更”并用functools.wraps讓內(nèi)層函數(shù)顯示被包裝函數(shù)的文檔。對于用戶來說更好的做法是遵循PEP 318的哲學(xué)裝飾器只是語法糖不要在文檔里隱藏太多奇跡。測試裝飾器必須測試也必須會繞過裝飾器和被裝飾函數(shù)橫切纏結(jié)測試時首先要驗(yàn)證的不只是原函數(shù)邏輯還有包裝后的邏輯。一種簡單做法是分別調(diào)用裝飾器內(nèi)外兩層用decorator(func)直接生成包裝函數(shù)然后測試它。同時在測試?yán)镌O(shè)置functools.wraps設(shè)置的__wrapped__屬性通過func.__wrapped__訪問原始函數(shù)這樣就能繞過裝飾器只測核心。__wrapped__屬性不只是留給框架的也是留給測試的逃生通道。當(dāng)裝飾器依賴外部狀態(tài)比如當(dāng)前登錄用戶測試中必須能夠替換這些狀態(tài)??梢园岩蕾囋O(shè)計成帶默認(rèn)參數(shù)的形式或用上下文變量來傳遞。不要用裝飾器去捕獲全局單例而應(yīng)通過參數(shù)注入。這類設(shè)計問題通常會在寫測試時暴露無遺——如果測試很難構(gòu)造一個不受污染的調(diào)用環(huán)境那么裝飾器的耦合性已經(jīng)亮起了紅燈。性能損耗真的可以忽略嗎每次函數(shù)調(diào)用經(jīng)過新的包裝層必然帶來額外開銷。一個裸函數(shù)調(diào)用要壓棧、彈棧經(jīng)過裝飾器后還要多幾次屬性查找和函數(shù)調(diào)用。對于高頻調(diào)用的手段比如循環(huán)內(nèi)百萬次操作裝飾器可能成為明顯的瓶頸。把計時裝飾器用在每個請求上并無大礙但如果用它包裹一個每毫秒執(zhí)行幾十次的小函數(shù)性能問題就會被放大。優(yōu)化裝飾器性能的思路不是去掉裝飾器而是讓裝飾器越薄越好——盡量在閉包中提前綁定變量、避免在每次調(diào)用時處理不必要的數(shù)據(jù)。Python 3的functools.lru_cache自帶緩存功能內(nèi)部使用字典比手寫的快速很多。寫裝飾器時優(yōu)先考慮標(biāo)準(zhǔn)庫方案而不是重復(fù)造輪子。如果你的裝飾器需要判斷參數(shù)類型或做繁重的摘要計算先想想能否把這些計算放到裝飾器工廠階段而不是每次調(diào)用都執(zhí)行。記住裝飾器工廠在導(dǎo)入時執(zhí)行內(nèi)層包裝在每次調(diào)用時執(zhí)行善用這個區(qū)別能省下大量CPU周期。深入理解時的最后一個領(lǐng)域參數(shù)注入與上下文體還有一種裝飾器設(shè)計模式叫“參數(shù)注入”它會檢查原函數(shù)請求哪些關(guān)鍵字參數(shù)并為其填充默認(rèn)上下文。典型例子是Flask的app.route并不是這種但Web框架里的get_current_user卻常用到。實(shí)現(xiàn)這種裝飾器需要對參數(shù)名做靜態(tài)分析簽名較脆弱。Python 3的inspect.signature可以綁定(bind)參數(shù)但用在裝飾器內(nèi)部時要謹(jǐn)慎處理與原函數(shù)參數(shù)沖突。參數(shù)注入裝飾器會重構(gòu)函數(shù)簽名因此它是最克制、最難優(yōu)雅化的裝飾器類型。如果業(yè)務(wù)能改用顯式參數(shù)沒人會選擇這種隱式的魔法。但當(dāng)下很多現(xiàn)代框架比如FastAPI利用inspect加上裝飾器把參數(shù)注入變成強(qiáng)大功能。這就是權(quán)衡的展示裝飾器適合做框架和業(yè)務(wù)之間的橋梁但不適合做業(yè)務(wù)內(nèi)部的數(shù)據(jù)流管道。認(rèn)清這種邊界你才算真正深入理解了Python的裝飾器。它們是從一個函數(shù)變換成另一個函數(shù)的工具簡潔、抽象、容易被誤用。當(dāng)你肯花時間研究functools.wraps、閉包變量、堆疊順序、簽名內(nèi)省這些問題時說明你已經(jīng)開始自覺地從“能寫”走向“會寫”。