1. 程式人生 > >Python 中的黑暗角落(一):理解 yield 關鍵字

Python 中的黑暗角落(一):理解 yield 關鍵字

Python 是非常靈活的語言,其中 yield 關鍵字是普遍容易困惑的概念。

此篇將介紹 yield 關鍵字,及其相關的概念。

迭代、可迭代、迭代器

迭代(iteration)與可迭代(iterable)

迭代是一種操作;可迭代是物件的一種特性。

很多資料都是「容器」;它們包含了很多其他型別的元素。實際使用容器時,我們常常需要逐個獲取其中的元素。逐個獲取元素的過程,就是「迭代」

1
2
3
4
# iteration
a_list = [1, 2, 3]
for i in a_list:
    print(i)

如果我們可以從一個物件中,逐個地獲取元素,那麼我們就說這個物件是「可迭代的」。

Python 中的順序型別,都是可迭代的(listtuplestring)。其餘包括 dictsetfile 也是可迭代的。對於使用者自己實現的型別,如果提供了 __iter__() 或者 __getitem__() 方法,那麼該類的物件也是可迭代的。

迭代器(iterator)

迭代器是一種物件。

迭代器抽象的是一個「資料流」,是隻允許迭代一次的物件。對迭代器不斷呼叫 next() 方法,則可以依次獲取下一個元素;當迭代器中沒有元素時,呼叫 next() 方法會丟擲 StopIteration 異常。迭代器的 __iter__() 方法返回迭代器自身;因此迭代器也是可迭代的。

迭代器協議(iterator protocol)

迭代器協議指的是容器類需要包含一個特殊方法。

如果一個容器類提供了 __iter__() 方法,並且該方法能返回一個能夠逐個訪問容器內所有元素的迭代器,則我們說該容器類實現了迭代器協議。

Python 中的迭代器協議和 Python 中的 for 迴圈是緊密相連的。

1
2
3
# iterator protocol and for loop
for x in something:
    print(x)

Python 處理 for 迴圈時,首先會呼叫內建函式 iter(something),它實際上會呼叫 something.__iter__()

,返回 something 對應的迭代器。而後,for 迴圈會呼叫內建函式 next(),作用在迭代器上,獲取迭代器的下一個元素,並賦值給 x。此後,Python 才開始執行迴圈體。

生成器、yield 表示式

生成器函式(generator function)和生成器(generator)

生成器函式是一種特殊的函式;生成器則是特殊的迭代器。

如果一個函式包含 yield 表示式,那麼它是一個生成器函式;呼叫它會返回一個特殊的迭代器,稱為生成器。

1
2
3
4
5
6
7
8
9
10
11
def func():
    return 1

def gen():
    yield 1

print(type(func))   # <class 'function'>
print(type(gen))    # <class 'function'>

print(type(func())) # <class 'int'>
print(type(gen()))  # <class 'generator'>

如上,生成器 gen 看起來和普通的函式沒有太大區別。僅只是將 return 換成了 yield。用 type() 函式列印二者的型別也能發現,func 和 gen 都是函式。然而,二者的返回值的型別就不同了。func() 是一個 int 型別的物件;而 gen() 則是一個迭代器物件。

yield 表示式

如前所述,如果一個函式定義中包含 yield 表示式,那麼該函式是一個生成器函式(而非普通函式)。實際上,yield 僅能用於定義生成器函式。

與普通函式不同,生成器函式被呼叫後,其函式體內的程式碼並不會立即執行,而是返回一個生成器(generator-iterator)。當返回的生成器呼叫成員方法時,相應的生成器函式中的程式碼才會執行。

1
2
3
4
5
6
def square():
    for x in range(4):
        yield x ** 2
square_gen = square()
for x in square_gen:
    print(x)

前面說到,for 迴圈會呼叫 iter() 函式,獲取一個生成器;而後呼叫 next() 函式,將生成器中的下一個值賦值給 x;再執行迴圈體。因此,上述 for 迴圈基本等價於:

1
2
3
4
genitor = square_gen.__iter__()
while True:
    x = geniter.next() # Python 3 是 __next__()
    print(x)

注意到,square 是一個生成器函式;作為它的返回值,square_gen 已經是一個迭代器;迭代器的 __iter__() 返回它自己。因此 geniter 對應的生成器函式,即是 square

