《流暢的Python》筆記。
本章將說明Python中迭代器和生成器的執行原理。
1. 前言
如果做嚴格區分,迭代器(iterator)和生成器(generator)是兩個概念。迭代器是用於從集合中挨個獲取元素,要求資料已存在;而生成器則是“憑空”生成元素,最典型的就是斐波那契數列。但是在Python中,大多數時候迭代器和生成器被視作同一概念。從Python2.2開始,可以使用yield
關鍵字構建生成器,其作用和迭代器一樣。在Python3中,生成器有了更廣泛的用途,比如range()
函式返回的就是一個類似生成器的物件,而在以前,它返回的是完整的列表。
本篇將有如下內容:
iter()
內建函式處理可迭代物件的方式- 如何使用Python實現經典的迭代器模式
- 詳細說明生成器函式的工作原理
- 如何使用生成器函式或生成器表示式代替經典的迭代器
- 如何使用
yield from
語句合成生成器
2. 可迭代物件與迭代器
2.1 iter()函式
當Python直譯器需要迭代物件x
時,會自動呼叫iter(x)
。內建的iter()
函式的執行過程如下:
- 檢查物件是否實現了
__iter__
方法,如果實現了就呼叫它來獲取一個迭代器; - 如果沒有實現
__iter__
方法,但實現了__getitem__
方法,Python會建立一個迭代器,嘗試從索引0開始獲取元素; - 如果上述操作都失敗了,Python丟擲
TypeError
異常,通常會提示“T object is not iterable”
,其中T
是目標物件所屬的類。
而從上述解釋可以看出,任何Python序列都可迭代的原因是,它們都實現了__getitem__
方法。但iter()
函式之所以要檢查__getitem__
方法,除了能讓更多物件可迭代之外,其實還為了向下相容。至於iter()
以後還檢不檢查__getitem__
方法就很難說了(不過目測未來很長一段時間內應該不會改變這種策略),而標準的序列型別都實現了__iter__
方法,所以,如果自定義類要實現可迭代,請實現__iter__
方法。
由此,我們還可得出可迭代的物件的定義:
實現了__iter__
方法,能獲取迭代器;或者實現了__getitem__
方法,能從零開始索引的物件都是可迭代的物件。
補充:
-
從Python3.4開始,檢查物件
x
能否迭代,最準確的方法是:呼叫iter(x)
,如果不可迭代,再處理TypeError
異常。這比使用isinstance(x, abc.Iterable)
更準確,因為abc.Iterable
不會考慮__getitem__
方法。 -
iter()
函式還有一個鮮為人知的用法,即:傳入兩個引數,使用常規的函式或任何可呼叫物件建立迭代器。此時,第一個引數必須是可呼叫物件,第二個引數是“哨兵”。當可呼叫物件返回的值與“哨兵”相等時,拋棄該值,結束迭代並丟擲StopIteration
異常。這種用法的一個實際情況就是讀取檔案,當讀取到空行或檔案末尾時,停止讀取:# 程式碼2.1 with open("test.txt") as fp: for line in iter(fp.readline, "\n"): process_line(line) 複製程式碼
2.2 迭代器
首先需要明確可迭代物件和迭代器之間的關係:Python從可迭代物件中獲取迭代器。當物件實現了__iter__
方法時,Python從它獲取迭代器;當物件只實現了__getitem__
方法時,Python為這個物件建立迭代器。所以,Python在迭代時始終用的是迭代器!
標準迭代器的UML繼承關係圖如下:
從上圖以及之前的描述,我們可以總結出以下幾點:
- 具體的可迭代物件的
__iter__
方法應該返回一個具體的迭代器; - 具體的迭代器必須實現
__next__
和__iter__
方法。__iter__
方法返回迭代器本身(return self
);真正的迭代操作由__next__
完成,當沒有可迭代元素時,它還要丟擲StopIteration
異常; - 由於迭代器也是從
Iterable
派生出來的,所以,迭代器是可迭代物件!
從上述內容可以猜出,應該有一個next()
函式與iter()
函式配對。沒錯,對可迭代物件的具體迭代操作就是由next()
函式完成。以下是兩個迭代過程:
# 程式碼2.2
s = "ABC"
# 方法1,Python會隱式建立迭代器,並捕獲StopIteration異常
for char in s:
print(char)
# 方法2,顯式建立迭代器並顯式迭代,此時需要手動捕獲StopIteration異常
it = iter(s)
while True:
try:
print(next(it))
except StopIteration:
del it
break
複製程式碼
如果我們要實現具體的迭代器,並不一定需要從collections.abc.Iterator
繼承,只需要實現__next__
和__iter__
方法即可。在Python的Lib/types.py
原始檔有如下注釋:
# Iterators in Python aren't a matter of type but of protocol. A large
# and changing number of builtin types implement *some* flavor of
# iterator. Don't check the type! Use hasattr to check for both
# "__iter__" and "__next__" attributes instead.
複製程式碼
所以,這裡可以給迭代器下個定義:實現了__next__
和__iter__
方法的物件就是迭代器。如果再去檢視abc.Iterator
的原始碼,可以發現如下程式碼:
# 程式碼2.3
class Iterator(Iterable):
-- snip --
@classmethod
def __subclasshook__(cls, C):
# 做了更改,實際是呼叫 _check_methods(C, '__iter__', '__next__')
if cls is Iterator:
if (any("__next__" in B.__dict__ for B in C.__mro__) and
any("__iter__" in B.__dict__ for B in C.__mro__)):
return True
# 希望大家看到NotImplemented能想到Python直譯器後面會有什麼操作
return NotImplemented # 如果猜不到,可以檢視《Python學習之路32》
複製程式碼
綜上,Iterator
採用的是白鵝型別技術:它實現了__subclasshook__
方法,通過判斷物件x
是否實現了__next__
和__iter__
來判斷x
是否是迭代器。所以,判斷物件x
是否為迭代器的最好方法是呼叫isinstance(x, abc.Iterator)
。
***友情提示:***通過迭代器不能判斷是否還有剩餘的元素,迭代器也不能重置。當然,你可以為迭代器新增其他方法來實現這兩種功能,但並不推薦這種做法,除非這程式碼只有你自己欣賞。如果想要重新迭代,請再次呼叫iter()
函式,並傳入之前的可迭代物件,傳入迭代器是沒有用。
2.3 典型的迭代器
下面通過實現一個Sentence
類和與之配對的SentenceIterator
來演示傳統迭代器的實現過程:
# 程式碼2.4
import re
import reprlib
RE_WORD = re.compile("\w+")
class Sentence:
def __init__(self, text):
self.text = text
self.words = RE_WORD.findall(text)
def __iter__(self):
return SentenceIterator(self.words)
class SentenceIterator:
def __init__(self, words):
self.words = words
self.index = 0 # 儲存索引
def __next__(self):
try:
word = self.words[self.index]
except IndexError: # 超出索引範圍時丟擲異常
raise StopIteration()
self.index += 1 # 遞增索引
return word
def __iter__(self):
return self # 返回迭代器本身
複製程式碼
這裡需要指出一個典型的錯誤思想:把Sentence
變為迭代器。迭代器是可迭代物件,但可迭代物件不能是迭代器!請不要在可迭代物件的__iter__
中返回可迭代物件自身,也不要為可迭代物件新增__next__
方法!這是一種常見的反模式行為。
從設計模式來講,我們對可迭代物件並不只有逐個迭代這種方式,有可能跳躍式迭代,也有可能反向迭代。如果把一個物件設計成既是可迭代物件也是迭代器,那這個物件內部將會有成噸的if-else
語句,這非常不利於維護和擴充套件。
3. 生成器
上述版本中的Sentence
需要配備一個迭代器。而更符合Python風格的方式是用生成器函式代替SentenceIterator
。
3.1 生成器函式
使用生成器函式改寫傳統的迭代器(實際上不再定義迭代器):
# 程式碼3.1 Sentence中其餘程式碼不變,且不用再定義SentenceIterator
class Sentence:
-- snip --
def __iter__(self):
for word in self.words:
yield word
複製程式碼
解釋:這裡的__iter__
是生成器函式,呼叫它時會建立生成器物件**,然後用這個生成器物件充當迭代器。
3.2 生成器函式工作原理
只要Python函式的定義體中有yield
關鍵字,該函式就是生成器函式(這也是和普通函式的唯一區別)。“生成器”一詞指代生成器函式,以及生成器函式構建的生成器物件,比較籠統,所以請具體語境具體分析。
生成器函式是一個生成器工廠,呼叫生成器函式時建立一個生成器物件,包裝生成器函式的定義體。
生成器物件實現了迭代器介面,通常Python會自動建立這個物件。當對生成器物件呼叫next()
函式時,生成器函式會執行到定義體中的下一個yield
語句的末尾,生成yield
關鍵字後面的表示式的值,然後停止在此處,等待下一次呼叫。當定義體中所有語句都執行完後,生成器函式返回,外層的生成器物件丟擲StopIteration
異常。
友情提醒:生成器函式並不是只執行其中的yield
語句;也不是只執行到最後一個yield
語句,如果最後一個yield
語句後面還有程式碼,依然會執行。
下面是關於生成器的一個簡單例子:
# 程式碼3.2
>>> def gen_AB():
... print("Start")
... yield "A"
... print("Continue")
... yield "B"
... print("End.")
...
>>> gen_AB
<function gen_AB at 0x...> # 返回值和普通函式沒區別
>>> gen_AB()
<generator object gen_AB at 0x...> # 返回了一個生成器物件
>>> g = gen_AB()
>>> next(g)
Start # print("Start")
'A' # 這個是生成的值
>>> temp = next(g) # 獲取生成器生成的第二個值
Continue # print("Continue")
>>> temp # 輸出生成器生成的第二個值
'B' # 此時還並沒有丟擲異常,因為生成器函式還沒執行完
>>> next(g)
End. # 生成器函式執行完畢,生成器丟擲異常。
Traceback (most recent call last): # 顯式呼叫next()需要自行捕獲異常
File "<input>", line 1, in <module>
StopIteration
複製程式碼
3.3 惰性實現與生成器表示式
上述的兩個版本中,我們都用了self.words
屬性來儲存文字中的單詞,即在建立Sentence
物件時就獲得了所有的單詞。這種方式叫做及早求值(Eager Evaluation)。而與之相反的則是惰性求值(Lazy Evaluation),通俗講就是“等用到的時候再來求值”。及早求值可能會消耗大量記憶體,而惰性求值則是為了減少記憶體的使用。
生成器表示式以前提到過,它是用圓括號括起來的推導式(並不是生成元組)。生成器表示式可以理解為列表推導的惰性版本:不會一次性構造整個列表,而是返回一個生成器,按需惰性生成元素。以下是它的一個簡單示例:
# 程式碼3.3
>>> def gen_AB():
... print("Start")
... yield "A"
... print("Continue")
... yield "B"
... print("End.")
...
>>> res1 = [x * 3 for x in gen_AB()] # 這裡有一個生成器,但被列表推導式全部迭代完
Start
Continue
End.
>>> res1 # 一次性生成了完整的列表
['AAA', 'BBB']
>>> res2 = (x * 3 for x in gen_AB()) # 這裡其實有連個生成器
>>> res2 # 返回了一個生成器物件,並沒有一次性生成所有資料,惰性
<generator object <genexpr> at 0x000001D6D34D4408>
>>> for i in res2:
... print(i)
...
Start
AAA
Continue
BBB
End.
複製程式碼
***解釋:***由於gen_AB()
是個生成器函式,所以(x * 3 for x in gen_AB())
包含了兩個生成器物件,其中一個是由gen_AB()
建立的,是不是有點巢狀生成器的意思?
現在我們使用re.finditer
將第2版的Sentence
改為惰性版本,並使用生成器表示式進一步簡化程式碼:
# 程式碼3.4
class Sentence:
def __init__(self, text):
self.text = text # 去掉了self.words
def __iter__(self):
return (match.group() for match in RE_WORD.finditer(self.text))
# 不適用生成器表示式的版本如下:
# for match in RE_WORD.finditer(self.text):
# yield match.group()
複製程式碼
***友情提醒:***在Python3中,如果想把某種實現變成惰性版本,一般都是可以的......
生成器表示式是建立生成器的簡潔語法,這樣就無需定義生成器函式,一般在情況簡單時使用。不過,生成器函式靈活得多,可以使用多個語句實現更復雜的邏輯,也可以作為協程使用,還可以重用程式碼。
3.4 itertools模組
該模組包含了很多有用的生成器函式,這裡介紹兩個生成器函式itertools.count
和itertools.takewhile
。
前面介紹的生成器中的資料都是有窮集合,而itertools.count
則生成無窮集合。它有兩個引數起始數值start
和步長step
,start
預設是0
,step
預設是1
。這兩個引數都支援多種數字型別,比如int
,float
,decimal.Decimal
和fractions.Fraction
。以下是它的一個示例:
# 程式碼3.5
>>> import itertools
>>> gen = itertools.count(1, 0.5)
>>> next(gen)
1
>>> next(gen)
1.5
複製程式碼
由於itertools.count
不停止生成資料,所以如果呼叫list(count())
,你的電腦會瘋狂運轉,直到超出記憶體限制。
itertools.takewhile
函式則不同,它會生成一個使用另一個生成器的生成器,在指定的函式返回False
時停止。因此,這兩個迭代器可以結合使用:
# 程式碼3.6
>>> gen = itertools.takewhile(lambda n: n < 3, itertools.count(1, 0.5))
>>> list(gen)
[1, 1.5, 2.0, 2.5]
複製程式碼
標準庫中還有很多非常有用的生成器函式,這裡就不一一列出了。
3.5 yield from
如果生成器函式需要產出另一個生成器生成的值,傳統的解決方法是使用巢狀for
迴圈,比如如下函式:
# 程式碼3.7
def chain(*iterables): # iterables中的元素是可迭代物件
for it in iterables:
for i in it:
yield i
複製程式碼
而如果使用yield from
句法則可以使程式碼更簡潔:
# 程式碼3.8
def chain(*iterables):
for it in iterables:
yield from it
複製程式碼
yield from
語法不僅僅是語法糖,除了代替迴圈之外,yield from
還會建立通道,把生成器當做協程使用。
3.6 把生成器當做協程
從Python2.5起,生成器加入了一個名為.send()
的方法,與.__next__
方法一樣,.send
方法致使生成器推進到下一個yield
語句。但.send
方法還允許生成器的呼叫者向生成器傳入引數,把這個引數作為對應的yield
語句的返回值。這個方法讓呼叫者和生成器之間能雙向交換資料,而.__next__
方法只允許呼叫者從生成器獲取值。下面是這個方法的一個簡單示例:
# 程式碼3.9 省略了最後丟擲的StopIteration異常
>>> def test_send():
... a = yield 1
... print("At the end of function, a = ", a)
...
>>> g = test_send()
>>> next(g)
1
>>> next(g)
At the end of function, a = None # 可以看出,yield表示式是有返回值的,預設返回None
>>> g = test_send() # 新建一個生成器
>>> next(g) # 在呼叫send()之前,必須先至少呼叫過一次next()
1
>>> g.send("msg")
At the end of function, a = msg # 把我們傳入的引數作為了yield表示式的返回值
複製程式碼
這一項重要改進甚至改變了生成器的本性:像這樣用的話,生成器就變為了協程。
這裡是想提醒大家,請慎重使用這個方法!生成器用於生產供迭代的資料,協程是資料的消費者。為了避免不必要的麻煩,請嚴格區分協程和迭代,雖然協程也用到到了yield
,但協程和迭代沒有關係!
關於協程的內容將會在後面的文章中介紹。
4. 總結
本篇首先介紹了可迭代物件與迭代器,內容包括迭代的原理以及iter()
和next()
函式所做的工作,然後實現了一個經典的迭代器。隨後,為了讓這個經典的迭代器更符合Python風格,我們討論了生成器。這期間講到了生成器和迭代器的關係,生成器函式及其工作原理,惰性實現和生成器表示式。根據這些內容,我們將之前傳統的迭代器進行了簡化。隨後補充了三個內容:itertools
模組中的生成器函式,yield from
語法和生成器的.send()
。
最後,建議大家一定要多瞭解標準庫中的生成器函式,尤其是itertools
模組。
迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~