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 中的順序型別,都是可迭代的(list
, tuple
, string
)。其餘包括 dict
, set
, file
也是可迭代的。對於使用者自己實現的型別,如果提供了 __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
把函式變成一個生成器函式,每次只產生一個元素,就能節省很多開銷了。