每次執行到 x = geniter.next() 時,square 函式會從上一次暫停的位置開始,一直執行到下一個 yield 表示式,將 yield 關鍵字後的表示式列表返回給呼叫者,並再次暫停。注意,每次從暫停恢復時,生成器函式的內部變數、指令指標、內部求值棧等內容和暫停時完全一致

生成器的方法

生成器有一些方法。呼叫這些方法可以控制對應的生成器函式;不過,若是生成器函式已在執行過程中,呼叫這些方法則會丟擲 ValueError 異常。

  • generator.next():從上一次在 yield 表示式暫停的狀態恢復,繼續執行到下一次遇見 yield 表示式。當該方法被呼叫時,當前 yield 表示式的值為 None,下一個 yield 表示式中的表示式列表會被返回給該方法的呼叫者。若沒有遇到 yield 表示式,生成器函式就已經退出,那麼該方法會丟擲 StopIterator 異常。
  • generator.send(value):和 generator.next() 類似,差別僅在與它會將當前 yield 表示式的值設定為 value
  • generator.throw(type[, value[, traceback]]):向生成器函式丟擲一個型別為 type 值為 value 呼叫棧為 traceback 的異常,而後讓生成器函式繼續執行到下一個 yield 表示式。其餘行為與 generator.next() 類似。
  • generator.close():告訴生成器函式,當前生成器作廢不再使用。

舉例和說明

如果你看不懂生成器函式

如果你還是不太能理解生成器函式,那麼大致上你可以這樣去理解。

  • 在函式開始處,加入 result = list()
  • 將每個 yield 表示式 yield expr 替換為 result.append(expr)
  • 在函式末尾處,加入 return result

關於「下一個」yield 表示式

介紹「生成器的方法」時,我們說當呼叫 generator.next() 時,生成器函式會從當前位置開始執行到下一個 yield 表示式。這裡的「下一個」指的是執行邏輯的下一個。因此

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def f123():
    yield 1
    yield 2
    yield 3

for item in f123(): # 1, 2, and 3, will be printed
    print(item)

def f13():
    yield 1
    while False:
        yield 2
    yield 3

for item in f13(): # 1 and 3, will be printed
    print(item)

使用 send() 方法與生成器函式通訊

1
2
3
4
5
6
7
8
9
10
def func():
    x = 1
    while True:
        y = (yield x)
        x += y

geniter = func()
geniter.next()  # 1
geniter.send(3) # 4
geniter.send(10)# 14

此處,生成器函式 func 用 yield 表示式,將處理好的 x 傳送給生成器的呼叫者;與此同時,生成器的呼叫者通過 send 函式,將外部資訊作為生成器函式內部的 yield 表示式的值,儲存在 y 當中,並參與後續的處理。

這一特性是使用 yield 在 Python 中使用協程的基礎。

yield 的好處

Python 的老使用者應該會熟悉 Python 2 中的一個特性:內建函式 range 和 xrange。其中,range 函式返回的是一個列表,而 xrange 返回的是一個迭代器。

在 Python 3 中,range 相當於 Python 2 中的 xrange;而 Python 2 中的 range 可以用 list(range()) 來實現。

Python 之所以要提供這樣的解決方案,是因為在很多時候,我們只是需要逐個順序訪問容器內的元素。大多數時候,我們不需要「一口氣獲取容器內所有的元素」。比方說,順序訪問容器內的前 5 個元素,可以有兩種做法:

  • 獲取容器內的所有元素,然後取出前 5 個;
  • 從頭開始,逐個迭代容器內的元素,迭代 5 個元素之後停止。

顯而易見,如果容器內的元素數量非常多(比如有 10 ** 8 個),或者容器內的元素體積非常大,那麼後一種方案能節省巨大的時間、空間開銷。

現在假設,我們有一個函式,其產出(返回值)是一個列表。而若我們知道,呼叫者對該函式的返回值,只有逐個迭代這一種方式。那麼,如果函式生產列表中的每一個元素都需要耗費非常多的時間,或者生成所有元素需要等待很長時間,則使用 yield把函式變成一個生成器函式,每次只產生一個元素,就能節省很多開銷了。