Python迴圈語句中的索引變數作用域

Janzou發表於2015-04-14

我們從一個測試開始。下面這個函式的功能是什麼?

如果你覺得它的功能是“計算lst中所有元素的和與積”,不要沮喪。通常很難發現這裡的錯誤。如果在大堆真實的程式碼中發現了這個錯誤就非常厲害了。——當你不知道這是一個測試時,很難發現這個錯誤。

這裡的錯誤是在第二個迴圈體中使用了i而不是t。等下,這到底是怎麼工作的?i在第一個迴圈外應該是不可見的? [1]哦,不。事實上,Python正式宣告過,為for迴圈目標(loop target)定義的名稱(更嚴格的正式名稱為“索引變數”)能洩露到外圍函式範圍。因此下面的程式碼:

這段程式碼是有效的,可以列印出3。在本文中,我想探討一下為什麼會這樣,為什麼它不太可能改變,以及將它作為一顆追蹤子彈來挖掘CPython編輯器中一些有趣的部分。

順便說一句,如果你不相信這種行為可能會導致真正的問題,考慮這個程式碼片斷:

如果你期待上面的程式碼能列印出[0,1,2,3],你的期望會落空的,它會列印出[3,3,3,3];因為在foo的作用域內只有一個i,這個i就是所有的lambda所捕獲的。

官方說明

Python參考文件中的for迴圈部分明確地記錄了這種行為:

for迴圈將變數賦值到目標列表中。……當迴圈結束時,賦值列表中的變數不會被刪除,但如果序列是空的,它們將不會被賦值給所有的迴圈。

注意最後一句,讓我們試試:

的確,上面的程式碼丟擲NameError異常。稍後,我們將看到這是Python虛擬機器執行位元組碼方式的必然結果。

為什麼會是這樣

其實我問過Guido van Rossum有關這個執行行為的原因,他很慷慨地告訴了我其中的一些歷史背景(感謝Guido!)。這樣執行程式碼的動機是保持Python獲得變數和作用域的簡單性,而不訴諸於hacks(例如在迴圈完成後,刪除定義在該迴圈中的所有變數——想想它可能引發的異常)或更復雜的作用域規則。

Python的作用域規則非常簡單、優雅:模組、類以及函式的程式碼塊可引入作用域。在函式體內,變數從它們定義到程式碼塊結束(包括巢狀的程式碼塊如巢狀函式)都是可見的。當然,對於區域性變數、全域性變數(以及其他nonlocal變數)其規則略有不同。不過,這和我們的討論沒有太多關係。

這裡最重要的一點是:最內層的可能作用域是一個函式體。不是一個for迴圈體。不是一個with程式碼塊。Python與其他程式語言不同(例如C及其後代語言),在函式水平下沒有巢狀詞法作用域。

因此,如果你只是基於Python實現,你的程式碼可能會以這樣的執行行為結束。下面是另一段令人啟發的程式碼片段:

變數d 在for迴圈結束後是可見及可訪問的,你對這樣的發現感到驚奇嗎?不,這正是Python的工作方式。那麼,為什麼索引變數的作用域被區別對待呢?

順便說一句,列表推導式(list comprehension)中的索引變數也洩露到其封閉作用域,或者更準確的說,在Python 3之前可以洩露。

Python 3包含許多重大更改,其中也修復了列表推導式中的變數洩露問題。毫無疑問,這樣破壞了向後相容中性。這就是我認為當前的執行行為不會被改變的原因。

此外,許多人仍然發現這是Python中的一個有用的功能。考慮一下下面的程式碼:

如果不知道somegenerator返回項的數目,可以使用這種簡潔的方式。否則,你就必須有一個獨立的計數器。

這裡有一個其他的例子:

這種模式可以有效的在迴圈中查詢某一項並在隨後使用該項。[2]

多年來,許多使用者都想保留這種特性。但即使對於開發者認定的有害特性,也很難引入重大更改了。當許多人認為該特性很有用,而且在真實世界的程式碼中大量使用時,就更不會除去這項特性了。

Under the hood

現在是最有趣的部分。讓我們來看看Python編譯器和VM是如何協同工作,讓這種程式碼執行行為成為可能的。在這種特殊的情況下,我認為呈現這些的最清晰方式是從位元組碼開始逆向分析。我希望通過這個例子來介紹如何挖掘Python內部[3]的資訊(這是如此充滿樂趣!)。

讓我們來看本文開篇提出的函式的一部分:

產生的位元組碼是:

