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

