Python學習之路34-迭代器和生成器

VPointer發表於2018-08-04

《流暢的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繼承關係圖如下:

Python學習之路34-迭代器和生成器

從上圖以及之前的描述,我們可以總結出以下幾點:

  • 具體的可迭代物件__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.countitertools.takewhile

前面介紹的生成器中的資料都是有窮集合,而itertools.count則生成無窮集合。它有兩個引數起始數值start和步長stepstart預設是0step預設是1。這兩個引數都支援多種數字型別,比如intfloatdecimal.Decimalfractions.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 ~

Python學習之路34-迭代器和生成器

相關文章