試讀:www.epubit.com.cn/book/detail…
購書:item.jd.com/12241204.ht…
編寫高效語法的能力會隨著時間逐步提高。回頭看看寫的第一個程式,你可能就會同意這個觀點。正確的語法看起來賞心悅目,而錯誤的語法則令人煩惱。
除了實現的演算法與程式架構設計之外,還要特別注意的是,程式的寫法也會嚴重影響它未來的發展。許多程式被丟棄並從頭重寫,就是因為難懂的語法、不清晰的API或不合常理的標準。
不過Python在最近幾年裡發生了很大變化。因此,如果你被鄰居(一個愛嫉妒的人,來自本地Ruby開發者使用者組)綁架了一段時間,並且遠離新聞,那麼你可能會對Python的新特性感到吃驚。從最早版本到目前的3.5版,這門語言已經做了許多改進,變得更加清晰、更加整潔、也更容易編寫。Python基礎知識並沒有發生很大變化,但現在使用的工具更符合人們的使用習慣。
本章將介紹現在這門語言的語法中最重要的元素,以及它們的使用技巧,如下所示。
- 列表推導(list comprehension)。
- 迭代器(iterator)和生成器(generator)。
- 描述符(descriptor)和屬性(property)。
- 裝飾器(decorator)。
with
和contextlib
。
速度提升或記憶體使用的程式碼效能技巧將會在第11、12章中講述。
2.1 Python的內建型別
Python提供了許多好用的資料型別,既包括數字型別,也包括集合型別。對於數字型別來說,語法並沒有什麼特別之處。當然,每種型別的定義會有些許差異,也有一些(可能)不太有名的運算子細節,但留給開發人員的選擇並不多。對於集合型別和字串來說,情況就發生變化了。雖然人們常說“做事的方法應該只有一種”,但留給Python開發人員的選擇確實有很多。在初學者看來,有些程式碼模式看起來既直觀又簡單,可是有經驗的程式設計師往往會認為它們不夠Pythonic,因為它們要麼效率低下,要麼就是過於囉嗦。
這種解決常見問題的Pythonic模式(許多程式設計師稱之為習語[idiom])看起來往往只是美觀而已。但這種看法大錯特錯。大多數習語都揭示了Python的內部實現方式以及內建結構和模組的工作原理。想要深入理解這門語言,瞭解更多這樣的細節是很必要的。此外,社群本身也會受到關於Python工作原理的一些謠言和成見的影響。只有自己深入鑽研,你才能夠分辨出關於Python的流行說法的真假。
2.1.1 字串與位元組
對於只用Python 2程式設計的程式設計師來說,字串的話題可能會造成一些困惑。Python 3中只有一種能夠儲存文字資訊的資料型別,就是str
(string,字串)。它是不可變的序列,儲存的是Unicode碼位(code point)。這是與Python 2的主要區別,Python 2用str
表示位元組字串,這種型別現在在Python 3中用bytes
物件來處理(但處理方式並不完全相同)。
Python中的字串是序列。基於這一事實,應該把字串放在其他容器型別的一節去介紹,但字串與其他容器型別在細節上有一個很重要的差異。字串可以儲存的資料型別有非常明確的限制,就是Unicode文字。
bytes
以及可變的bytearray
與str
不同,只能用位元組作為序列值,即0 <= x < 256
範圍內的整數。一開始可能會有點糊塗,因為其列印結果與字串非常相似:
>>> print(bytes([102, 111, 111]))
b'foo'複製程式碼
對於bytes
和bytearray
,在轉換為另一種序列型別(例如list
或tuple
)時可以顯示出其本來面目:
>>> list(b'foo bar')
[102, 111, 111, 32, 98, 97, 114]
>>> tuple(b'foo bar')
(102, 111, 111, 32, 98, 97, 114)複製程式碼
許多關於Python 3的爭議都是關於打破字串的向後相容和Unicode的處理方式。從Python 3.0開始,所有沒有字首的字串都是Unicode。因此,所有用單引號('
)、雙引號("
)或成組的3個引號(單引號或雙引號)包圍且沒有字首的值都表示str
資料型別:
>>> type("some string")
< class 'str' >複製程式碼
在Python 2中,Unicode需要有u
字首(例如u"some string"
)。從Python 3.3開始,為保證向後相容,仍然可以使用這個字首,但它在Python 3中沒有任何語法上的意義。
前面的一些例子中已經提到過位元組,但為了保持前後一致,我們來明確介紹它的語法。位元組也被單引號、雙引號或三引號包圍,但必須有一個b
或B
字首:
>>> type(b"some bytes")
< class 'bytes' >複製程式碼
注意,Python語法中沒有bytearray
字面值。
最後同樣重要的是,Unicode字串中包含無法用位元組表示的“抽象”文字。因此,如果Unicode字串沒有被編碼為二進位制資料的話,是無法儲存在磁碟中或通過網路傳送的。將字串物件編碼為位元組序列的方法有兩種:
- 利用
str.encode(encoding, errors)
方法,用註冊編解碼器(registered codec)對字串進行編碼。編解碼器由encoding
引數指定,預設值為'utf-8'
。第二個errors
引數指定錯誤的處理方案,可以取'strict'
(預設值)、'ignore'
、'replace'
、'xmlcharrefreplace'
或其他任何註冊的處理程式(參見內建codecs
模組的文件)。 - 利用
bytes(source, encoding, errors)
建構函式,建立一個新的位元組序列。如果source
是str
型別,那麼必須指定encoding
引數,它沒有預設值。encoding
和errors
引數的用法與str.encode()
方法中的相同。
用類似方法可以將bytes
表示的二進位制資料轉換成字串:
- 利用
bytes.decode(encoding, errors)
方法,用註冊編解碼器對位元組進行解碼。這一方法的引數含義及其預設值與str.encode()
相同。 - 利用
str(source, encoding, error)
建構函式,建立一個新的字串例項。與bytes()
建構函式類似,如果source
是位元組序列的話,必須指定str
函式的encoding
引數,它沒有預設值。
命名——位元組與位元組字串的對比
由於Python 3中的變化,有些人傾向於將
bytes
例項稱為位元組字串。這主要是由於歷史原因——Python 3中的bytes
是與Python 2中的str
型別最為接近的序列型別(但並不完全相同)。不過bytes
例項是位元組序列,也不需要表示文字資料。所以為了避免混淆,雖然bytes
例項與字串具有相似性,但建議始終將其稱為bytes
或位元組序列。Python 3中字串的概念是為文字資料準備的,現在始終是str
型別。
1.實現細節
Python字串是不可變的。位元組序列也是如此。這一事實很重要,因為它既有優點又有缺點。它還會影響Python高效處理字串的方式。由於不變性,字串可以作為字典的鍵或set
的元素,因為一旦初始化之後字串的值就不會改變。另一方面,每當需要修改過的字串時(即使只是微小的修改),都需要建立一個全新的字串例項。幸運的是,bytearray
是bytes
的可變版本,不存在這樣的問題。位元組陣列可以通過元素賦值來進行原處修改(無需建立新物件),其大小也可以像列表一樣動態地變化(利用append
、pop
、inseer
等方法)。
2.字串拼接
由於Python字串是不可變的,在需要合併多個字串例項時可能會產生一些問題。如前所述,拼接任意不可變序列都會生成一個新的序列物件。思考下面這個例子,利用多個字串的重複拼接操作來建立一個新字串:
s = ""
for substring in substrings:
s += substring複製程式碼
這會導致執行時間成本與字串總長度成二次函式關係。換句話說,這種方法效率極低。處理這種問題可以用str.join()
方法。它接受可迭代的字串作為引數,返回合併後的字串。由於這是一個方法,實際的做法是利用空字串來呼叫它:
s = "".join(substrings)複製程式碼
字串的這一方法還可以用於在需要合併的多個子字串之間插入分隔符,看下面這個例子:
>>> ','.join(['some', 'comma', 'separated', 'values'])
'some,comma,separated,values'複製程式碼
需要記住,僅僅因為join()
方法速度更快(對於大型列表來說更是如此),並不意味著在所有需要拼接兩個字串的情況下都應該使用這一方法。雖然這是一種廣為認可的做法,但並不會提高程式碼的可讀性。可讀性是很重要的!在某些情況下,join()
的效能可能還不如利用加法的普通拼接,下面舉幾個例子。
- 如果子字串的數量很少,而且已經包含在某個可迭代物件中,那麼在某些情況下,建立一個新序列來進行拼接操作的開銷可能會超過使用
join()
節省下來的開銷。 - 在拼接短的字面值時,由於CPython中的常數摺疊(constant folding),一些複雜的字面值(不只是字串)在編譯時會被轉換為更短的形式,例如
'a' + 'b' + 'c'
被轉換為'abc'
。當然,這隻適用於相對短的常量(字面值)。
最後,如果事先知道字串的數目,可以用正確的字串格式化方法來保證字串拼接的最佳可讀性。字串格式化可以用str.format()
方法或%
運算子。如果程式碼段的效能不是很重要,或者優化字串拼接節省的開銷很小,那麼推薦使用字串格式化作為最佳方法。
常數摺疊和窺孔優化程式
CPython對編譯過的原始碼使用窺孔優化程式來提高其效能。這種優化程式直接對Python位元組碼實現了許多常見的優化。如上所述,常數摺疊就是其功能之一。生成常數的長度不得超過一個固定值。在Python 3.5中這個固定值仍然是 20。不管怎樣,這個具體細節只是為了滿足讀者的好奇心而已,並不能在日常程式設計中使用。窺孔優化程式還實現了許多有趣的優化,詳細資訊請參見Python原始碼中的
Python/peephole.c
檔案。
2.1.2 集合型別
Python提供了許多內建的資料集合型別,如果選擇明智的話,可以高效解決許多問題。你可能已經學過下面這些集合型別,它們都有專門的字面值,如下所示。
- 列表(list)。
- 元組(tuple)。
- 字典(dictionary)。
- 集合(set)
Python的集合型別當然不止這4種,它的標準庫擴充套件了其可選列表。在許多情況下,問題的答案可能正如選擇正確的資料結構一樣簡單。本書的這一部分將深入介紹各種集合型別,以幫你做出更好的選擇。
1.列表與元組
Python最基本的兩個集合型別就是列表與元組,它們都表示物件序列。只要是花幾小時學過Python的人,應該都很容易發現二者之間的根本區別:列表是動態的,其大小可以改變;而元組是不可變的,一旦建立就不能修改。
雖然快速分配/釋放小型物件的優化方法有很多,但對於元素位置本身也是資訊的資料結構來說,推薦使用元組這一資料型別。舉個例子,想要儲存(x, y)座標對,元組可能是一個很好的選擇。反正關於元組的細節相當無趣。本章關於元組唯一重要的內容就是,tuple
是不可變的(immutable),因此也是可雜湊的(hashable)。其具體含義將會在後面“字典”一節介紹。比元組更有趣的是另一種動態的資料結構list
,以及它的工作原理和高效處理理方式。
(1)實現細節
許多程式設計師容易將Python的list
型別與其他語言(如C、C++或Java)標準庫中常見的連結串列的概念相混淆。事實上,CPython的列表根本不是列表。在CPython中,列表被實現為長度可變的陣列。對於其他Python實現(如Jython和IronPython)而言,這種說法應該也是正確的,雖然這些專案的文件中沒有記錄其實現細節。造成這種混淆的原因很清楚。這種資料型別被命名為列表,還和連結串列實現有相似的介面。
為什麼這一點很重要,這又意味著什麼呢?列表是最常見的資料結構之一,其使用方式會對所有應用的效能帶來極大影響。此外,CPython又是最常見也最常用的Python實現,所以瞭解其內部實現細節至關重要。
從細節上來看,Python中的列表是由對其他物件的引用組成的的連續陣列。指向這個陣列的指標及其長度被儲存在一個列表頭結構中。這意味著,每次新增或刪除一個元素時,由引用組成的陣列需要改變大小(重新分配)。幸運的是,Python在建立這些陣列時採用了指數過分配(exponential over-allocation),所以並不是每次操作都需要改變陣列大小。這也是新增或取出元素的平攤複雜度較低的原因。不幸的是,在普通連結串列中“代價很小”的其他一些操作在Python中的計算複雜度卻相對較高:
- 利用
list.insert
方法在任意位置插入一個元素——複雜度為O(n)。 - 利用
list.delete
或del
刪除一個元素——複雜度為O(n)。
這裡n是列表的長度。至少利用索引來查詢或修改元素的時間開銷與列表大小無關。表2-1是一張完整的表格,列出了大多數列表操作的平均時間複雜度。
表2-1
操作 | 複雜度 |
---|---|
複製 | O(n) |
新增元素 | O(1) |
插入元素 | O(n) |
獲取元素 | O(1) |
修改元素 | O(1) |
刪除元素 | O(n) |
遍歷 | O(n) |
獲取長度為k的切片 | O(k) |
刪除切片 | O(n) |
修改長度為k的切片 | O(k+n) |
列表擴充套件(Extend) | O(k) |
乘以k | O(nk) |
測試元素是否在列表中(element in list) | O(n) |
min()/max() | O(n) |
獲取列表長度 | O(1) |
對於需要真正的連結串列(或者簡單來說,雙端append
和pop
操作的複雜度都是O(1)的資料結構)的場景,Python在內建的collections
模組中提供了deque
(雙端佇列)。它是棧和佇列的一般化,在需要用到雙向連結串列的地方都可以使用這種資料結構。
(2)列表推導
你可能知道,編寫這樣的程式碼是很痛苦的:
>>> evens = []
>>> for i in range(10):
... if i % 2 == 0:
... evens.append(i)
...
>>> evens
[0, 2, 4, 6, 8]複製程式碼
這種寫法可能適用於C語言,但在Python中的實際執行速度很慢,原因如下。
- 直譯器在每次迴圈中都需要判斷序列中的哪一部分需要修改。
- 需要用一個計數器來跟蹤需要處理的元素。
- 由於
append()
是一個列表方法,所以每次遍歷時還需要額外執行一個查詢函式。
列表推導正是解決這個問題的正確方法。它使用編排好的功能對上述語法的一部分做了自動化處理:
>>> [i for i in range(10) if i % 2 == 0]
[0, 2, 4, 6, 8]複製程式碼
這種寫法除了更加高效之外,也更加簡短,涉及的語法元素也更少。在大型程式中,這意味著更少的錯誤,程式碼也更容易閱讀和理解。
列表推導和內部陣列調整大小
有些Python程式設計師中會謠傳這樣的說法:每新增幾個元素之後都要對錶示列表物件的內部陣列大小進行調整,這個問題可以用列表推導來解決。還有人說一次分配就可以將陣列大小調整到剛剛好。不幸的是,這些說法都是不正確的。
直譯器在對列表推導進行求值的過程中並不知道最終結果容器的大小,也就無法為它預先分配陣列的最終大小。因此,內部陣列的重新分配方式與
for
迴圈中完全相同。但在許多情況下,與普通迴圈相比,使用列表推導建立列表要更加整潔、更加快速。
(3)其他習語
Python習語的另一個典型例子是使用enumerate
(列舉)。在迴圈中使用序列時,這個內建函式可以很方便地獲取其索引。以下面這段程式碼為例:
>>> i = 0
>>> for element in ['one', 'two', 'three']:
... print(i, element)
... i += 1
...
0 one
1 two
2 three複製程式碼
它可以替換為下面這段更短的程式碼:
>>> for i, element in enumerate(['one', 'two', 'three']):
... print(i, element)
...
0 one
1 two
2 three複製程式碼
如果需要一個一個合併多個列表(或任意可迭代物件)中的元素,那麼可以使用內建的zip()
函式。對兩個大小相等的可迭代物件進行均勻遍歷時,這是一種非常常用的模式:
>>> for item in zip([1, 2, 3], [4, 5, 6]):
... print(item)
...
(1, 4)
(2, 5)
(3, 6)複製程式碼
注意,對zip()
函式返回的結果再次呼叫zip()
,可以將其恢復原狀:
>>> for item in zip(zip([1, 2, 3], [4, 5, 6])):
... print(item)
...
(1, 2, 3)
(4, 5, 6)複製程式碼
另一個常用的語法元素是序列解包(sequence unpacking)。這種方法並不限於列表和元組,而是適用於任意序列型別(甚至包括字串和位元組序列)。只要賦值運算子左邊的變數數目與序列中的元素數目相等,你都可以用這種方法將元素序列解包到另一組變數中:
>>> first, second, third = "foo", "bar", 100
>>> first
'foo'
>>> second
'bar'
>>> third
100複製程式碼
解包還可以利用帶星號的表示式獲取單個變數中的多個元素,只要它的解釋沒有歧義即可。還可以對巢狀序列進行解包。特別是在遍歷由序列構成的複雜資料結構時,這種方法非常實用。下面是一些更復雜的解包示例:
>>> # 帶星號的表示式可以獲取序列的剩餘部分
>>> first, second, 複製程式碼rest = 0, 1, 2, 3
>>> first
0
>>> second
1
>>> rest
[2, 3]
>>> # 帶星號的表示式可以獲取序列的中間部分
>>> first, inner, last = 0, 1, 2, 3
>>> first
0
>>> inner
[1, 2]
>>> last
3
>>> # 巢狀解包
>>> (a, b), (c, d) = (1, 2), (3, 4)
>>> a, b, c, d
(1, 2, 3, 4)複製程式碼
2.字典
字典是Python中最通用的資料結構之一。dict
可以將一組唯一鍵對映到對應的值,如下所示:
{
1: ' one',
2: ' two',
3: ' three',
}複製程式碼
字典是你應該已經瞭解的基本內容。不管怎樣,程式設計師還可以用和前面列表推導類似的推導來建立一個新的字典。這裡有一個非常簡單的例子如下所示:
squares = {number: number**2 for number in range(100)}複製程式碼
重要的是,使用字典推導具有與列表推導相同的優點。因此在許多情況下,字典推導要更加高效、更加簡短、更加整潔。對於更復雜的程式碼而言,需要用到許多if
語句或函式呼叫來建立一個字典,這時最好使用簡單的for
迴圈,尤其是它還提高了可讀性。
對於剛剛接觸Python 3的Python程式設計師來說,在遍歷字典元素時有一點需要特別注意。字典的keys()
、values()
和items()
3個方法的返回值型別不再是列表。此外,與之對應的iterkeys()
、itervalues()
和iteritems()
本來返回的是迭代器,而Python 3中並沒有這3個方法。現在keys()
、values()
和items()
返回的是檢視物件(view objects)。
keys()
:返回dict keys
物件,可以檢視字典的所有鍵。values()
:返回dict
values
物件,可以檢視字典的所有值。it ems()
:返回dict _ items
物件,可以檢視字典所有的(key, value)
二元元組。
檢視物件可以動態檢視字典的內容,因此每次字典發生變化時,檢視都會相應改變,見下面這個例子:
>>> words = {'foo': 'bar', 'fizz': 'bazz'}
>>> items = words.items()
>>> words['spam'] = 'eggs'
>>> items
dictitems([('spam', 'eggs'), ('fizz', 'bazz'), ('foo', 'bar')])複製程式碼
檢視物件既有舊的keys()
、values()
和items()
方法返回的列表的特性,也有舊的iterkeys()
、itervalues()
和iteritems()
方法返回的迭代器的特性。檢視無需冗餘地將所有值都儲存在記憶體裡(像列表那樣),但你仍然可以獲取其長度(使用len
),也可以測試元素是否包含其中(使用in
子句)。當然,檢視是可迭代的。
最後一件重要的事情是,在keys()
和values()
方法返回的檢視中,鍵和值的順序是完全對應的。在Python 2中,如果你想保證獲取的鍵和值順序一致,那麼在兩次函式呼叫之間不能修改字典的內容。現在dict
keys
和dict _ values
是動態的,所以即使在呼叫keys()
和values()
之間字典內容發生了變化,那麼這兩個檢視的元素遍歷順序也是完全一致的。
(1)實現細節
CPython使用偽隨機探測(pseudo-random probing)的雜湊表(hash table)作為字典的底層資料結構。這似乎是非常高深的實現細節,但在短期內不太可能發生變化,所以程式設計師也可以把它當做一個有趣的事實來了解。
由於這一實現細節,只有可雜湊的(hashable)物件才能作為字典的鍵。如果一個物件有一個在整個生命週期都不變的雜湊值(hash value),而且這個值可以與其他物件進行比較,那麼這個物件就是可雜湊的。Python所有不可變的內建型別都是可雜湊的。可變型別(如列表、字典和集合)是不可雜湊的,因此不能作為字典的鍵。定義可雜湊型別的協議包括下面這兩個方法。
hash
:這一方法給出dict
內部實現需要的雜湊值(整數)。對於使用者自定義類的例項物件,這個值由id()
給出。eq
:比較兩個物件的值是否相等。對於使用者自定義類,除了自身之外,所有例項物件預設不相等。
如果兩個物件相等,那麼它們的雜湊值一定相等。反之則不一定成立。這說明可能會發生雜湊衝突(hash collision),即雜湊值相等的兩個物件可能並不相等。這是允許的,所有Python實現都必須解決雜湊衝突。CPython用開放定址法(open addressing)來解決這一衝突(en.wikipedia.org/wiki/Open_a…
字典的3個基本操作(新增元素、獲取元素和刪除元素)的平均時間複雜度為O(1),但它們的平攤最壞情況複雜度要高得多,為O(n),這裡的n是當前字典的元素數目。此外,如果字典的鍵是使用者自定義類的物件,並且雜湊方法不正確的話(發生衝突的風險很大),那麼這會給字典效能帶來巨大的負面影響。CPython字典的時間複雜度的完整表格如表2-2所示。
表2-2
操作 | 平均複雜度 | 平攤最壞情況複雜度 |
---|---|---|
獲取元素 | O(1) | O(n) |
修改元素 | O(1) | O(n) |
刪除元素 | O(1) | O(n) |
複製 | O(n) | O(n) |
遍歷 | O(n) | O(n) |
還有很重要的一點需要注意,在複製和遍歷字典的操作中,最壞情況複雜度中的n是字典曾經達到的最大元素數目,而不是當前元素數目。換句話說,如果一個字典曾經元素個數很多,後來又大大減少了,那麼遍歷這個字典可能要花費相當長的時間。因此在某些情況下,如果需要頻繁遍歷某個字典,那麼最好建立一個新的字典物件,而不是僅在舊字典中刪除元素。
(2)缺點和替代方案
使用字典的常見陷阱之一,就是它並不會按照鍵的新增順序來儲存元素的順序。在某些情況下,字典的鍵是連續的,對應的雜湊值也是連續值(例如整數),那麼由於字典的內部實現,元素的順序可能和新增順序相同:
>>> {number: None for number in range(5)}.keys()
dict_keys([0, 1, 2, 3, 4])複製程式碼
不過,如果使用雜湊方法不同的其他資料型別,那麼字典就不會儲存元素順序。下面是CPython中的例子:
>>> {str(number): None for number in range(5)}.keys()
dict_keys(['1', '2', '4', '0', '3'])
>>> {str(number): None for number in reversed(range(5))}.keys()
dict_keys(['2', '3', '1', '4', '0'])複製程式碼
如上述程式碼所示,字典元素的順序既與物件的雜湊方法無關,也與元素的新增順序無關。但我們也不能完全信賴這一說法,因為在不同的Python實現中可能會有所不同。
但在某些情況下,開發者可能需要使用能夠儲存新增順序的字典。幸運的是,Python標準庫的collections
模組提供了名為OrderedDict
的有序字典。它選擇性地接受一個可迭代物件作為初始化引數:
>>> from collections import OrderedDict
>>> OrderedDict((str(number), None) for number in range(5)).keys()
odictkeys(['0', '1', '2', '3', '4'])複製程式碼
OrderedDict
還有一些其他功能,例如利用popitem()
方法在雙端取出元素或者利用move
to _ end()
方法將指定元素移動到某一端。這種集合型別的完整參考可參見Python文件(docs.python.org/3/library/c…
還有很重要的一點是,在非常老的程式碼庫中,可能會用dict
來實現原始的集合,以確保元素的唯一性。雖然這種方法可以給出正確的結果,但只有在低於2.3的Python版本中才予以考慮。字典的這種用法十分浪費資源。Python有內建的set
型別專門用於這個目的。事實上,CPython中set
的內部實現與字典非常類似,但還提供了一些其他功能,以及與集合相關的特定優化。
3.集合
集合是一種魯棒性很好的資料結構,當元素順序的重要性不如元素的唯一性和測試元素是否包含在集合中的效率時,大部分情況下這種資料結構是很有用的。它與數學上的集合概念非常類似。Python的內建集合型別有兩種。
set()
:一種可變的、無序的、有限的集合,其元素是唯一的、不可變的(可雜湊的)物件。frozenset()
:一種不可變的、可雜湊的、無序的集合,其元素是唯一的、不可變的(可雜湊的)物件。
由於frozenset()
具有不變性,它可以用作字典的鍵,也可以作為其他set()
和frozenset()
的元素。在一個set()
或frozenset()
中不能包含另一個普通的可變set()
,因為這會引發TypeError
:
>>> set([set([1,2,3]), set([2,3,4])])
Traceback (most recent call last):
File "< stdin >", line 1, in < module >
TypeError: unhashable type: 'set'複製程式碼
下面這種集合初始化的方法是完全正確的:
>>> set([frozenset([1,2,3]), frozenset([2,3,4])])
{frozenset({1, 2, 3}), frozenset({2, 3, 4})}
>>> frozenset([frozenset([1,2,3]), frozenset([2,3,4])])
frozenset({frozenset({1, 2, 3}), frozenset({2, 3, 4})})複製程式碼
建立可變集合方法有以下3種,如下所示。
- 呼叫
set()
,選擇性地接受可迭代物件作為初始化引數,例如set([0, 1, 2])
。 - 使用集合推導,例如
{element for element in range(3)}
。 - 使用集合字面值,例如
{1, 2, 3}
。
注意,使用集合的字面值和推導要格外小心,因為它們在形式上與字典的字面值和推導非常相似。此外,空的集合物件是沒有字面值的。空的花括號{}
表示的是空的字典字面值。
實現細節
CPython中的集合與字典非常相似。事實上,集合被實現為帶有空值的字典,只有鍵才是實際的集合元素。此外,集合還利用這種沒有值的對映做了其他優化。
由於這一點,可以快速向集合新增元素、刪除元素或檢查元素是否存在,平均時間複雜度均為O(1)。但由於CPython的集合實現依賴於類似的雜湊表結構,因此這些操作的最壞情況複雜度是O(n),其中n是集合的當前大小。
字典的其他實現細節也適用於集合。集合中的元素必須是可雜湊的,如果集合中使用者自定義類的例項的雜湊方法不佳,那麼將會對效能產生負面影響。
4.超越基礎集合型別——collections
模組
每種資料結構都有其缺點。沒有一種集合型別適合解決所有問題,4種基本型別(元組、列表、集合和字典)提供的選擇也不算多。它們是最基本也是最重要的集合型別,都有專門的語法。幸運的是,Python標準庫內建的collections
模組提供了更多的選擇。前面已經提到過其中一種(deque
)。下面是這個模組中最重要的集合型別。
namedtuple()
:用於建立元組子類的工廠函式(factory function),可以通過屬性名來訪問它的元索引。deque
:雙端佇列,類似列表,是棧和佇列的一般化,可以在兩端快速新增或取出元素。ChainMap
:類似字典的類,用於建立多個對映的單一檢視。Counter
:字典子類,由於對可雜湊物件進行計數。OrderedDict
:字典子類,可以儲存元素的新增順序。defaultdict
:字典子類,可以通過呼叫使用者自定義的工廠函式來設定缺失值。
第12章介紹了從
collections
模組選擇集合型別的更多細節,也給出了關於何時使用這些集合型別的建議。
2.2 高階語法
在一種語言中,很難客觀判斷哪些語法元素屬於高階語法。對於本章會講到的高階語法元素,我們會講到這樣的元素,它們不與任何特定的內建型別直接相關,而且在剛開始學習時相對難以掌握。對於Python中難以理解的特性,其中最常見的是:
- 迭代器(iterator)。
- 生成器(generator)。
- 裝飾器(decorator)。
- 上下文管理器(context manager)。
2.2.1 迭代器
迭代器只不過是一個實現了迭代器協議的容器物件。它基於以下兩個方法。
next
:返回容器的下一個元素。iter
:返回迭代器本身。
迭代器可以利用內建的iter
函式和一個序列來建立。看下面這個例子:
>>> i = iter('abc')
>>> next(i)
'a'
>>> next(i)
'b'
>>> next(i)
'c'
>>> next(i)
Traceback (most recent call last):
File "< input >", line 1, in < module >
StopIteration複製程式碼
當遍歷完序列時,會引發一個StopIteration
異常。這樣迭代器就可以與迴圈相容,因為可以捕獲這個異常並停止迴圈。要建立自定義的迭代器,可以編寫一個具有 next
方法的類,只要這個類提供返回迭代器例項的 iter
特殊方法:
class CountDown:
def init(self, step):
self.step = step
def next(self):
"""Return the next element."""
if self.step < = 0:
raise StopIteration
self.step -= 1
return self.step
def iter(self):
"""Return the iterator itself."""
return self複製程式碼
下面是這個迭代器的用法示例:
>>> for element in CountDown(4):
... print(element)
...
3
2
1
0複製程式碼
迭代器本身是一個底層的特性和概念,在程式中可以不用它。但它為生成器這一更有趣的特性提供了基礎。
2.2.2 yield
語句
生成器提供了一種優雅的方法,可以讓編寫返回元素序列的函式所需的程式碼變得簡單、高效。基於yield
語句,生成器可以暫停函式並返回一箇中間結果。該函式會儲存執行上下文,稍後在必要時可以恢復。
舉個例子,斐波納契(Fibonacci)數列可以用生成器語法來實現。下列程式碼是來自於PEP 255(簡單生成器)文件中的例子:
def fibonacci():
a, b = 0, 1
while True:
yield b
a, b = b, a + b複製程式碼
你可以用next()
函式或for
迴圈從生成器中獲取新的元素,就像迭代器一樣:
>>> fib = fibonacci()
>>> next(fib)
1
>>> next(fib)
1
>>> next(fib)
2
>>> [next(fib) for i in range(10)]
[3, 5, 8, 13, 21, 34, 55, 89, 144, 233]複製程式碼
這個函式返回一個generator
物件,是特殊的迭代器,它知道如何儲存執行上下文。它可以被無限次呼叫,每次都會生成序列的下一個元素。這種語法很簡潔,演算法可無限呼叫的性質並沒有影響程式碼的可讀性。不必提供使函式停止的方法。實際上,它看上去就像用虛擬碼設計的數列一樣。
在社群中,生成器並不常用,因為開發人員還不習慣這種思考方式。多年來,開發人員已經習慣於使用直截了當的函式。每次你需要返回一個序列的函式或在迴圈中執行的函式時,都應該考慮使用生成器。當序列元素被傳遞到另一個函式中以進行後續處理時,一次返回一個元素可以提高整體效能。
在這種情況下,用於處理一個元素的資源通常不如用於整個過程的資源重要。因此,它們可以保持位於底層,使程式更加高效。舉個例子,斐波那契數列是無窮的,但用來生成它的生成器每次提供一個值,並不需要無限大的記憶體。一個常見的應用場景是使用生成器的資料流緩衝區。使用這些資料的第三方程式碼可以暫停、恢復和停止生成器,在開始這一過程之前無需匯入所有資料。
舉個例子,來自標準庫的tokenize
模組可以從文字流中生成令牌(token),並對處理過的每一行都返回一個迭代器,以供後續處理:
>>> import tokenize
>>> reader = open('hello.py').readline
>>> tokens = tokenize.generate_tokens(reader)
>>> next(tokens)
TokenInfo(type=57 (COMMENT), string='# -複製程式碼- coding: utf-8 --', start=(1,
0), end=(1, 23), line='# -- coding: utf-8 --\n')
>>> next(tokens)
TokenInfo(type=58 (NL), string='\n', start=(1, 23), end=(1, 24), line='#
-- coding: utf-8 --\n')
>>> next(tokens)
TokenInfo(type=1 (NAME), string='def', start=(2, 0), end=(2, 3),
line='def helloworld():\n')複製程式碼
從這裡可以看出,open
遍歷檔案的每一行,而generate
tokens
則利用管道對其進行遍歷,完成一些額外的工作。對於基於某些序列的資料轉換演算法而言,生成器還有助於降低演算法複雜度並提高效率。把每個序列看作一個iterator
,然後再將其合併為一個高階函式,這種方法可以有效避免函式變得龐大、醜陋、沒有可讀性。此外,這種方法還可以為整個處理鏈提供實時反饋。
在下面的示例中,每個函式都定義了一個對序列的轉換。然後將這些函式連結起來並應用。每次呼叫都將處理一個元素並返回其結果:
def power(values):
for value in values:
print('powering %s' % value)
yield value
def adder(values):
for value in values:
print('adding to %s' % value)
if value % 2 == 0:
yield value + 3
else:
yield value + 2複製程式碼
將這些生成器合併使用,可能的結果如下:
>>> elements = [1, 4, 7, 9, 12, 19]
>>> results = adder(power(elements))
>>> next(results)
powering 1
adding to 1
3
>>> next(results)
powering 4
adding to 4
7
>>> next(results)
powering 7
adding to 7
9複製程式碼
保持程式碼簡單,而不是保持資料簡單
最好編寫多個處理序列值的簡單可迭代函式,而不要編寫一個複雜函式,同時計算出整個集合的結果。
Python生成器的另一個重要特性,就是能夠利用next
函式與呼叫的程式碼進行互動。yield
變成了一個表示式,而值可以通過名為send
的新方法來傳遞:
def psychologist():
print('Please tell me your problems')
while True:
answer = (yield)
if answer is not None:
if answer.endswith('?'):
print("Don't ask yourself too much questions")
elif 'good' in answer:
print("Ahh that's good, go on")
elif 'bad' in answer:
print("Don't be so negative")複製程式碼
下面是呼叫psychologist()
函式的示例會話:
>>> free = psychologist()
>>> next(free)
Please tell me your problems
>>> free.send('I feel bad')
Don't be so negative
>>> free.send("Why I shouldn't ?")
Don't ask yourself too much questions
>>> free.send("ok then i should find what is good for me")
Ahh that's good, go on複製程式碼
send
的作用和next
類似,但會將函式定義內部傳入的值變成yield
的返回值。因此,這個函式可以根據客戶端程式碼來改變自身行為。為完成這一行為,還新增了另外兩個函式:throw
和close
。它們將向生成器丟擲錯誤。
throw
:允許客戶端程式碼傳送要丟擲的任何型別的異常。close
:作用相同,但會引發特定的異常——GeneratorExit
。在這種情況下,生成器函式必須再次引發GeneratorExit
或StopIteration
。
生成器是Python中協程、非同步併發等其他概念的基礎,這些概念將在第13章介紹。
2.2.3 裝飾器
Python裝飾器的作用是使函式包裝與方法包裝(一個函式,接受函式並返回其增強函式)變得更容易閱讀和理解。最初的使用場景是在方法定義的開頭能夠將其定義為類方法或靜態方法。如果不用裝飾器語法的話,定義可能會非常稀疏,並且不斷重複:
class WithoutDecorators:
def some_static_method():
print("this is static method")
some_static_method = staticmethod(some_static_method)
def some_class_method(cls):
print("this is class method")
some_class_method = classmethod(some_class_method)複製程式碼
如果用裝飾器語法重寫的話,程式碼會更簡短,也更容易理解:
class WithDecorators:
@staticmethod
def some_static_method():
print("this is static method")
@classmethod
def some_class_method(cls):
print("this is class method")複製程式碼
1.一般語法和可能的實現
裝飾器通常是一個命名的物件(不允許使用lambda
表示式),在被(裝飾函式)呼叫時接受單一引數,並返回另一個可呼叫物件。這裡用的是“可呼叫(callable)”。而不是之前以為的“函式”。裝飾器通常在方法和函式的範圍內進行討論,但它的適用範圍並不侷限於此。事實上,任何可呼叫物件(任何實現了 call
方法的物件都是可呼叫的)都可以用作裝飾器,它們返回的物件往往也不是簡單的函式,而是實現了自己的 call
方法的更復雜的類的例項。
裝飾器語法只是語法糖而已。看下面這種裝飾器用法:
@some_decorator
def decorated_function():
pass複製程式碼
這種寫法總是可以替換為顯式的裝飾器呼叫和函式的重新賦值:
def decorated_function():
pass
decorated_function = some_decorator(decorated_function)複製程式碼
但是,如果在一個函式上使用多個裝飾器的話,後一種寫法的可讀性更差,也非常難以理解。
裝飾器甚至不需要返回可呼叫物件!
事實上,任何函式都可以用作裝飾器,因為Python並沒有規定裝飾器的返回型別。因此,將接受單一引數但不返回可呼叫物件的函式(例如
str
)用作裝飾器,在語法上是完全有效的。如果使用者嘗試呼叫這樣裝飾過的物件,最後終究會報錯。不管怎樣,針對這種裝飾器語法可以做一些有趣的試驗。
(1)作為一個函式
編寫自定義裝飾器有許多方法,但最簡單的方法就是編寫一個函式,返回包裝原始函式呼叫的一個子函式。
通用模式如下:
def mydecorator(function):
def wrapped(複製程式碼args, kwargs):
# 在呼叫原始函式之前,做點什麼
result = function(*args, kwargs)
# 在函式呼叫之後,做點什麼,
# 並返回結果
return result
# 返回wrapper作為裝飾函式
return wrapped複製程式碼
(2)作為一個類
雖然裝飾器幾乎總是可以用函式實現,但在某些情況下,使用使用者自定義類可能更好。如果裝飾器需要複雜的引數化或者依賴於特定狀態,那麼這種說法往往是對的。
非引數化裝飾器用作類的通用模式如下:
class DecoratorAsClass:
def init(self, function):
self.function = function
def call(self, args, **kwargs):
# 在呼叫原始函式之前,做點什麼
result = self.function(args, kwargs)
# 在呼叫函式之後,做點什麼,
# 並返回結果
return result複製程式碼
(3)引數化裝飾器
在實際程式碼中通常需要使用引數化的裝飾器。如果用函式作為裝飾器的話,那麼解決方法很簡單:需要用到第二層包裝。下面一個簡單的裝飾器示例,給定重複次數,每次被呼叫時都會重複執行一個裝飾函式:
def repeat(number=3):
"""多次重複執行裝飾函式。
返回最後一次原始函式呼叫的值作為結果
:param number: 重複次數,預設值是3
"""
def actual_decorator(function):
def wrapper(*args, 複製程式碼kwargs):
result = None
for _ in range(number):
result = function(args, **kwargs)
return result
return wrapper
return actual_decorator複製程式碼
這樣定義的裝飾器可以接受引數:
>>> @repeat(2)
... def foo():
... print("foo")
...
>>> foo()
foo
foo複製程式碼
注意,即使引數化裝飾器的引數有預設值,但名字後面也必須加括號。帶預設引數的裝飾器的正確用法如下:
>>> @repeat()
... def bar():
... print("bar")
...
>>> bar()
bar
bar
bar複製程式碼
沒加括號的話,在呼叫裝飾函式時會出現以下錯誤:
>>> @repeat
... def bar():
... pass
...
>>> bar()
Traceback (most recent call last):
File "< input >", line 1, in < module >
TypeError: actual_decorator() missing 1 required positional
argument: 'function'複製程式碼
(4)儲存內省的裝飾器
使用裝飾器的常見錯誤是在使用裝飾器時不儲存函式後設資料(主要是文件字串和原始函式名)。前面所有示例都存在這個問題。裝飾器組合建立了一個新函式,並返回一個新物件,但卻完全沒有考慮原始函式的標識。這將會使得除錯這樣裝飾過的函式更加困難,也會破壞可能用到的大多數自動生成文件的工具,因為無法訪問原始的文件字串和函式簽名。
但我們來看一下細節。假設我們有一個虛設的(dummy)裝飾器,僅有裝飾作用,還有其他一些被裝飾的函式:
def dummy_decorator(function):
def wrapped(複製程式碼args, kwargs):
"""包裝函式內部文件。"""
return function(*args, kwargs)
return wrapped
@dummy_decorator
def function_with_importantdocstring():
"""這是我們想要儲存的重要文件字串。"""複製程式碼
如果我們在Python互動式會話中檢視function
with important docstring()
,會注意到它已經失去了原始名稱和文件字串:
>>> function_with_important_docstring.name
'wrapped'
>>> function_with_important_docstring.doc
'包裝函式內部文件。'複製程式碼
解決這個問題的正確方法,就是使用functools
模組內建的wraps()
裝飾器:
from functools import wraps
def preserving_decorator(function):
@wraps(function)
def wrapped(args, **kwargs):
"""包裝函式內部文件。"""
return function(args, kwargs)
return wrapped
@preserving_decorator
def function_with_important_docstring():
"""這是我們想要儲存的重要文件字串。"""複製程式碼
這樣定義的裝飾器可以儲存重要的函式後設資料:
>>> function_with_important_docstring.name
'function_with_important_docstring.'
>>> function_with_important_docstring.doc
'這是我們想要儲存的重要文件字串。'複製程式碼
2.用法和有用的例子
由於裝飾器在模組被首次讀取時由直譯器來載入,所以它們的使用應受限於通用的包裝器(wrapper)。如果裝飾器與方法的類或所增強的函式簽名繫結,那麼應該將其重構為常規的可呼叫物件,以避免複雜性。在任何情況下,裝飾器在處理API時,一個好的做法是將它們聚集在一個易於維護的模組中。
常見的裝飾器模式如下所示。
- 引數檢查。
- 快取。
- 代理。
- 上下文提供者。
(1)引數檢查
檢查函式接受或返回的引數,在特定上下文中執行時可能有用。舉個例子,如果一個函式要通過XML-RPC來呼叫,那麼Python無法像靜態語言那樣直接提供其完整簽名。當XML-RPC客戶端請求函式簽名時,就需要用這個功能來提供內省能力。
XML-RPC協議
XML-RPC協議是一種輕量級的遠端過程呼叫(Remote Procedure Call)協議,通過HTTP使用XML對呼叫進行編碼。對於簡單的客戶端-伺服器交換,通常使用這種協議而不是SOAP。SOAP提供了列出所有可呼叫函式的頁面(WSDL),XML-RPC與之不同,並沒有可用函式的目錄。該協議提出了一個擴充套件,可以用來發現伺服器API,Python的
xmlrpc
模組實現了這一擴充套件(參見docs.python.org/3/library/x…
自定義裝飾器可以提供這種型別的簽名,並確保輸入和輸出代表自定義的簽名引數:
rpcinfo = {}
def xmlrpc(in=(), out=(type(None),)):
def _xmlrpc(function):
# 註冊簽名
func_name = function.name
rpc_info[funcname] = (in, out)
def _check_types(elements, types):
"""用來檢查型別的子函式。"""
if len(elements) != len(types):
raise TypeError('argument count is wrong')
typed = enumerate(zip(elements, types))
for index, couple in typed:
arg, of_the_right_type = couple
if isinstance(arg, of_the_right_type):
continue
raise TypeError(
'arg #%d should be %s' % (index,
of_the_right_type))
# 包裝過的函式
def xmlrpc(args): # 沒有允許的關鍵詞
# 檢查輸入的內容
checkable_args = args[1:] # 去掉self
_check_types(checkableargs, in)
# 執行函式
res = function(args)
# 檢查輸出的內容
if not type(res) in (tuple, list):
checkable_res = (res,)
else:
checkable_res = res
_check_types(checkable_res, out)
# 函式及其型別檢查成功
return res
return xmlrpc
return xmlrpc複製程式碼
裝飾器將函式註冊到全域性字典中,並將其引數和返回值儲存在一個型別列表中。注意,這個示例做了很大的簡化,為的是展示裝飾器的引數檢查功能。
使用示例如下:
class RPCView:
@xmlrpc((int, int)) # two int -> None
def meth1(self, int1, int2):
print('received %d and %d' % (int1, int2))
@xmlrpc((str,), (int,)) # string -> int
def meth2(self, phrase):
print('received %s' % phrase)
return 12複製程式碼
在實際讀取時,這個類定義會填充rpc
infos
字典,並用於檢查引數型別的特定環境中:
>>> rpc_info
{'meth2': ((< class 'str'>,), (< class 'int'>,)), 'meth1': ((< class
'int'>, < class 'int'>), (,))}
>>> my = RPCView()
>>> my.meth1(1, 2)
received 1 and 2
>>> my.meth2(2)
Traceback (most recent call last):
File "< input>", line 1, in < module>
File "< input>", line 26, in xmlrpc
File "< input>", line 20, in _check_types
TypeError: arg #0 should be < class 'str'>複製程式碼
(2)快取
快取裝飾器與引數檢查十分相似,不過它重點是關注那些內部狀態不會影響輸出的函式。每組引數都可以連結到唯一的結果。這種程式設計風格是函數語言程式設計(functional programming,參見en.wikipedia.org/wiki/Functi…
因此,快取裝飾器可以將輸出與計算它所需要的引數放在一起,並在後續的呼叫中直接返回它。這種行為被稱為memoizing(參見en.wikipedia.org/wiki/Memoiz…
import time
import hashlib
import pickle
cache = {}
def is_obsolete(entry, duration):
return time.time() - entry['time'] > duration
def compute_key(function, args, kw):
key = pickle.dumps((function.複製程式碼name, args, kw))
return hashlib.sha1(key).hexdigest()
def memoize(duration=10):
def _memoize(function):
def memoize(*args, 複製程式碼kw):
key = compute_key(function, args, kw)
# 是否已經擁有它了?
if (key in cache and
not is_obsolete(cache[key], duration)):
print('we got a winner')
return cache[key]['value']
# 計算
result = function(args, **kw)
# 儲存結果
cache[key] = {
'value': result,
'time': time.time()
}
return result
return memoize
return _memoize複製程式碼
利用已排序的引數值來構建SHA
雜湊鍵,並將結果儲存在一個全域性字典中。利用pickle來建立hash,這是凍結所有作為引數傳入的物件狀態的快捷方式,以確保所有引數都滿足要求。舉個例子,如果用一個執行緒或套接字作為引數,那麼會引發PicklingError
(參見docs.python.org/3/library/p…duration
引數的作用是,如果上一次函式呼叫已經過去了太長時間,那麼它會使快取值無效。
下面是一個使用示例:
>>> @memoize()
... def very_very_very_complex_stuff(a, b):
... # 如果在執行這個計算時計算機過熱
... # 請考慮中止程式
... return a + b
...
>>> very_very_very_complex_stuff(2, 2)
4
>>> very_very_very_complex_stuff(2, 2)
we got a winner
4
>>> @memoize(1) # 1秒後令快取失效
... def very_very_very_complex_stuff(a, b):
... return a + b
...
>>> very_very_very_complex_stuff(2, 2)
4
>>> very_very_very_complex_stuff(2, 2)
we got a winner
4
>>> cache
{'c2727f43c6e39b3694649ee0883234cf': {'value': 4, 'time':
1199734132.7102251)}
>>> time.sleep(2)
>>> very_very_very_complex_stuff(2, 2)
4複製程式碼
快取代價高昂的函式可以顯著提高程式的總體效能,但必須小心使用。快取值還可以與函式本身繫結,以管理其作用域和生命週期,代替集中化的字典。但在任何情況下,更高效的裝飾器會使用基於高階快取演算法的專用快取庫。
第12章將會介紹與快取相關的詳細資訊和技術。
(3)代理
代理裝飾器使用全域性機制來標記和註冊函式。舉個例子,一個根據當前使用者來保護程式碼訪問的安全層可以使用集中式檢查器和相關的可呼叫物件要求的許可權來實現:
class User(object):
def 複製程式碼init(self, roles):
self.roles = roles
class Unauthorized(Exception):
pass
def protect(role):
def _protect(function):
def protect(複製程式碼args, kw):
user = globals().get('user')
if user is None or role not in user.roles:
raise Unauthorized("I won't tell you")
return function(*args, kw)
return protect
return _protect複製程式碼
這一模型常用於Python Web框架中,用於定義可釋出類的安全性。例如,Django提供裝飾器來保護函式訪問的安全。
下面是一個示例,當前使用者被儲存在一個全域性變數中。在方法被訪問時裝飾器會檢查他/她的角色:
>>> tarek = User(('admin', 'user'))
>>> bill = User(('user',))
>>> class MySecrets(object):
... @protect('admin')
... def waffle_recipe(self):
... print('use tons of butter!')
...
>>> these_are = MySecrets()
>>> user = tarek
>>> these_are.waffle_recipe()
use tons of butter!
>>> user = bill
>>> these_are.waffle_recipe()
Traceback (most recent call last):
File "< stdin>", line 1, in < module>
File "< stdin>", line 7, in wrap
main.Unauthorized: I won't tell you複製程式碼
(4)上下文提供者
上下文裝飾器確保函式可以執行在正確的上下文中,或者在函式前後執行一些程式碼。換句話說,它設定並復位一個特定的執行環境。舉個例子,當一個資料項需要在多個執行緒之間共享時,就要用一個鎖來保護它避免多次訪問。這個鎖可以在裝飾器中編寫,程式碼如下:
from threading import RLock
lock = RLock()
def synchronized(function):
def _synchronized(args, **kw):
lock.acquire()
try:
return function(args, **kw)
finally:
lock.release()
return _synchronized
@synchronized
def thread_safe(): # 確保鎖定資源
pass複製程式碼
上下文裝飾器通常會被上下文管理器(with
語句)替代,後者將在本章後面介紹。
2.2.4 上下文管理器——with
語句
為了確保即使在出現錯誤的情況下也能執行某些清理程式碼,try...finally
語句是很有用的。這一語句有許多使用場景,例如:
- 關閉一個檔案。
- 釋放一個鎖。
- 建立一個臨時的程式碼補丁。
- 在特殊環境中執行受保護的程式碼。
with
語句為這些使用場景下的程式碼塊包裝提供了一種簡單方法。即使該程式碼塊引發了異常,你也可以在其執行前後呼叫一些程式碼。例如,處理檔案通常採用這種方式:
>>> hosts = open('/etc/hosts')
>>> try:
... for line in hosts:
... if line.startswith('#'):
... continue
... print(line.strip())
... finally:
... hosts.close()
...
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost複製程式碼
本示例只針對Linux系統,因為要讀取位於
etc
資料夾中的主機檔案,但任何文字檔案都可以用相同的方法來處理。
利用with
語句,上述程式碼可以重寫為:
>>> with open('/etc/hosts') as hosts:
... for line in hosts:
... if line.startswith('#'):
... continue
... print(line.strip())
...
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost複製程式碼
在前面的示例中,open
的作用是上下文管理器,確保即使出現異常也要在執行完for
迴圈之後關閉檔案。
與這條語句相容的其他專案是來自threading
模組的類:
threading.Lock
threading.RLock
threading.Condition
threading.Semaphore
threading.BoundedSemaphore
一般語法和可能的實現
with
語句的一般語法的最簡單形式如下:
with context_manager:
# 程式碼塊
...複製程式碼
此外,如果上下文管理器提供了上下文變數,可以用as
子句儲存為區域性變數:
with context_manager as context:
# 程式碼塊
...複製程式碼
注意,多個上下文管理器可以同時使用,如下所示:
with A() as a, B() as b:
...複製程式碼
這種寫法等價於巢狀使用,如下所示:
with A() as a:
with B() as b:
...複製程式碼
(1)作為一個類
任何實現了上下文管理器協議(context manager protocol)的物件都可以用作上下文管理器。該協議包含兩個特殊方法。
enter (self)
:更多內容請訪問docs.python.org/3.3/referen… #object.enter。exit (self, exc type, exc value, traceback)
:更多內容請訪問docs.python.org/3.3/referen….exit。
簡而言之,執行with
語句的過程如下:
- 呼叫
enter
方法。任何返回值都會繫結到指定的as
子句。 - 執行內部程式碼塊。
- 呼叫
exit
方法。
exit
接受程式碼塊中出現錯誤時填入的3個引數。如果沒有出現錯誤,那麼這3個引數都被設為None
。出現錯誤時, exit
不應該重新引發這個錯誤,因為這是呼叫者(caller)的責任。但它可以通過返回True
來避免引發異常。這可用於實現一些特殊的使用場景,例如下一節將會看到的contextmanager
裝飾器。但在大多數使用場景中,這一方法的正確行為是執行類似於finally
子句的一些清理工作,無論程式碼塊中發生了什麼,它都不會返回任何內容。
下面是某個實現了這一協議的上下文管理器示例,以更好地說明其工作原理:
class ContextIllustration:
def 複製程式碼enter(self):
print('entering context')
def exit(self, exc_type, exc_value, traceback):
print('leaving context')
if exc_type is None:
print('with no error')
else:
print('with an error (%s)' % exc_value)複製程式碼
沒有引發異常時的執行結果如下:
>>> with ContextIllustration():
... print("inside")
...
entering context
inside
leaving context
with no error複製程式碼
引發異常時的輸出如下:
>>> with ContextIllustration():
... raise RuntimeError("raised within 'with'")
...
entering context
leaving context
with an error (raised within 'with')
Traceback (most recent call last):
File "< input >", line 2, in < module >
RuntimeError: raised within 'with'複製程式碼
(2)作為一個函式——contextlib
模組
使用類似乎是實現Python語言提供的任何協議最靈活的方法,但對許多使用場景來說可能樣板太多。標準庫中新增了contextlib
模組,提供了與上下文管理器一起使用的輔助函式。它最有用的部分是contextmanager
裝飾器。你可以在一個函式裡面同時提供 enter
和 exit
兩部分,中間用yield
語句分開(注意,這樣函式就變成了生成器)。用這個裝飾器編寫前面的例子,其程式碼如下:
from contextlib import contextmanager
@contextmanager
def contextillustration():
print('entering context')
try:
yield
except Exception as e:
print('leaving context')
print('with an error (%s)' % e)
# 需要再次丟擲異常
raise
else:
print('leaving context')
print('with no error')複製程式碼
如果出現任何異常,該函式都需要再次丟擲這個異常,以便傳遞它。注意,context
illustration
在需要時可以有一些引數,只要在呼叫時提供這些引數即可。這個小的輔助函式簡化了常規的基於類的上下文API,正如生成器對基於類的迭代器API的作用一樣。
這個模組還提供了其他3個輔助函式。
closing(element)
:返回一個上下文管理器,在退出時會呼叫該元素的close
方法。例如,它對處理流的類就很有用。supress(*exceptions)
:它會壓制發生在with
語句正文中的特定異常。redirect stdout(new target)
和redirect stderr(new target)
:它會將程式碼塊內任何程式碼的sys.stdout
或sys.stderr
輸出重定向到類檔案(file-like)物件的另一個檔案。
2.3 你可能還不知道的其他語法元素
Python語法中有一些元素不太常見,也很少用到。這是因為它們能提供的好處很少,或者它們的用法很難記住。因此,許多Python程式設計師(即使有多年的經驗)完全不知道這些語法元素的存在。其中最有名的例子如下:
for ... else
語句。- 函式註解(function annotation)。
2.3.1 for ... else ...
語句
在for
迴圈之後使用else
子句,可以在迴圈“自然”結束而不是被break
語句終止時執行一個程式碼塊:
>>> for number in range(1):
... break
... else:
... print("no break")
...
>>>
>>> for number in range(1):
... pass
... else:
... print("break")
...
break複製程式碼
這一語句在某些情況下很有用,因為它有助於刪除一些“哨兵(sentinel)”變數,如果出現break
時使用者想要儲存資訊,可能會需要這些變數。這使得程式碼更加清晰,但可能會使不熟悉這種語法的程式設計師感到困惑。有人說else
子句的這種含義是違反直覺的,但這裡介紹一個簡單的技巧,可以幫你記住它的用法:for
迴圈之後else
子句的含義是“沒有break”。
2.3.2 函式註解
函式註解是Python 3最獨特的功能之一。官方文件是這麼說的:函式註解是關於使用者自定義函式使用的型別的完全可選的元資訊,但事實上,它並不侷限於型別提示,而且在Python及其標準庫中也沒有單個功能可以利用這種註解。這就是這個功能獨特的原因:它沒有任何語法上的意義。可以為函式定義註解,並在執行時獲取這些註解,但僅此而已。如何使用註解留給開發人員去思考。
1.一般語法
對Python官方文件中的示例稍作修改,就可以很好展示如何定義並獲取函式註解:
>>> def f(ham: str, eggs: str = 'eggs') -> str:
... pass
...
>>> print(f.annotations)
{'return': < class 'str' >, 'eggs': < class 'str' >, 'ham': < class 'str' >}複製程式碼
如上所述,引數註解的定義為冒號後計算註解值的表示式。返回值註解的定義為表示def
語句結尾的冒號與引數列表之後的->
之間的表示式。
定義好之後,註解可以通過函式物件的 annotations __
屬性獲取,它是一個字典,在應用執行期間可以獲取。
任何表示式都可以用作註解,其位置靠近預設引數,這樣可以建立一些迷惑人的函式定義,如下所示:
>>> def square(number: 0< =3 and 1=0) - > (\
... +9000): return number**2
>>> square(10)
100複製程式碼
不過,註解的這種用法只會讓人糊塗,沒有任何其他作用。即使不用註解,編寫出難以閱讀和理解的程式碼也是相對容易的。
2.可能的用法
雖然註解有很大的潛力,但並沒有被廣泛使用。一篇介紹Python 3新增功能的文章(參見docs.python.org/3/whatsnew/…PEP 3107列出以下可能的使用場景:
- 提供型別資訊。
- 型別檢查。
- 讓IDE顯示函式接受和返回的型別。
- 函式過載/通用函式。
- 與其他語言之間的橋樑。
- 適配。
- 謂詞邏輯函式。
- 資料庫查詢對映。
- RPC引數編組。
- 其他資訊。
- 引數和返回值的文件。
雖然函式註解存在的時間和Python 3一樣長,但仍然很難找到任一常見且積極維護的包,將函式註解用作型別檢查之外的功能。所以函式註解仍主要用於試驗和玩耍,這也是Python 3最初發布時包含該功能的最初目的。
2.4 小結
本章介紹了不直接與Python類和麵向物件程式設計相關的多個最佳語法實踐。本章第一部分重點介紹了與Python序列和集合相關的語法特性,也討論了字串和位元組相關的序列。本章其餘部分介紹了兩組獨立的語法元素:一組是初學者相對難以理解的(例如迭代器、生成器和裝飾器),另一組是鮮為人知的(for...else
子句和函式註解)。
</div>複製程式碼