在看完函數(shù)的參數(shù)解析之后,我們來聊一聊函數(shù)的 local 名字空間。 我們知道函數(shù)的參數(shù)和函數(shù)內(nèi)部定義的變量都屬于局部變量,均是通過靜態(tài)的方式訪問的。
無論是參數(shù)還是內(nèi)部新創(chuàng)建的變量,本質(zhì)上都是局部變量。并且我們發(fā)現(xiàn)如果函數(shù)內(nèi)部定義的變量和參數(shù)名稱一致,那么參數(shù)就沒用了。 這很好理解,因為本質(zhì)上就相當(dāng)于重新賦值了,此時外面無論給函數(shù)foo2的參數(shù)a、b傳什么值,最終都會變成 1 和 2。所以其實局部變量的實現(xiàn)機(jī)制和函數(shù)參數(shù)的實現(xiàn)機(jī)制是一致的。 按照之前的理解,當(dāng)訪問一個全局變量時,會去訪問 global 名字空間,而這也確實如此。但是當(dāng)訪問函數(shù)的局部變量時,是不是訪問其內(nèi)部的 local 名字空間呢? 之前我們說過 Python 變量的訪問是有規(guī)則的,按照本地、閉包、全局、內(nèi)置的順序去查找,所以當(dāng)然會首當(dāng)其沖去 local 名字空間里面查找啊。 但不幸的是,在調(diào)用函數(shù)期間,虛擬機(jī)通過_PyFrame_New_NoTrack創(chuàng)建棧幀對象時,這個至關(guān)重要的 local 名字空間并沒有被創(chuàng)建。
對于模塊而言,它的 f_locals和 f_globals指向是同一個PyDictObject;但對于函數(shù)而言,f_locals 卻是 NULL。那么問題來了,這些重要的符號到底存儲在什么地方呢?
我們先來舉個栗子:
它的字節(jié)碼如下:
此時我們對局部變量 c 的藏身之處已經(jīng)了然于心,但是為什么函數(shù)的實現(xiàn)沒有使用 local 名字空間呢?答案很簡單,因為函數(shù)內(nèi)部的局部變量有多少,在編譯的時候就已經(jīng)確定了,個數(shù)是不會變的。因此編譯時就能確定局部變量占用的內(nèi)存大小,也能確定訪問局部變量的字節(jié)碼指令應(yīng)該如何訪問內(nèi)存。
我們看到符號 c 位于符號表中索引為 2 的位置(編譯時就已確定),那么通過 f_localsplus[2] 即可拿到變量 c 對應(yīng)的值。 這個過程是基于數(shù)組索引實現(xiàn)的靜態(tài)查找,它的效率非常高。而 local 空間是一個字典,雖然字典也是經(jīng)過高度優(yōu)化的,但肯定沒有靜態(tài)查找快。 因此,盡管虛擬機(jī)為函數(shù)實現(xiàn)了 local 空間(初始為 NULL,后續(xù)訪問的時候會進(jìn)行填充),但是在變量查找時卻沒有使用它,原因就是為了更高的效率,而且函數(shù)是一等公民,使用頻率很高。
我們從 Python 的層面來演示一下:
我們在函數(shù)內(nèi)部訪問了 global 名字空間,而 global 空間全局唯一,在 Python 層面上就是一個字典。
因此在執(zhí)行完 foo() 之后,全局變量 x 就被修改了。但 local 名字空間也是如此嗎?我們來看看:
我們按照相同的套路,卻并沒有成功,這是為什么?原因就是我們剛才解釋的那樣,函數(shù)內(nèi)部有哪些局部變量在編譯時就已經(jīng)確定好了,存儲在符號表 co_varnames 中,查詢的時候是從 f_localsplus 中靜態(tài)查找的,而不是從 locals() 中查找。 locals() 不像 globals(),雖然它們都是字典,但 globals() 全局唯一。我們調(diào)用 globals() 就直接訪問到了存放全局變量的字典,一旦做了更改,肯定會影響外面的全局變量。 而 locals() 則不會,因為局部變量壓根就不是從它這里訪問的,盡管它和 globals() 類似,在函數(shù)中也唯一,也會隨著當(dāng)前的上下文動態(tài)改變。
我們看到真的就類似于全局名字空間一樣,前后地址沒有變化,但是鍵值對的個數(shù)在增加。因為 locals() 底層會執(zhí)行 PyEval_GetLocals,實際上拿到就是當(dāng)前棧幀對象的 f_locals 屬性。
因此我們可以看到一個比較奇特的現(xiàn)象:
仔細(xì)思考一下肯定很好理解,它就有點類似 globals() 與 __builtins__ 之間的關(guān)系:
再看一個例子:
此時會得到什么結(jié)果估計不用我說了,因為本地、全局、builtin 里面都沒有變量 x,所以報錯。盡管在locals()里面我們設(shè)置了,但局部變量的值不是從它這里獲取的,而是從 f_localsplus 里面。而且查看符號表的話,會發(fā)現(xiàn)里面也沒有 'x' 這個符號。 如果我們設(shè)置一個全局變量呢?
顯然此時會訪問全局變量。
盡管 locals() 變了,但是依舊訪問不到 x,因為虛擬機(jī)并不知道 exec("x = 1") 是創(chuàng)建一個局部變量,它只知道這是一個函數(shù)調(diào)用。 而 exec("x = 1") 默認(rèn)影響的是當(dāng)前所在的作用域,所以效果就是改變了局部名字空間,里面多了一個 "x": 1 鍵值對。但關(guān)鍵的是,局部變量 x 不是從局部名字空間中查找的,exec 終究還是錯付了人。由于函數(shù) foo 對應(yīng)的 PyCodeObject 對象的符號表中并沒有 x 這個符號,所以報錯了。
這么做是可以的,因為 exec 默認(rèn)影響的是當(dāng)前作用域,而這里的當(dāng)前作用域就是全局作用域,所以 global 名字空間會多一個 key 為 "x" 的鍵值對。而全局變量是從global 名字空間中查找的,所以這里沒有問題。
再來看一個奇怪的問題:
這是什么情況?函數(shù) bar只是多了一行賦值語句,為啥就報錯了呢?要想搞懂這個問題,首先要明確兩點:
為了更好地解釋上面這個例子,這里再舉一個常見的錯誤:
調(diào)用函數(shù)foo沒有問題,但調(diào)用 bar 的時候會報出如下錯誤:UnboundLocalError: local variable 'x' referenced before assignment。 原因就在于上面說的兩個點,函數(shù)內(nèi)的局部變量在編譯的時候已經(jīng)確定,當(dāng)進(jìn)行語法解析的時候,看到了 x=2 這樣的字眼,就知道內(nèi)部會存在一個名為 x 的局部變量。所以對于 bar 函數(shù)而言,符號表中是存在 "x" 這個符號的。 而函數(shù)內(nèi)的局部變量在整個作用域內(nèi)又都是可見的,因此對于函數(shù)bar而言,在 print(x) 的時候知道符號表中存在 "x" 這個符號。那么它也就認(rèn)為局部作用域中存在 x 這個局部變量,因此就不會去找全局變量了,而是去找局部變量。 但是顯然 print(x) 是在 x=2 之前發(fā)生的,所以此時 print(x) 的時候就報錯了。
因為 print(x) 的時候,f_localsplus中還沒有對應(yīng)的值與之綁定,或者說 x 此時還是一個 NULL(空指針),并沒有指向一塊合法的內(nèi)存(已存在的 PyObject)。 當(dāng)虛擬機(jī)執(zhí)行到 x=2 之后,x 才會和 2 這個 PyLongObject 對象進(jìn)行綁定,只可惜我們在綁定之前就使用 x 這個變量了,顯然這是不合法的??梢钥匆幌伦止?jié)碼: 我們看到指令是LOAD_FAST,說明加載的是一個局部變量,但這個局部變量的賦值是發(fā)生在LOAD_FAST之后。 那么一開始的那個問題就很好解釋了:
對于 foo 而言,結(jié)果符合我們的預(yù)期;但對于 bar 而言,只是多了一個賦值語句,結(jié)果局部空間就變成空字典了。
|
|