Python名稱空間和作用域窺探

CipherChen發表於2015-07-20

Namespace and Scope(名稱空間和作用域)

namespace

Namespace(只)是 從名字到物件的一個對映(a mapping from name to objects) 。大部分namespace都是按Python中的字典來實現的。有一些常見的namespace:built-in中的集合( abs() 函式等)、一個模組中的全域性變數等。

從某種意義上來說,一個物件(object)的所有屬性(attribute)也構成了一個namespace。在程式執行期間,可能(其實是肯定)會有多個名空間同時存在。不同namespace的建立/銷燬時間也不同。

此外,兩個不同namespace中的兩個相同名字的變數之間沒有任何聯絡。

scope

有了namespace基礎之後,讓我們再來看看scope。Scope是Python程式的一塊文字區域(textual region)。

在該文字區域中,對namespace是可以直接訪問,而不需要通過屬性來訪問。

Scope是定義程式該如何搜尋確切地“名字-物件”的名空間的層級關係。
(The “scope” in Python defines the “hirerchy level” in which we search namespaces for
certain “name-to-object” mappings.)

Tip

直接訪問:對一個變數名的引用會在所有namespace中查詢該變數,而不是通過屬性訪問。

屬性訪問:所有名字後加 . 的都認為是屬性訪問。

如 module_name.func_name ,需要指定 func_name 的名空間,屬於屬性訪問。
而 abs(-1) , abs 屬於直接訪問。

兩者之間有什麼聯絡呢?

Important

在Python中,scope是由namespace按特定的層級結構組合起來的。

scope一定是namespace,但namespace不一定是scope.

LEGB-rule

在一個Python程式執行中,至少有4個scopes是存在的。

直接訪問一個變數可能在這四個namespace中逐一搜尋。

  • Local(innermost)
    包含區域性變數。
    比如一個函式/方法內部。
  • Enclosing
    包含了非區域性(non-local)也非全域性(non-global)的變數。
    比如兩個巢狀函式,內層函式可能搜尋外層函式的namespace,但該namespace對內層函式而言既非區域性也非全域性。 
  • Global(next-to-last)
    當前指令碼的最外層。
    比如當前模組的全域性變數。 
  • Built-in(outtermost)
    Python __builtin__ 模組。
    包含了內建的變數/關鍵字等。 

那麼,這麼多的作用域,Python是按什麼順序搜尋對應作用域的呢?

著名的”LEGB-rule”,即scope的搜尋順序:

Important

Local -> Enclosing -> Global -> Built-in

怎麼個意思呢?

當有一個變數在 local 域中找不到時,Python會找上一層的作用域,即 enclosing 域(該域不一定存在)。enclosing 域還找不到的時候,再往上一層,搜尋模組內的 global 域。最後,會在 built-in 域中搜尋。對於最終沒有搜尋到時,Python會丟擲一個 NameError 異常。

作用域可以巢狀。比如模組匯入時。

這也是為什麼不推薦使用 from a_module import * 的原因,匯入的變數可能被當前模組覆蓋。

Assignment rule

看似python作用域到此為止已經很清晰了,讓我們再看一段程式碼:

你覺得結果是什麼呢?So easy是不是?

如果多加一句呢?

結果又會是什麼呢?

是不是很奇怪?

原因是這樣的:

Python直譯器執行到 inner() 中的 print b 時,發現有個變數 b 在當前作用域(local)中
無法找到該變數。它繼續嘗試把整塊程式碼解釋完。

Bingo! 找到了。那麼 b 是屬於 inner() 作用域的。
既然對變數 b 的賦值(宣告)發生在 print 語句之後, print 語句執行時
變數 b 是還未被宣告的,於是丟擲錯誤:變數在賦值前就被引用。

在這個例子中,只有A語句沒有B語句也會導致同樣的結果。
因為 b += 1 等同於 b = b + 1。

對於變數的作用域查詢有了瞭解之後,還有兩條很重要的規則:

Important

  1. 賦值語句通常隱式地會建立一個區域性(local)變數,即便該變數名已存在於賦值語句發生的上一層作用域中;
  2. 如果沒有 global 關鍵字宣告變數,對一個變數的賦值總是認為該變數存在於最內層(innermost)的作用域中;

也就是說在作用域內有沒有發生賦值是不一樣的。

但是,在這點上,Python 2和Python 3又有不同, Python access non-local variable:

