Python中閉包變量作用域踩坑實錄
一個讓我加班兩小時的Bug
先給你講個真實故事。
上周三晚上十一點,我還在工位上盯著屏幕。面前是一個跑了好幾天都沒問題的Python腳本,今天突然出了個詭異的bug。
代碼邏輯很簡單:用循環(huán)創(chuàng)建幾個函數(shù),每個函數(shù)記住自己對應(yīng)的數(shù)字。
我當時寫的大概是這樣:
funcs = []
for i in range(3):
def f():
print(i)
funcs.append(f)
for f in funcs:
f()
我本以為會輸出:
0
1
2
結(jié)果你猜怎么著?
2
2
2
三個2。
我當時第一反應(yīng)是:“這不可能”。Python這么簡單的循環(huán)閉包怎么可能出錯?一定是我哪里寫錯了。
檢查了三遍代碼,沒錯。又檢查了三遍,還是沒錯。
那一刻我感覺自己像是個剛開始學(xué)Python的新手。
后來查了半小時資料,才發(fā)現(xiàn)——原來是我對閉包的理解一直有問題。
今天就把這個坑徹底講清楚。
先搞明白:什么是閉包?
先說定義。閉包就是一個函數(shù),它記住了外部作用域的變量。
舉個例子:
def outer():
msg = "hello"
def inner():
print(msg)
return inner
my_func = outer()
my_func() # 輸出 hello
這里的 inner 就是一個閉包。它記住了 outer 函數(shù)里的 msg 變量,即使 outer 已經(jīng)執(zhí)行完了。
看起來很簡單對吧?那為什么開頭的例子會出問題?
問題核心:變量什么時候被“記住”?
很多人(包括以前的我)以為:定義函數(shù)的時候,它就會記住當前變量的值。
錯。
實際上:函數(shù)被調(diào)用的時候,才會去查找變量的值。
這就是關(guān)鍵。
看回開頭那個例子:
funcs = []
for i in range(3):
def f():
print(i)
funcs.append(f)
循環(huán)創(chuàng)建了三個函數(shù),每個函數(shù)都在說“我要打印變量 i 的值”。
但這個 i 是誰?不是每個函數(shù)的私有拷貝,而是同一個變量 i。
循環(huán)結(jié)束后,i 的值變成了 2。
然后你調(diào)用這些函數(shù):
for f in funcs:
f()
每個函數(shù)都去查找當前環(huán)境里變量 i 的值——找到了,是 2。
于是三個函數(shù)都打印 2。
這就是真相:閉包捕獲的是變量本身,不是變量的值。
用比喻理解這個問題
想象你在一棟寫字樓里。
第1層、第2層、第3層各有一家公司。每家公司都裝了一個顯示屏,顯示“當前樓層號”。
本來每層的顯示屏應(yīng)該顯示不同的數(shù)字:1、2、3。
但現(xiàn)在有個問題——這三塊顯示屏都連著同一個傳感器,這個傳感器告訴你“當前電梯停在哪一層”。
你一開始把電梯停到1樓,傳感器顯示1。但突然有人按了電梯,電梯跑到2樓了。傳感器現(xiàn)在顯示2,三塊顯示屏同時更新成2。電梯再跑到3樓,三塊屏又同時變成3。
最后電梯停在2樓不動了,三塊屏就都顯示2。
你是不是覺得這樣設(shè)計很蠢?每個樓層明明應(yīng)該有自己的數(shù)字。
對,Python這個行為就有點像那個“共用傳感器”的設(shè)計。
常見的錯誤寫法
這種錯誤不只出現(xiàn)在循環(huán)里。來看另一個場景:
def create_multipliers():
multipliers = []
for n in [1, 2, 3]:
multipliers.append(lambda x: x * n)
return multipliers
for m in create_multipliers():
print(m(5))
你可能會以為輸出:
5
10
15
實際輸出:
15
15
15
因為 n 最終變成了 3,三個 lambda 用的都是最后那個 n。
解決方案一:默認參數(shù)
最常見的修復(fù)方法:給函數(shù)加一個默認參數(shù)。
funcs = []
for i in range(3):
def f(i=i): # 注意這里
print(i)
funcs.append(f)
for f in funcs:
f() # 輸出 0 1 2
為什么這樣就可以了?
因為 Python 的默認參數(shù)在函數(shù)定義時就會被求值。
也就是說,當循環(huán)執(zhí)行到 i=0 時,f(i=i) 里面的第二個 i 被立即計算成數(shù)字 0,然后作為默認值綁定到這個函數(shù)上。
每個函數(shù)有了自己獨立的默認值,不再依賴外部的變量 i。
解決方案二:functools.partial
如果你覺得默認參數(shù)寫法有點“取巧”,可以用更明確的 partial:
from functools import partial
funcs = []
for i in range(3):
def f(x):
print(x)
funcs.append(partial(f, i))
for f in funcs:
f()
partial 就是提前把參數(shù)固定住,生成一個新函數(shù)。
解決方案三:再包一層函數(shù)
也可以再包一層函數(shù)來“凍結(jié)”當前的值:
funcs = []
for i in range(3):
def outer(i):
def inner():
print(i)
return inner
funcs.append(outer(i))
for f in funcs:
f()
當 outer(i) 被調(diào)用時,參數(shù) i 的值被固定在這個局部作用域里。后面的閉包 inner 記住的是這個局部變量,不是循環(huán)里的那個 i。
為什么初學(xué)者特別容易踩這個坑?
因為很多其他語言在類似的場景下行為不同。
比如 JavaScript(ES6 之前)也有類似問題,但如果你用 let 聲明循環(huán)變量,每次迭代會創(chuàng)建一個新的綁定。
Python 的 for 循環(huán)不會為每次迭代創(chuàng)建新的作用域。循環(huán)里的變量就是同一個變量,反復(fù)被賦新值。
而且 Python 的 lambda 和嵌套函數(shù)語法很簡潔,讓人覺得好像“隨手一寫就行”,結(jié)果正好踩坑。
更隱蔽的坑:修改外部變量
閉包默認只能讀取外部變量。如果想修改,需要 nonlocal 關(guān)鍵字。
看這個例子:
def counter():
count = 0
def increment():
count += 1 # 報錯!
return count
return increment
c = counter()
c()
運行會報錯:
UnboundLocalError: local variable 'count' referenced before assignment
因為 count += 1 相當于 count = count + 1,Python 在函數(shù)內(nèi)部看到 count = 就會認為 count 是一個局部變量。但局部變量 count 還沒來得及賦值就被引用了。
修復(fù)方法:
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
nonlocal 告訴 Python:“這個 count 不是我的局部變量,去外一層作用域找”。
閉包的內(nèi)存陷阱
還有一個容易被忽略的點:閉包會一直持有外部變量,即使外部函數(shù)已經(jīng)返回了。
看這個例子:
def outer():
large_data = [0] * 10000000 # 一個大列表
def inner():
return len(large_data)
return inner
func = outer()
# 此時 large_data 應(yīng)該被銷毀嗎?并不會
# 因為 inner 還在引用它
large_data 不會被垃圾回收,只要 func 還存在。
如果你不小心在循環(huán)里創(chuàng)建了一堆閉包,每個都引用了大對象,內(nèi)存可能會暴漲。
實用的調(diào)試技巧
當你懷疑閉包變量出問題時,可以用 __closure__ 屬性檢查:
funcs = []
for i in range(3):
def f():
print(i)
funcs.append(f)
for f in funcs:
print(f.__closure__)
輸出類似:
(<cell at 0x...: int object at 0x...>,)
(<cell at 0x...: int object at 0x...>,)
(<cell at 0x...: int object at 0x...>,)
你可以進一步查看閉包里的值:
for f in funcs:
print(f.__closure__[0].cell_contents)
輸出:
2
2
2
果然,三個閉包引用的都是同一個 cell 對象,里面存著 2。
這個工具在排查復(fù)雜閉包問題時非常有用。
再看一個經(jīng)典面試題
很多 Python 面試會考這個:
def create_funcs():
return [lambda x: x * i for i in range(5)]
for f in create_funcs():
print(f(2))
輸出是什么?
如果你讀到這里,應(yīng)該能立刻說出答案:
8
8
8
8
8
因為 i 最終是 4,2*4=8。
修復(fù)方法:
def create_funcs():
return [lambda x, i=i: x * i for i in range(5)]
或者:
def create_funcs():
return [lambda x, n=i: x * n for i in range(5)]
總結(jié):記住這三句話就夠了
- 閉包捕獲的是變量本身,不是變量的值。 變量變了,閉包看到的值也跟著變。
- 默認參數(shù)在定義時求值。 想“凍結(jié)”當前值,就用默認參數(shù)。
- 修改外部變量要加 nonlocal。 否則 Python 會當成本地變量處理。
這三點說起來簡單,但每一條背后都有實際踩坑的故事。
回到開頭那個讓我加班到深夜的Bug。最后怎么解決的?用了默認參數(shù),三行代碼,一分鐘改完。
但為了搞清楚“為什么”,花了兩小時。
很多時候就是這樣——改代碼很快,真正花時間的是理解“原來我一直想錯了”。
希望這篇文章能幫你省下那兩小時。
下次寫閉包的時候,多問自己一句:“我捕獲的是變量,還是變量的值?”
想清楚這個問題,這個坑就再也坑不到你了。
以上就是Python中閉包變量作用域踩坑實錄的詳細內(nèi)容,更多關(guān)于Python踩坑實錄的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Python中免驗證跳轉(zhuǎn)到內(nèi)容頁的實例代碼
在本篇文章里小編給大家整理的是一篇關(guān)于Python中免驗證跳轉(zhuǎn)到內(nèi)容頁的實例代碼,有興趣的朋友們可以學(xué)習(xí)分享下。2020-10-10
對比分析BN和dropout在預(yù)測和訓(xùn)練時區(qū)別
這篇文章主要為大家介紹了對比分析BN和dropout在預(yù)測和訓(xùn)練時區(qū)別,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-05-05
Python列表list常用內(nèi)建函數(shù)實例小結(jié)
這篇文章主要介紹了Python列表list常用內(nèi)建函數(shù),結(jié)合實例形式總結(jié)分析了Python列表list常見內(nèi)建函數(shù)的功能、使用方法及相關(guān)操作注意事項,需要的朋友可以參考下2019-10-10
Python生命游戲?qū)崿F(xiàn)原理及過程解析(附源代碼)
這篇文章主要介紹了Python生命游戲?qū)崿F(xiàn)原理及過程解析(附源代碼),文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-08-08
Python操作SQLite數(shù)據(jù)庫過程解析
這篇文章主要介紹了Python操作SQLite數(shù)據(jù)庫過程解析,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-09-09
Python辦公自動化之將任意文件轉(zhuǎn)為PDF格式
這種把某個文件轉(zhuǎn)為pdf枯燥無聊的工作,既沒有什么技術(shù)含量又累. 今天辰哥就教大家將任意文件批量轉(zhuǎn)為PDF,這里以日常辦公的word、excel、ppt為例,這三種格式的文件轉(zhuǎn)為PDF.需要的朋友可以參考下2021-06-06
python?爬取豆瓣電影短評并利用wordcloud生成詞云圖
這篇文章主要介紹了python?爬取豆瓣電影短評并利用wordcloud生成詞云圖,文章圍繞主題展開詳細的內(nèi)容介紹,具有一定的參考價值,需要的小伙伴可以參考一下2022-06-06