作為提示,LOAD_FASTSTORE_FAST是位元組碼(opcode),Python用它來訪問只在函式中使用的變數。由於Python編譯器知道(編譯時)在每個函式中有多少個這樣的靜態變數,它們可以通過靜態陣列偏移量而不是一個雜湊表進行訪問,這使得訪問速度更快(因而是_FAST字尾)。我有些離題了。這裡真正重要的是變數ai被平等對待。它們都通過LOAD_FAST獲取,並通過STORE_FAST修改。絕對沒有任何理由認為它們的可見性是不同的。[4]

那麼,這種執行現象是怎麼發生的?為什麼編譯器認為變數i只是foo中的一個區域性變數。這個邏輯在符號表中的程式碼中,當編譯器執行到AST開始建立一個控制流圖,隨後會產生位元組碼。這個過程的更多細節在我有關符號表的文章中的介紹——所以我只在這裡提及其中的重點。

符號表程式碼並不認為for語句很特別。在symtable_visit_stmt中有如下程式碼:

索引變數如任何其他表示式一樣被訪問。由於該程式碼訪問了AST,這值得去看看for語句結點內部是怎樣的:

所以i在一個名為Name的節點中。這些是由符號表程式碼通過symtable_visit_expr中以下語句來處理的:

由於變數i被清楚地標記為DEF_LOCAL(因為* _FAST位元組碼是可訪問的,但是這也很容易觀察到,如果符號表是不能用的則使用symtable模組),上述明顯的程式碼呼叫symtable_add_defDEF_LOCAL 作為第三個引數。現在來瀏覽一下上面的AST,並注意到Name結點中ictx=Store部分。因此,它是在For結點的target部分儲存著i的資訊的AST。讓我們看看這是如何實現的。

編譯器中的AST構建部分越過了解析樹(這是原始碼中相當底層的表示——一些背景資料可以在這裡獲得),同時在其他事項中,在某些結點設定expr_context屬性,其中最顯著的是Name結點。想想看,這樣一來,在下面的語句:

forbar這兩個變數都將在Name結點中結束。但是bar只是被載入到這段程式碼中,而for實際上被儲存到這段程式碼中。expr_context屬性通過符號表程式碼被用來區分當前和未來使用[5] 。

回到我們for迴圈的索引變數。這些內容將在函式ast_for_for_stmt——for語句建立AST——中處理。下面是該函式的相關部分:

在呼叫函式ast_for_exprlist時建立了Store上下文,該函式為索引變數建立了一個結點(注意,for迴圈的索引變數還可能是一序列變數的元組,而不僅僅是一個變數)。

在介紹為什麼for迴圈變數和迴圈中的其他變數一視同仁的過程中,這個函式是最後總要的一部分。在AST中進行標記之後,在符號表和虛擬機器中用於處理迴圈變數的程式碼與處理其他變數的程式碼是相同的。

結束語

本文討論了Python中可能被認為是“疑難雜症”的某些特定行為。我希望這篇文章確實解釋了Python的變數和作用域的程式碼執行行為,說明了為什麼這些行為是有用的而且永遠不太可能改變,以及Python編譯器的內部如何使其正常工作。感謝您的閱讀!

[1] 在這裡,我很想開個Microsoft Visual C ++ 6的玩笑,但事實讓人有些不安,因為在2015年這個部落格的大部分讀者不會懂這個笑話(這反映了我的年齡,而不是我的讀者的能力)。

[2] 你可能會說,在執行到break之前時,dowithstuff(i)可以進入if中。但是,這並不總是很方便。此外,根據Guido的解釋,這裡對我們關注的問題做了一個很好的分離——迴圈被用於並只用於搜尋。在搜尋結束後,迴圈中的變數會發生什麼已經不是迴圈關注的事情。我覺得這是非常好的一點。

[3]: 通常我的文章中的程式碼是基於Python 3。具體而言,我期待Python庫中將要完成的下一個版本(3.5)的default分支。但是對於這個特定的主題,在3.x系列中的任何版本的原始碼都應該是可以工作的。

[4] 函式分解中另一件很明顯的事是,如果迴圈不執行,為什麼i仍然是不可見的,GET_ITERFOR_ITER這對位元組碼將我們的迴圈當做一個迭代器,然後呼叫其__next__方法。如果這個呼叫最後以丟擲StopIteration異常結束,虛擬機器捕捉到這個異常然後結束迴圈。只有實際值被返回,虛擬機器才會繼續對i執行STORE_FAST,因此讓這個值存在,讓後續程式碼可以引用。

[5] 這是一個奇怪的設計,我懷疑這個設計的實質是為了使用相對乾淨的遞迴訪問AST中的程式碼,如符號表程式碼和CFG生成器。

相關文章