Python’s scoping rules indicate that a function defines a new scope level,
and a name is bound to a value in only one scope level – it is statically scoped.

In Python 2.x, it is not possible to modify a non-local variable;
1) you have either read-only access to a global or non-local variable,
2) or read-write access to a global variable by using the global statement,
3) or read-write access to a local variable (by default).

In Python 3.x, the nonlocal statement has been introduced with a similar effect
to global, but for an intermediate scope.

for 迴圈

為什麼講到作用域要說到 for 迴圈呢?難道!@#$%^&*()???

對於大部分語言(比如 C 語言)而言, for-loop 會引入一個新的作用域。
但Python有點一樣卻又不太一樣。

讓我們先來看個例子:

有點不可思議是不是?

在 Python 2.x for語句 中是這麼說的:

The for-loop makes assignments to the variable(s) in the target list.
This overwrites all previous assignments to those variablees including those made in the suite of the for-loop.

The target list is not deleted when the loop is finished.
But if the sequence is empty, they will not have been assigned to at all the loop.

for 後面跟著的變數(target list)在迴圈結束後是不會被刪除的,
但如果 for 迴圈的序列為空,這些變數是完全不會被賦值的。

這在Python中是個大坑啊。

避免這個坑的解決辦法就是規範命名規範。
比如用於迴圈的變數儘量使用單字元。在任何有疑議的情況可以直接將索引值初始化。

很不幸,Python 3中這點沒有改變。

List Comprehension vs. Generator Expression

關於Python作用域這堂課已經上了很久了,我們先休息一下,說個題外話吧。

  • 列表推導式(List Comprehension)

    簡單的理解列表推導式:

    • 列表推導式會把所有資料都載入到記憶體。適合 “結果需要多次被使用” 或者 “需要使用list相關的方法(分片等)” 等的情況。
  • 生成器表示式(Generator Expression)

    簡單的理解生成器表示式:

    • 使用生成器實現。適合“資料量非常大或者無限”的情況。

它們的表現效果分別是這樣的:

Python 作用域,我已經完全掌握了!

稍作小憩之後,看來大家對Python作用域很有信心了。

好的。那我們來測試一下。

這是類(class)定義中的一個小問題:

這段程式碼執行起來是不是跟你想的有點一樣但又不那麼一樣呢?

剛剛總結的規則完全用不上啊!!!

“元芳,你怎麼看?”

真相只有一個:

class沒有作用域(scope),但有一個區域性的名空間(namespace),它並不構成一個作用域。
這意味著在類定義中的表示式可以訪問該名空間。

但在類體(class body)中, 對 b 的賦值表示式中,該表示式引入了一個新的作用域,該作用域並不能訪問類的名空間。

就像剛剛說的,函式會引入一個新的作用域。

比如說:

在Python 2中,列表推導式沒有引入一個新的作用域。所以:

而對於Python 2和Python 3,生成器表示式都有引入新的作用域。

為了讓列表推導式和生成器表示式的表現一致,
在Python 3中,列表推導式也有引入一個新的作用域。所以:

解決方案

所以,要解決這個問題,有幾種解決辦法:

  1. 用生成器表示式
  1. 用函式/lambda引入新的作用域

有沒有開始懷疑人生懷疑理想?

附一份:訪問許可權彙總表

Can access class attributes Python 2 Python 3
list comp. iterable Y Y
list comp. expression Y N
gen expr. iterable Y Y
gen expr. expression N N
dict comp. iterable Y Y
dict comp. expression N N

總結

本文介紹了Python中 namespace 和 scope 的區別,
以及複雜作用域的搜尋規則( LEGB )。
此外,還介紹了一些常見的會建立scope的情況(函式定義,生成器表示式等),當然包括
了Python 2和Python 3中的不同實現。

Python中對於作用域的定義確實是個大問題,我並沒有找到像 C 語言那樣,
“程式碼塊 {} 中定義的即是一個區域性作用域”這樣簡潔的規則來清晰地表明
Python中作用域的 建立/銷燬 的條件。

這篇文章的內容積壓了很久,終於抽了點時間出來整理了下。

寫的也有點沒章法了,各位看官看得懂就看吧;看不懂多看幾遍吧。

看望之後也提點啥建議意見之類的,好讓後來人也能更快速簡單的理解這個問題。
萬一我理解錯了呢?

歡迎探討。

但有一點可以肯定,“這事兒還沒完”。

相關文章