完全指南:從原理到裝飾器實戰(zhàn)與避坑)
做Python開發(fā)到現(xiàn)在閉包函數(shù)這個概念我一直覺得是“會者不難難者不會”的典型代表。很多朋友學(xué)Python學(xué)了很久函數(shù)、類、裝飾器都用得飛起但一被問到“閉包是什么”就有點含糊。其實閉包在Python里非常常用尤其是寫裝飾器、回調(diào)函數(shù)、延遲計算、狀態(tài)保持這類場景時它幾乎是繞不開的底層機制。這篇博文我就從實際使用角度把Python里的閉包函數(shù)徹底講透包含原理、典型用法、各種坑以及我踩過的一些經(jīng)驗教訓(xùn)希望能幫到正在啃這個知識點的初學(xué)者也能給已經(jīng)會用但沒深究過原理的朋友提供一點新視角。1. 閉包函數(shù)是什么先理清作用域再說閉包1.1 從嵌套函數(shù)和作用域規(guī)則開始閉包不是Python特有的概念但Python對它的支持非常自然。想理解閉包首先得理解Python的作用域規(guī)則。你可能已經(jīng)知道函數(shù)內(nèi)部可以訪問全局變量也可以訪問局部變量。但函數(shù)內(nèi)部還能再定義函數(shù)這個內(nèi)層函數(shù)訪問外層函數(shù)變量的時候事情就變得有意思了。def outer(x): y 10 def inner(): return x y return inner fn outer(5) print(fn()) # 15這里inner定義在outer內(nèi)部它使用了outer里的參數(shù)x和局部變量y。按說outer執(zhí)行完、棧幀彈出后x和y應(yīng)該都“消失”了但實際調(diào)用fn()時依然能正確拿到x5和y10并算出15。這就是閉包的基本形態(tài)內(nèi)層函數(shù)捕獲了外層函數(shù)的環(huán)境變量并且在外部函數(shù)返回之后仍然保存著這個環(huán)境。作用域規(guī)則的補充知識可以看這里Python查找變量時遵循LEGB原則即局部Local、閉包Enclosing、全局Global、內(nèi)置Built-in依次查找。inner里找不到x和y時就會去外層函數(shù)outer的作用域里找也就是“Enclosing”這一層。閉包之所以能工作是因為Python在函數(shù)對象創(chuàng)建時把它引用的外部變量打包進了函數(shù)對象自身的屬性里而不是依賴調(diào)用時的棧幀。1.2 閉包的核心三要素嵌套函數(shù)、引用外部變量、返回內(nèi)層函數(shù)網(wǎng)上對閉包的定義很多我自己的理解可以歸納成三個必備條件缺一個都不能稱為嚴格意義上的閉包必須存在函數(shù)嵌套即一個函數(shù)定義在另一個函數(shù)內(nèi)部。內(nèi)層函數(shù)必須引用了外層函數(shù)作用域里的變量包括參數(shù)。外層函數(shù)必須把內(nèi)層函數(shù)作為返回值返回或者以某種方式傳遞出去讓內(nèi)層函數(shù)在外層函數(shù)執(zhí)行完畢后還能被調(diào)用。三個條件對應(yīng)到代碼上就是經(jīng)典的“閉包工廠”模式def make_adder(n): def add(x): return x n return add add_10 make_adder(10) add_20 make_adder(20) print(add_10(3)) # 13 print(add_20(3)) # 23make_adder(10)返回的add函數(shù)就和參數(shù)n10綁定在了一起make_adder(20)則綁定的是n20。兩個函數(shù)雖然是同一個工廠生產(chǎn)出來的但各自保存著獨立的n互不干擾。這種“函數(shù)作為一個帶狀態(tài)的小工具”的思路正是閉包最大的價值所在。打個比方閉包就像給一個普通函數(shù)掛上了一個隨身小背包。函數(shù)每次被調(diào)用時背包里的東西都跟著它別的地方再創(chuàng)建一個新函數(shù)就有另一個新背包誰也不搶誰的。這個類比雖然簡單但能幫初學(xué)朋友快速理解“為什么函數(shù)還能記住變量”。2. 為什么需要閉包三個典型應(yīng)用場景2.1 保留狀態(tài)用閉包寫計數(shù)器告別全局變量最直觀的閉包用途就是做一個帶狀態(tài)的小函數(shù)。比如你需要一個計數(shù)器每次調(diào)用就加一初學(xué)階段第一反應(yīng)往往是搞一個全局變量count 0 def add(): global count count 1 return count全局變量的問題在于誰都能改改亂了很難查程序里計數(shù)器一多就得創(chuàng)建一堆全局名字很容易命名沖突。用閉包可以把這個狀態(tài)封閉在函數(shù)內(nèi)部def make_counter(): count 0 def counter(): nonlocal count count 1 return count return counter c1 make_counter() c2 make_counter() print(c1()) # 1 print(c1()) # 2 print(c2()) # 1這里c1和c2是兩個完全獨立的計數(shù)器狀態(tài)互不干擾。nonlocal關(guān)鍵字的作用稍后詳細說它用來聲明“我要修改的是外層函數(shù)的變量count”如果沒有它count 1會被Python解釋為創(chuàng)建了一個新的局部變量count結(jié)果就是UnboundLocalError。這種做法的好處有兩個一是狀態(tài)被封裝外部沒辦法直接篡改計數(shù)器的內(nèi)部值二是不污染全局命名空間。實際工作中我用類似思路做過很多輕量級的狀態(tài)管理比如記錄某個接口的調(diào)用次數(shù)、統(tǒng)計批處理任務(wù)的成功數(shù)失敗數(shù)等比到處定義全局變量干凈太多。2.2 延遲執(zhí)行把函數(shù)和參數(shù)打包到點再觸發(fā)閉包的另一個常見場景是延遲執(zhí)行。比如你要給一堆任務(wù)注冊回調(diào)這些回調(diào)需要帶上各自不同的參數(shù)但又不想在注冊時立刻執(zhí)行而是等某個事件觸發(fā)后再調(diào)用。def register_task(name, delay): def task(): print(f執(zhí)行任務(wù) {name}延遲 {delay} 秒) # 這里可以寫真正的任務(wù)邏輯 return task tasks [] tasks.append(register_task(備份數(shù)據(jù)庫, 10)) tasks.append(register_task(清理臨時文件, 30)) # 什么都不做任務(wù)已經(jīng)準備好了 # 等到某個時機... for t in tasks: t()register_task把name和delay打包進返回的task函數(shù)里后續(xù)執(zhí)行時不需要再傳參數(shù)。這種模式在定時任務(wù)、事件驅(qū)動框架、GUI按鈕回調(diào)里極其常見。你可以把閉包理解成一種“提前把參數(shù)封裝好”的函數(shù)偏應(yīng)用雖然Python里有functools.partial能做類似的事但閉包更自由因為它還可以攜帶額外狀態(tài)和邏輯。2.3 裝飾器的本質(zhì)就是一個閉包說到閉包最繞不開的應(yīng)用就是裝飾器。Python裝飾器的標準寫法是這樣的def my_decorator(func): def wrapper(*args, **kwargs): print(函數(shù)調(diào)用前) result func(*args, **kwargs) print(函數(shù)調(diào)用后) return result return wrapper my_decorator def say_hello(name): return fHello, {name} print(say_hello(Python))拆開看my_decorator就是外層函數(shù)wrapper就是內(nèi)層函數(shù)它引用了外層傳入的funcmy_decorator最后返回wrapper。這就是一個標準閉包。my_decorator語法糖只是把say_hello my_decorator(say_hello)這行代碼隱藏了起來。理解了閉包裝飾器就不再是神秘魔法了它就是一個“接收函數(shù)、返回新函數(shù)”的普通高階函數(shù)而已。我見過不少初學(xué)者死記裝飾器模板問為什么wrapper要用*args, **kwargs也答不上來。其實因為被裝飾的函數(shù)參數(shù)千變?nèi)f化閉包捕獲了func卻不知道調(diào)用方會傳什么進來只能用不定參數(shù)兜底再原樣轉(zhuǎn)發(fā)給func。這種“轉(zhuǎn)發(fā)”模式就是裝飾器能通用化的關(guān)鍵。3. 閉包的核心機制自由變量和__closure__3.1 自由變量存哪去了__closure__里都有答案Python的每一個函數(shù)對象都有屬性其中__closure__這個屬性專門用來存放閉包捕獲的外部變量。它是一個元組每個元素是一個cell對象cell.cell_contents就是變量當前的值。def outer(x): y 10 def inner(): return x y return inner fn outer(42) print(fn.__closure__) # (cell at 0x...: int object at 0x..., cell at 0x...: int object at 0x...) print(fn.__closure__[0].cell_contents) # 42 print(fn.__closure__[1].cell_contents) # 10調(diào)試閉包問題時直接打印__closure__非常容易定位問題。比如你覺得閉包里的變量值不對勁一看cell_contents就真相大白。還要注意__closure__捕獲的是變量本身不是變量在某個時刻的快照。也就是說外層函數(shù)后續(xù)把這個變量修改了內(nèi)層函數(shù)看到的是最新值這就引出了經(jīng)典的“循環(huán)變量捕獲陷阱”。3.2 經(jīng)典陷阱循環(huán)里的閉包為什么全是同一個值很多Python面試都會問這道題funcs [] for i in range(3): def f(): return i funcs.append(f) for f in funcs: print(f())輸出是2 2 2不是0 1 2。原因就是閉包捕獲的是i這個變量本身。循環(huán)結(jié)束后i的值停在2所有f都在讀取同一個i所以結(jié)果全是2。想解決核心思路是讓每個f捕獲不同的變量常見做法是使用默認參數(shù)funcs [] for i in range(3): def f(ii): return i funcs.append(f)這里的玄機在于默認參數(shù)的值是在函數(shù)定義時計算并綁定到函數(shù)對象上的所以f(ii)把當前循環(huán)里的具體數(shù)值“凍結(jié)”成了默認值。另一個更現(xiàn)代的做法是用functools.partialfrom functools import partial funcs [] for i in range(3): funcs.append(partial(lambda x: x, i))或者干脆用lambda默認參數(shù)lambda ii: i。我個人的習慣是遇到循環(huán)創(chuàng)建閉包第一時間想想“我到底要捕獲值還是捕獲變量”。捕獲值用默認參數(shù)捕獲變量才讓閉包自然引用。這個思維方式能幫你避開大多數(shù)閉包陷阱。3.3nonlocal獨家經(jīng)驗和作用域小抄nonlocal關(guān)鍵字的職責是告訴Python當前作用域內(nèi)有個變量名我要修改它但它不是本地的去外層函數(shù)找。這里有一張我整理的作用域修改規(guī)則表聲明關(guān)鍵字查找位置典型使用場景無最內(nèi)層局部作用域找不到再按LEGB查找只讀外部變量global全局作用域修改模塊級變量nonlocal最近的外層函數(shù)作用域修改閉包外層的自由變量一個容易忽略的坑如果外層變量是整數(shù)、字符串、元組這類不可變對象內(nèi)層函數(shù)直接count 1一定會被判定為創(chuàng)建局部變量進而報錯。此時必須有nonlocal count。但如果外層變量是列表、字典這類可變對象內(nèi)層函數(shù)執(zhí)行data.append(1)就沒問題因為這里只調(diào)用了對象的方法沒有重新綁定變量名。這兩種情況的行為差異是很多人寫閉包時最常卡住的地方。4. 閉包實戰(zhàn)計數(shù)器工廠、計時裝飾器、手寫緩存4.1 帶重置功能的計數(shù)器狀態(tài)封閉但能力可控閉包的一大特點是把狀態(tài)“藏”起來但有時候我們又希望提供一些受控的修改能力。一個很實用的設(shè)計是返回一個函數(shù)集或者用一個函數(shù)加標志位來模擬。下面這個計數(shù)器工廠我給它增加了重置功能def make_counter(): count 0 def get(): nonlocal count return count def inc(): nonlocal count count 1 return count def reset(): nonlocal count count 0 return get, inc, reset get_count, increment, reset_count make_counter() print(increment()) # 1 print(increment()) # 2 print(get_count()) # 2 reset_count() print(get_count()) # 0這種寫法相當于把內(nèi)部狀態(tài)和操作一起暴露成一個微型API。實際工作中我用它做過限流器里的滑動窗口計數(shù)、批處理任務(wù)中的進度統(tǒng)計等場景。和定義一個類比起來閉包方案更輕量尤其當你只需要一兩個方法時寫在函數(shù)內(nèi)部比搞一個完整類要簡潔很多。4.2 手寫一個計時裝飾器理解裝飾器性能開銷計時裝飾器應(yīng)該是每個Python開發(fā)者都寫過的小工具?;陂]包實現(xiàn)如下import time def timer(func): def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost time.perf_counter() - start print(f{func.__name__} 耗時 {cost:.6f} 秒) return result return wrapper timer def slow_work(n): total 0 for i in range(n): total i ** 2 return total print(slow_work(100000))注意這里有幾個細節(jié)一是用time.perf_counter()而不是time.time()因為perf_counter專門用來測量短時間間隔精度更高不容易受到系統(tǒng)時鐘調(diào)整的影響二是通過func.__name__保存函數(shù)名方便輸出日志三是wrapper用了*args, **kwargs確保能轉(zhuǎn)發(fā)任意參數(shù)。另外wrapper.__name__默認會變成wrapper如果你想保留原函數(shù)名可以用functools.wraps(func)裝飾一下wrapper這是閉包實戰(zhàn)里一個幾乎必做的修正。4.3 手寫memoize緩存用閉包給所有函數(shù)加性能增益閉包能保存狀態(tài)自然也能用來做緩存。下面這個memoize裝飾器會把函數(shù)的入?yún)⒑头祷刂涤涗浵聛碛龅较嗤瑓?shù)直接返回緩存結(jié)果def memoize(func): cache {} def wrapper(*args): if args not in cache: cache[args] func(*args) return cache[args] return wrapper memoize def fib(n): if n 2: return n return fib(n - 1) fib(n - 2) print(fib(30)) # 832040速度極快cache是外層函數(shù)的局部字典被wrapper捕獲后只要wrapper對象不銷毀緩存就一直存在。這里我用的是元組作為字典鍵所以wrapper接收的參數(shù)必須是可哈希的。這個手寫版本比較簡單真正生產(chǎn)環(huán)境我會直接用functools.lru_cache它自帶最大緩存容量和線程安全設(shè)計。但手寫一遍的價值在于充分理解原理lru_cache本質(zhì)上也是閉包加字典緩存只是封裝得更好而已。5. 常見坑與排查技巧實錄5.1 報錯集錦UnboundLocalError和NameError的區(qū)別閉包最常見的報錯就是UnboundLocalError: local variable count referenced before assignment。這種報錯的原因我在前面提過內(nèi)層函數(shù)里一旦出現(xiàn)count ...這類賦值語句Python就認為count是內(nèi)層的局部變量于是后面讀取count時發(fā)現(xiàn)它還沒賦值直接報錯。解決辦法是加nonlocal count。還有一種容易混淆的情況是外層函數(shù)根本沒定義這個變量那就不是UnboundLocalError而是NameError了。排查順序建議是先看變量名是不是在外層函數(shù)里定義過再確認內(nèi)層函數(shù)里是否有賦值操作最后檢查是不是漏了nonlocal聲明。三步走完九成的閉包報錯都能解決。5.2 閉包變量的生命周期和意外共享閉包變量的生命周期隨函數(shù)對象走。只要還有引用指向返回的內(nèi)層函數(shù)它捕獲的變量就不會被回收。這個特性在寫長生命周期服務(wù)時尤其要注意如果你把閉包對象存在某個全局列表里那么它捕獲的大量數(shù)據(jù)也會一直活著可能導(dǎo)致內(nèi)存占用不斷上漲。還有一個非常隱蔽的共享問題。如果兩個閉包都捕獲了同一個外層函數(shù)的同一個變量它們其實共享同一個cell。比如def outer(): x 1 def a(): return x def b(): nonlocal x x 1 return x return a, b a, b outer() print(a()) # 1 print(b()) # 2 print(a()) # 2a和b共享同一個xb修改后a也看到了變化。這不是bug是閉包的固有語義但在多閉包協(xié)作時容易忽略。如果你希望每個閉包各自獨立持有狀態(tài)就應(yīng)該為每個閉包單獨調(diào)用一次工廠函數(shù)而不是共享同一個外層變量。5.3 調(diào)試閉包inspect模塊和__closure__聯(lián)合使用調(diào)試閉包問題我的常用手法是嘗試打印函數(shù)對象的__closure__屬性檢查cell的個數(shù)和值是否符合預(yù)期。另外inspect模塊能幫上大忙import inspect def outer(x): y 10 def inner(): return x y return inner fn outer(1) print(inspect.getclosurevars(fn)) # ClosureVars(nonlocals{x: 1, y: 10}, globals{}, builtins{}, unboundset())inspect.getclosurevars會清晰列出閉包捕獲的非局部變量、全局變量、內(nèi)置變量以及未綁定變量。相比直接看__closure__元組這個輸出更適合快速分析。還有一個小技巧用fn.__code__.co_freevars可以查看閉包捕獲的變量名列表配合__closure__的值列表能準確對應(yīng)“哪個名字對應(yīng)哪個值”。5.4 lambda和閉包混合使用的細節(jié)lambda是Python里最典型的匿名函數(shù)也可以形成閉包。很多時候我們用它來做回調(diào)但要特別留意它捕獲變量時的表現(xiàn)。下面的代碼是典型的“l(fā)ambda遍歷綁定”問題actions [] for i in range(3): actions.append(lambda: print(i)) for act in actions: act() # 輸出 2 2 2原因和之前的循環(huán)閉包陷阱完全一樣lambda捕獲了變量i而不是值i。解決辦法也可以照搬默認參數(shù)actions [] for i in range(3): actions.append(lambda ii: print(i))說句實在話我在團隊代碼評審里看過太多這種問題了。凡是循環(huán)內(nèi)創(chuàng)建lambda或函數(shù)我都會下意識多看一眼變量綁定方式。這不是Python設(shè)計有問題而是理解閉包本質(zhì)后就能規(guī)避的經(jīng)典細節(jié)。6. 閉包的優(yōu)缺點和選型建議6.1 閉包和類的取舍什么時候用哪個閉包能做的事情類通常也能做比如計數(shù)器用類實現(xiàn)也很簡單class Counter: def __init__(self): self.count 0 def inc(self): self.count 1 return self.count那閉包的優(yōu)勢在哪第一輕量。寫一個閉包不需要定義類、不需要__init__、不需要self第二封裝性好。閉包里的變量外部根本拿不到而類屬性至少還能通過_count這類約定來規(guī)避做不到真正隱藏第三實現(xiàn)簡單邏輯時代碼更短。但類也有不可替代的場景需要多個方法協(xié)作、需要繼承多態(tài)、需要__repr__等特殊方法時類更合適。我的選型建議是狀態(tài)簡單、只需要一兩個函數(shù)時用閉包邏輯復(fù)雜、有多種操作需要組織時用類。這個標準不絕對但足夠?qū)嵱谩?.2 閉包的內(nèi)存開銷和引用循環(huán)問題閉包不是零成本的。每個閉包函數(shù)對象都額外持有一個__closure__元組每個cell都引用一個變量。如果一個程序創(chuàng)建了大量閉包比如幾十萬個每個閉包都捕獲了自己的變量內(nèi)存開銷會比普通函數(shù)大不少。在數(shù)據(jù)密集型的高性能場景里需要注意這一點。引用循環(huán)的問題也值得提如果閉包捕獲的對象反向引用了這個函數(shù)對象就可能形成循環(huán)引用。好在Python的垃圾回收器能處理循環(huán)引用但這會讓對象回收延遲。尤其涉及文件句柄、網(wǎng)絡(luò)連接這類需要及時釋放的資源時不要依賴垃圾回收最好顯式關(guān)閉或析構(gòu)。6.3 什么時候不要用閉包閉包雖好用但有幾種情況我會明確避開。一是并發(fā)場景。閉包保存的變量如果沒有鎖保護多線程同時修改會出現(xiàn)數(shù)據(jù)競爭。此時用類加鎖或者直接用threading.local更明確。二是需要序列化的場景。閉包函數(shù)無法被pickle序列化因為Python沒法把一個閉包所依賴的環(huán)境完整打包。如果你要把任務(wù)分發(fā)到多進程或分布式系統(tǒng)閉包方案會直接夭折此時應(yīng)該用模塊級函數(shù)加顯式參數(shù)。三是調(diào)試可讀性要求很高的場景。閉包把狀態(tài)藏得太好有時候反而不利于排查問題。尤其是多層嵌套閉包變量查找路徑變長可讀性顯著下降。遇到這種情況我會考慮用類或者命名函數(shù)替代讓狀態(tài)更透明。結(jié)尾我的一點個人體會用了這么多年P(guān)ython閉包給我的最大感受是它像一把精密的小刀用好了非常趁手用不好容易劃傷自己。我實際項目里用得最多的其實是“配置隔離”這個思路。比如要給多個任務(wù)準備不同的回調(diào)配置用閉包工廠一次性生成互不干擾的處理函數(shù)比傳一堆配置參數(shù)干凈得多。最后分享一個小技巧如果實在記不住閉包三要素就記“嵌套、引用、返回”六個字。寫代碼時拿不準就打印__closure__看一眼。閉包不是玄學(xué)它就是函數(shù)和它隨身攜帶的環(huán)境理解到這一層裝飾器、回調(diào)、緩存這些高級話題都會順暢很多。希望這篇分享對你有用。