yield 產生器

April 22, 2022

你可以在函式中使用 yield 來產生值,表面上看來,yield 有點像是 return,不過函式並不會因為 yield 而結束,只是將流程控制權讓給函式的呼叫者。

yield 入門

以下來個模仿 range 的實作,自訂一個 xrange 函式:

def xrange(n):
    x = 0
    while x != n:
        yield x
        x += 1

for n in xrange(10):
    print(n) 

就流程來看,xrange 函式首次執行時,使用 yield 產生 x,然後回到主流程使用 print 顯示該值,接著流程重回 xrange 函式 yield 之後繼續執行,迴圈中再度使用 yield 產生 x,然後又回到主流程使用 print 顯示該值,這樣的反覆流程,會直到 xrangewhile 迴圈結束為止。

顯然地,這樣的流程有別於函式中使用了 return,函式就結束了的情況。實際上,當函式中使用 yield 產生值時,呼叫該函式會傳回 generator 物件,也就是產生器,此物件具有 __next__ 方法,通常會使用 next 函式呼叫該方法取出下個產生值(也就是 yield 的值),若無法產生下一個值(也就是含有 yield 的函式結束了),會發生 StopIteration 例外(Exception)。

>>> g = xrange(2)
>>> type(g)
<class 'generator'>
>>> next(g)
0
>>> next(g)
1
>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>

因此,for in 實際上是對 xrange 傳回的產生器進行迭代,如果一個物件具有 __next__ 方法,for in 會呼叫 __next__ 方法取得值,並在遇到 StopIteration 時結束迭代。

因為每次呼叫產生器的 __next__ 時,產生器才會運算並傳回下個產生值,因此就解釋了先前為何提到產生器,都稱其具有惰性求值的效果。

在〈for Comprehension〉談過,可以使用 () 包括 for Compherension,這會建立一個 generator 物件,這個物件也可以使用 for in 來迭代。

儘管少見,然而具有 yield 的函式,還是可以使用 return,由於 return 就是直接結束函式,因而執行 return 時會引發 StopIteration,被 return 的值可以透過 StopIterationargs 來取得。

send 資料

yield 是個運算式,除了可以呼叫產生器的 __next__ 方法,取得 yield 右方的值之外,還可以透過 send 方法指定值,令其成為 yield 運算結果,也就是產生器可以給呼叫者值,呼叫者也可以指定值給產生器,這成了一種溝通機制。

例如,設計一個簡單的生產者與消費者程式:

import sys
import random

def producer():
    while True:
        data = random.randint(0, 9)
        print('生產了:', data)
        yield data

def consumer():
    while True:
        data = yield
        print('消費了:', data)

def clerk(jobs, producer, consumer):
    print('執行 {} 次生產與消費'.format(jobs))
    p = producer()
    c = consumer()
    next(c)  
    for i in range(jobs):
        data = next(p)
        c.send(data)

clerk(int(sys.argv[1]), producer, consumer) 

由於 send 方法的引數會是 yield 的運算結果,因此 clerk 流程中必須先使用 next,使得流程首次執行至 consumer 函式中 data = yield 處先執行 yield,執行 yield 會令流程回到 clerk 函式,之後執行至 next,這時流程進行至 producer 函式的 yield data,在 clerk 取得 data 之後,接著執行 c.send(data),這時流程回到 consumer 先前 data=yield 處,send 方法的引數此時成為 yield 的結果。一個執行結果如下:

>python producer_consumer.py 3
執行 3 次生產與消費
生產了: 4
消費了: 4
生產了: 5
消費了: 5
生產了: 9
消費了: 9

不過使用 send,代表又要轉移流程控制權,這種流程交織轉移並不容易控制,因此並不鼓勵使用 send;類似地,如果想對產生器引發例外,可以使用產生器的 throw 方法,這個方法接受的三個引數為例外類型、實例以及 traceback 物件,然而並不鼓勵使用。

yield from

如果打算建立一個產生器函式,資料來源是直接從另一個產生器取得,那會怎麼樣呢?舉例來說,range 函式就是傳回產生器,而你打算建立一個 np_range 函式,可以產生指定數字的正負範圍,但不包含 0:

def np_range(n):
    for i in range(0 - n, 0):
        yield i

    for i in range(1, n + 1):
        yield i

# 顯示[-5, -4, -3, -2, -1, 1, 2, 3, 4, 5]
print(list(np_range(5)))

因為 np_range 必須是個產生器,結果就是得逐一從來源產生器取得資料,再將之 yield,像是這邊重複使用了 for in 來迭代。

Python 3.3 新增了 yield from 語法,上面的程式片段可以直接改寫為以下實作:

def np_range(n):
    yield from range(0 - n, 0)
    yield from range(1, n + 1)

# 顯示[-5, -4, -3, -2, -1, 1, 2, 3, 4, 5]
print(list(np_range(5)))

當需要直接從某個產生器取得資料,以便建立另一個產生器時,yield from 可以作為銜接的語法。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter