def foo(lst): a = 0 for i in lst: a += i b = 1 for t in lst: b *= i return a, b
如果你覺得它的功能是“計(jì)算lst中所有元素的和與積”,不要沮喪。通常很難發(fā)現(xiàn)這里的錯(cuò)誤。如果在大堆真實(shí)的代碼中發(fā)現(xiàn)了這個(gè)錯(cuò)誤就非常厲害了?!?dāng)你不知道這是一個(gè)測試時(shí),很難發(fā)現(xiàn)這個(gè)錯(cuò)誤。
這里的錯(cuò)誤是在第二個(gè)循環(huán)體中使用了i而不是t。等下,這到底是怎么工作的?i在第一個(gè)循環(huán)外應(yīng)該是不可見的? [1]哦,不。事實(shí)上,Python正式聲明過,為for循環(huán)目標(biāo)(loop target)定義的名稱(更嚴(yán)格的正式名稱為“索引變量”)能泄露到外圍函數(shù)范圍。因此下面的代碼:
for i in [1, 2, 3]: pass print(i)
這段代碼是有效的,可以打印出3。在本文中,我想探討一下為什么會(huì)這樣,為什么它不太可能改變,以及將它作為一顆追蹤子彈來挖掘CPython編輯器中一些有趣的部分。
順便說一句,如果你不相信這種行為可能會(huì)導(dǎo)致真正的問題,考慮這個(gè)代碼片斷:
def foo(): lst = [] for i in range(4): lst.append(lambda: i) print([f() for f in lst])
如果你期待上面的代碼能打印出[0,1,2,3],你的期望會(huì)落空的,它會(huì)打印出[3,3,3,3];因?yàn)樵趂oo的作用域內(nèi)只有一個(gè)i,這個(gè)i就是所有的lambda所捕獲的。
官方說明
Python參考文檔中的for循環(huán)部分明確地記錄了這種行為:
for循環(huán)將變量賦值到目標(biāo)列表中?!?dāng)循環(huán)結(jié)束時(shí),賦值列表中的變量不會(huì)被刪除,但如果序列是空的,它們將不會(huì)被賦值給所有的循環(huán)。
注意最后一句,讓我們試試:
for i in []: pass print(i)
的確,上面的代碼拋出NameError異常。稍后,我們將看到這是Python虛擬機(jī)執(zhí)行字節(jié)碼方式的必然結(jié)果。
為什么會(huì)是這樣
其實(shí)我問過Guido van Rossum有關(guān)這個(gè)執(zhí)行行為的原因,他很慷慨地告訴了我其中的一些歷史背景(感謝Guido?。?。這樣執(zhí)行代碼的動(dòng)機(jī)是保持Python獲得變量和作用域的簡單性,而不訴諸于hacks(例如在循環(huán)完成后,刪除定義在該循環(huán)中的所有變量——想想它可能引發(fā)的異常)或更復(fù)雜的作用域規(guī)則。
Python的作用域規(guī)則非常簡單、優(yōu)雅:模塊、類以及函數(shù)的代碼塊可引入作用域。在函數(shù)體內(nèi),變量從它們定義到代碼塊結(jié)束(包括嵌套的代碼塊如嵌套函數(shù))都是可見的。當(dāng)然,對于局部變量、全局變量(以及其他nonlocal變量)其規(guī)則略有不同。不過,這和我們的討論沒有太多關(guān)系。
這里最重要的一點(diǎn)是:最內(nèi)層的可能作用域是一個(gè)函數(shù)體。不是一個(gè)for循環(huán)體。不是一個(gè)with代碼塊。Python與其他編程語言不同(例如C及其后代語言),在函數(shù)水平下沒有嵌套詞法作用域。
因此,如果你只是基于Python實(shí)現(xiàn),你的代碼可能會(huì)以這樣的執(zhí)行行為結(jié)束。下面是另一段令人啟發(fā)的代碼片段:
for i in range(4): d = i * 2 print(d)
變量d 在for循環(huán)結(jié)束后是可見及可訪問的,你對這樣的發(fā)現(xiàn)感到驚奇嗎?不,這正是Python的工作方式。那么,為什么索引變量的作用域被區(qū)別對待呢?
順便說一句,列表推導(dǎo)式(list comprehension)中的索引變量也泄露到其封閉作用域,或者更準(zhǔn)確的說,在Python 3之前可以泄露。
Python 3包含許多重大更改,其中也修復(fù)了列表推導(dǎo)式中的變量泄露問題。毫無疑問,這樣破壞了向后兼容中性。這就是我認(rèn)為當(dāng)前的執(zhí)行行為不會(huì)被改變的原因。
此外,許多人仍然發(fā)現(xiàn)這是Python中的一個(gè)有用的功能??紤]一下下面的代碼:
for i, item in enumerate(somegenerator()): dostuffwith(i, item) print('The loop executed {0} times!'.format(i+1))
如果不知道somegenerator返回項(xiàng)的數(shù)目,可以使用這種簡潔的方式。否則,你就必須有一個(gè)獨(dú)立的計(jì)數(shù)器。
這里有一個(gè)其他的例子:
for i in somegenerator(): if isinteresing(i): break dostuffwith(i)
這種模式可以有效的在循環(huán)中查找某一項(xiàng)并在隨后使用該項(xiàng)。[2]
多年來,許多用戶都想保留這種特性。但即使對于開發(fā)者認(rèn)定的有害特性,也很難引入重大更改了。當(dāng)許多人認(rèn)為該特性很有用,而且在真實(shí)世界的代碼中大量使用時(shí),就更不會(huì)除去這項(xiàng)特性了。
Under the hood
現(xiàn)在是最有趣的部分。讓我們來看看Python編譯器和VM是如何協(xié)同工作,讓這種代碼執(zhí)行行為成為可能的。在這種特殊的情況下,我認(rèn)為呈現(xiàn)這些的最清晰方式是從字節(jié)碼開始逆向分析。我希望通過這個(gè)例子來介紹如何挖掘Python內(nèi)部[3]的信息(這是如此充滿樂趣?。?。
讓我們來看本文開篇提出的函數(shù)的一部分:
def foo(lst): a = 0 for i in lst: a += i return a
產(chǎn)生的字節(jié)碼是:
0 LOAD_CONST 1 (0) 3 STORE_FAST 1 (a) 6 SETUP_LOOP 24 (to 33) 9 LOAD_FAST 0 (lst) 12 GET_ITER 13 FOR_ITER 16 (to 32) 16 STORE_FAST 2 (i) 19 LOAD_FAST 1 (a) 22 LOAD_FAST 2 (i) 25 INPLACE_ADD 26 STORE_FAST 1 (a) 29 JUMP_ABSOLUTE 13 32 POP_BLOCK 33 LOAD_FAST 1 (a) 36 RETURN_VALUE
作為提示,LOAD_FAST和STORE_FAST是字節(jié)碼(opcode),Python用它來訪問只在函數(shù)中使用的變量。由于Python編譯器知道(編譯時(shí))在每個(gè)函數(shù)中有多少個(gè)這樣的靜態(tài)變量,它們可以通過靜態(tài)數(shù)組偏移量而不是一個(gè)哈希表進(jìn)行訪問,這使得訪問速度更快(因而是_FAST后綴)。我有些離題了。這里真正重要的是變量a和i被平等對待。它們都通過LOAD_FAST獲取,并通過STORE_FAST修改。絕對沒有任何理由認(rèn)為它們的可見性是不同的。[4]
那么,這種執(zhí)行現(xiàn)象是怎么發(fā)生的?為什么編譯器認(rèn)為變量i只是foo中的一個(gè)局部變量。這個(gè)邏輯在符號表中的代碼中,當(dāng)編譯器執(zhí)行到AST開始創(chuàng)建一個(gè)控制流圖,隨后會(huì)產(chǎn)生字節(jié)碼。這個(gè)過程的更多細(xì)節(jié)在我有關(guān)符號表的文章中的介紹——所以我只在這里提及其中的重點(diǎn)。
符號表代碼并不認(rèn)為for語句很特別。在symtable_visit_stmt中有如下代碼:
case For_kind: VISIT(st, expr, s->v.For.target); VISIT(st, expr, s->v.For.iter); VISIT_SEQ(st, stmt, s->v.For.body); if (s->v.For.orelse) VISIT_SEQ(st, stmt, s->v.For.orelse); break;
索引變量如任何其他表達(dá)式一樣被訪問。由于該代碼訪問了AST,這值得去看看for語句結(jié)點(diǎn)內(nèi)部是怎樣的:
For(target=Name(id='i', ctx=Store()), iter=Name(id='lst', ctx=Load()), body=[AugAssign(target=Name(id='a', ctx=Store()), op=Add(), value=Name(id='i', ctx=Load()))], orelse=[])
所以i在一個(gè)名為Name的節(jié)點(diǎn)中。這些是由符號表代碼通過symtable_visit_expr中以下語句來處理的:
case Name_kind: if (!symtable_add_def(st, e->v.Name.id, e->v.Name.ctx == Load ? USE : DEF_LOCAL)) VISIT_QUIT(st, 0); /* ... */
由于變量i被清楚地標(biāo)記為DEF_LOCAL(因?yàn)? _FAST字節(jié)碼是可訪問的,但是這也很容易觀察到,如果符號表是不能用的則使用symtable模塊),上述明顯的代碼調(diào)用symtable_add_def與DEF_LOCAL 作為第三個(gè)參數(shù)?,F(xiàn)在來瀏覽一下上面的AST,并注意到Name結(jié)點(diǎn)中i的ctx=Store部分。因此,它是在For結(jié)點(diǎn)的target部分存儲(chǔ)著i的信息的AST。讓我們看看這是如何實(shí)現(xiàn)的。
編譯器中的AST構(gòu)建部分越過了解析樹(這是源代碼中相當(dāng)?shù)讓拥谋硎尽恍┍尘百Y料可以在這里獲得),同時(shí)在其他事項(xiàng)中,在某些結(jié)點(diǎn)設(shè)置expr_context屬性,其中最顯著的是Name結(jié)點(diǎn)。想想看,這樣一來,在下面的語句:
foo = bar + 1
for和bar這兩個(gè)變量都將在Name結(jié)點(diǎn)中結(jié)束。但是bar只是被加載到這段代碼中,而for實(shí)際上被存儲(chǔ)到這段代碼中。expr_context屬性通過符號表代碼被用來區(qū)分當(dāng)前和未來使用[5] 。
回到我們for循環(huán)的索引變量。這些內(nèi)容將在函數(shù)ast_for_for_stmt——for語句創(chuàng)建AST——中處理。下面是該函數(shù)的相關(guān)部分:
static stmt_ty ast_for_for_stmt(struct compiling *c, const node *n) { asdl_seq *_target, *seq = NULL, *suite_seq; expr_ty expression; expr_ty target, first; /* ... */ node_target = CHILD(n, 1); _target = ast_for_exprlist(c, node_target, Store); if (!_target) return NULL; /* Check the # of children rather than the length of _target, since for x, in ... has 1 element in _target, but still requires a Tuple. */ first = (expr_ty)asdl_seq_GET(_target, 0); if (NCH(node_target) == 1) target = first; else target = Tuple(_target, Store, first->lineno, first->col_offset, c->c_arena); /* ... */ return For(target, expression, suite_seq, seq, LINENO(n), n->n_col_offset, c->c_arena); }
在調(diào)用函數(shù)ast_for_exprlist時(shí)創(chuàng)建了Store上下文,該函數(shù)為索引變量創(chuàng)建了一個(gè)結(jié)點(diǎn)(注意,for循環(huán)的索引變量還可能是一序列變量的元組,而不僅僅是一個(gè)變量)。
在介紹為什么for循環(huán)變量和循環(huán)中的其他變量一視同仁的過程中,這個(gè)函數(shù)是最后總要的一部分。在AST中進(jìn)行標(biāo)記之后,在符號表和虛擬機(jī)中用于處理循環(huán)變量的代碼與處理其他變量的代碼是相同的。
結(jié)束語
本文討論了Python中可能被認(rèn)為是“疑難雜癥”的某些特定行為。我希望這篇文章確實(shí)解釋了Python的變量和作用域的代碼執(zhí)行行為,說明了為什么這些行為是有用的而且永遠(yuǎn)不太可能改變,以及Python編譯器的內(nèi)部如何使其正常工作。感謝您的閱讀!
[1] 在這里,我很想開個(gè)Microsoft Visual C ++ 6的玩笑,但事實(shí)讓人有些不安,因?yàn)樵?015年這個(gè)博客的大部分讀者不會(huì)懂這個(gè)笑話(這反映了我的年齡,而不是我的讀者的能力)。
[2] 你可能會(huì)說,在執(zhí)行到break之前時(shí),dowithstuff(i)可以進(jìn)入if中。但是,這并不總是很方便。此外,根據(jù)Guido的解釋,這里對我們關(guān)注的問題做了一個(gè)很好的分離——循環(huán)被用于并只用于搜索。在搜索結(jié)束后,循環(huán)中的變量會(huì)發(fā)生什么已經(jīng)不是循環(huán)關(guān)注的事情。我覺得這是非常好的一點(diǎn)。
[3]: 通常我的文章中的代碼是基于Python 3。具體而言,我期待Python庫中將要完成的下一個(gè)版本(3.5)的default分支。但是對于這個(gè)特定的主題,在3.x系列中的任何版本的源代碼都應(yīng)該是可以工作的。
[4] 函數(shù)分解中另一件很明顯的事是,如果循環(huán)不執(zhí)行,為什么i仍然是不可見的,GET_ITER和FOR_ITER這對字節(jié)碼將我們的循環(huán)當(dāng)做一個(gè)迭代器,然后調(diào)用其__next__方法。如果這個(gè)調(diào)用最后以拋出StopIteration異常結(jié)束,虛擬機(jī)捕捉到這個(gè)異常然后結(jié)束循環(huán)。只有實(shí)際值被返回,虛擬機(jī)才會(huì)繼續(xù)對i執(zhí)行STORE_FAST,因此讓這個(gè)值存在,讓后續(xù)代碼可以引用。
[5] 這是一個(gè)奇怪的設(shè)計(jì),我懷疑這個(gè)設(shè)計(jì)的實(shí)質(zhì)是為了使用相對干凈的遞歸訪問AST中的代碼,如符號表代碼和CFG生成器。
聲明:本網(wǎng)頁內(nèi)容旨在傳播知識,若有侵權(quán)等問題請及時(shí)與本網(wǎng)聯(lián)系,我們將在第一時(shí)間刪除處理。TEL:177 7030 7066 E-MAIL:11247931@qq.com