Python迭代和解析(5):搞懂生成器和yield機制

駿馬金龍發表於2019-01-14

解析、迭代和生成系列文章:https://www.cnblogs.com/f-ck-need-u/p/9832640.html


何為生成器

生成器的wiki頁:https://en.wikipedia.org/wiki/Generator_(computer_programming)

在電腦科學中,生成器是特定的迭代器,它完全實現了迭代器介面,所以所有生成器都是迭代器。不過,迭代器用於從資料集中取出元素;而生成器用於”憑空”生成(yield)元素。它不會一次性將所有元素全部生成,而是按需一個一個地生成,所以從頭到尾都只需佔用一個元素的記憶體空間。

很典型的一個例子是斐波納契數列:斐波納契數列中的數有無窮個,在一個資料結構裡放不下,但是可以在需要下一個元素的時候臨時計算。

再比如內建函式range()也返回一個類似生成器的物件,每次需要range裡的一個資料時才會臨時去產生它。如果一定要讓range()函式返回列表,必須明確指明list(range(100))

在Python中生成器是一個函式,但它的行為像是一個迭代器。另外,Python也支援生成器表示式。

初探生成器

下面是一個非常簡單的生成器示例:

>>> def my_generator(chars):
...     for i in chars:
...         yield i * 2

>>> for i in my_generator("abcdef"):
...     print(i, end=" ")

aa bb cc dd ee ff

這裡的my_generator是生成器函式(使用了yield關鍵字的函式,將被宣告為generator物件),但是它在for迴圈中充當的是一個可迭代物件。實際上它本身就是一個可迭代物件:

>>> E = my_generator("abcde")
>>> hasattr(E, "__iter__")
True
>>> hasattr(E, "__next__")
True

>>> E is iter(E)
True

由於生成器自動實現了__iter____next__,且__iter__返回的是迭代器自身,所以生成器是一個單迭代器,不支援多迭代

此外,生成器函式中使用for來迭代chars變數,但對於chars中被迭代的元素沒有其它操作,而是使用yield來返回這個元素,就像return語句一樣。

只不過yield和return是有區別的,yield在生成一個元素後,會記住迭代的位置並將當前的狀態掛起(還記住了其它一些必要的東西),等到下一次需要元素的時候再從這裡繼續yield一個元素,直到所有的元素都被yield完(也可能永遠yield不完)。return則是直接退出函式,

yield from

當yield的來源為一個for迴圈,那麼可以改寫成yield from。也就是說,for i in g:yield i等價於yield from g

例如下面是等價的。

def mygen(chars):
  yield from chars

def mygen(chars):
  for i in chars:
    yiled i

yield from更多地用於子生成器的委託,本文暫不對此展開描述。

生成器和直接構造結果集的區別

下面是直接構造出列表的方式,它和前面示例的生成器結果一樣,但是內部工作方式是不一樣的。

def mydef(chars):
    res = []
    for i in chars:
        res.append(i * 2)
    return res

for i in mydef("abcde"):
    print(i,end=" ")

這樣的結果也能使用列表解析或者map來實現,例如:

for x in [s * 2 for s in "abcde"]: print(x, end=" ")

for x in map( (lambda s: s * 2), "abcde" ): print(x, end=" ")

雖然結果上都相同,但是記憶體使用上和效率上都有區別。直接構造結果集將會等待所有結果都計算完成後一次性返回,可能會佔用大量記憶體並出現結果集等待的現象。而使用生成器的方式,從頭到尾都只佔用一個元素的記憶體空間,且無需等待所有元素都計算完成後再返回,所以將時間資源分佈到了每個結果的返回上。

例如總共可能會產生10億個元素,但只想取前10個元素,如果直接構造結果集將佔用巨量記憶體且等待很長時間,但使用生成器的方式,這10個元素根本不需等待,很快就計算出來。

必須理解的生成器函式:yield如何工作

理解這個工作過程非常重要,是理解和掌握yield的關鍵。

1.呼叫生成器函式的時候並沒有執行函式體中的程式碼,它僅僅只是返回一個生成器物件

正如下面的示例,並非輸出任何內容,說明沒有執行生成器函式體。

def my_generator(chars):
    print("before")
    for i in chars:
        yield i
    print("after")

>>> c = my_generator("abcd")
>>> c
<generator object my_generator at 0x000001DC167392A0>
>>> I = iter(c)

2.只有開始迭代的時候,才真正開始執行函式體。且在yield之前的程式碼體只執行一次,在yield之後的程式碼體只在當前yield結束的時候才執行

>>> next(I)
before        # 第一次迭代
`a`
>>> next(I)
`b`
>>> next(I)
`c`
>>> next(I)
`d`          
>>> next(I)
after        # 最後一次迭代,丟擲異常停止迭代
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

一個生成器函式可以有多個yield語句,看看下面的執行過程:

def mygen():
    print("1st")
    yield 1
    print("2nd")
    yield 2
    print("3rd")
    yield 3
    print("end")

>>> m = mygen()
>>> next(m)
1st
1
>>> next(m)
2nd
2
>>> next(m)
3rd
3
>>> next(m)
end
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

到此,想必已經理解了yield的工作過程。但還有一些細節有必要解釋清楚。

yield是一個表示式,但它是有返回值的。需要注意的是,yield操作會在產生併傳送了值之後立即讓函式處於掛起狀態,掛起的時候連返回值都還沒來得及返回。所以,yield表示式的返回值是在下一次迭代時才生成返回值的。關於yield的返回值相關,見下面的生成器的send()方法。

yield的返回值和send()

上面說了,yield有返回值,且其返回值是在下一次迭代的時候才返回的。它的返回值根據恢復yield的方式不同而不同

yield有以下幾種常見的表示式組合方式:

yield 10            # (1) 丟棄yield的返回值
x = yield 10        # (2) 將yield返回值賦值給x
x = (yield 10)      # (3) 等價於 (2)
x = (yield 10) + 11 # (4) 將yield返回值加上11後賦值給x

不管yield表示式的編碼方式如何,它的返回值都和呼叫next()(或__next__())還是生成器物件的send()方法有關。這裡的send()方法和next()都用於恢復當前掛起的yield。

如果是呼叫next()來恢復yield,那麼yield的返回值為None,如果呼叫gen.send(XXX)來恢復yield,那麼yield的返回值為XXX。其實next()可以看作是等價於gen.send(None)

再次提醒,yield表示式會在產生一個值後立即掛起,它連返回值都是在下一次才返回的,更不用說yield的賦值和yield的加法操作。

所以,上面的4種yield表示式方式中,如果使用next()來恢復yield,則它們的值分別為:

yield 10       # 先產生10傳送出去,然後返回None,但丟棄
x = yield 10   # 返回None,賦值給x
x = (yield 10) # 與上等價
x = (yield 10)+11 # 返回None,整個過程報錯,因為None和int不能相加

如果使用的是send(100),上面的4種yield表示式方式中的值分別為:

yield 10       # 先產生10傳送出去,然後返回100,但丟棄
x = yield 10   # 返回100,賦值給x,x=100
x = (yield 10) # 與上等價
x = (yield 10)+11 # 返回100,加上11後賦值給x,x=111

為了解釋清楚yield工作時的返回值問題,我將用兩個示例詳細地解釋每一次next()/send()的過程。

解釋yield的第一個示例

這個示例比較簡單。

def mygen():
  x = yield 111         # (1)
  print("x:", x)        # (2)
  for i in range(5):    # (3)
    y = yield i         # (4)
    print("y:", y)      # (5)

M = mygen()

1.首先執行下面的程式碼

>>> print("first:",next(M))
111

這一行執行後,首先將yield出來的111傳遞給呼叫者,然後立即在(1)處進行掛起,這時yield表示式還沒有進入返回值狀態,所以x還未進行賦值操作。但是next(M)已經返回了,所以print正常輸出。

無論是next()(或__next__)還是send()都可以用來恢復掛起的yield,但第一次進入yield必須使用next()或者使用send(None)來產生一個掛起的yield。假如第一次就使用send(100),由於此時還沒有掛起的yield,所以沒有yield需要返回值,這會報錯。

2.再執行下面的程式碼

>>> print("second:",M.send(10))
x: 10
second: 0

這裡的M.send(10)首先恢復(1)處掛起的yield,並將10作為該yield的返回值,所以x = 10,然後生成器函式的程式碼體繼續向下執行,到了print("x:",x)正常輸出。

再繼續進入到for迴圈迭代中,又再次遇到了yield,於是yield產生range(5)的第一個數值0傳遞給呼叫者然後立即掛起,於是M.send()等待到了這個yield值,於是輸出”second: 0″。但注意,這時候y還沒有進行賦值,因為yield還沒有進入返回值的過程。

3.再執行下面的程式碼

>>> print("third:",M.send(11))
y: 11
third: 1

這裡的M.send(11)首先恢復上次掛起的yield並將11作為該掛起yield的返回值,所以y=11,因為yield已經恢復,所以程式碼體繼續詳細執行print("y:",y),執行之後進入下一輪for迭代,於是再次遇到yield,它生成第二個range的值1並傳遞給呼叫者,然後掛起,於是M.send()接收到數值1並返回,於是輸出third: 1。注意,此時的y仍然是11,因為for的第二輪yield還沒有返回。

4.繼續執行,但使用next()

>>> print("fourth:",next(M))
y: None
fourth: 2

這裡的next(M)恢復前面掛起的yield,並且將None作為yield的返回值,所以y賦值為None。然後進入下一輪for迴圈、遇到yield,next()接收yield出來的值2並返回。

next()可以看作等價於M.send(None)

5.依此類推,直到迭代結束丟擲異常

>>> print("fifth:",M.send(13))
y: 13
fifth: 3
>>> print("sixth:",M.send(14))
y: 14
sixth: 4
>>> print("seventh:",M.send(15))     # 看此行
y: 15
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

當傳送M.send(15)時,前面掛起的yield恢復並以15作為返回值,所以y=15。於是繼續執行,但此時for迭代已經完成了,於是丟擲異常,整個生成器函式終止。

解釋yield的第二個示例

這個示例稍微複雜些,但理解了前面的yield示例,這個示例也很容易理解。注意,下面的程式碼不要在互動式python環境中執行,而是以py指令碼的方式執行。

def gen():
    for i in range(5):
        X = int((yield i) or 0) + 10 + i
        print("X:",X)

G = gen()
for a in G:
    print(a)
    G.send(77)

執行結果為:

0
X: 87
X: 11
2
X: 89
X: 13
4
X: 91
Traceback (most recent call last):
  File "g:pycodelists.py", line 10, in <module>
    G.send(77)
StopIteration

這裡for a in G用的是next(),在這個for迴圈裡又用了G.send(),因為send()接收的值在空上下文,所以被丟棄,但它卻將生成器向前移動了一步。

更多的細節請自行思考,如不理解可參考上一個示例的分析。

生成器表示式和列表解析

列表解析/字典解析/集合解析是使用中括號、大括號包圍for表示式的,而生成器表示式則是使用小括號包圍for表示式,它們的for表示式寫法完全一樣。

# 列表解析
>>> [ x * 2 for x in range(5) ]
[0, 2, 4, 6, 8]

# 生成器表示式
>>> ( x * 2 for x in range(5) )
<generator object <genexpr> at 0x0000013F550A92A0>

在結果上,列表解析等價於list()函式內放生成器表示式:

>>> [ x * 2 for x in range(5) ]
[0, 2, 4, 6, 8]

>>> list( x * 2 for x in range(5) )
[0, 2, 4, 6, 8]

但是工作方式完全不一樣。列表解析等待所有元素都計算完成後一次性返回,而生成器表示式則是返回一個生成器物件,然後一個一個地生成並構建成列表。生成器表示式可以看作是列表解析的記憶體優化操作,但執行速度上可能要稍慢於列表解析。所以生成器表示式和列表解析之間,在結果集非常大的時候可以考慮採用生成器表示式。

一般來說,如果生成器表示式作為函式的引數,只要該函式沒有其它引數都可以省略生成器表示式的括號,如果有其它引數,則需要括號包圍避免歧義。例如:

sum( x ** 2 for x in range(4))

sorted( x ** 2 for x in range(4))

sorted((x ** 2 for x in range(4)),reverse=True)

生成器表示式和生成器函式

生成器表示式一般用來寫較為簡單的生成器物件,生成器函式程式碼可能稍多一點,但可以實現邏輯更為複雜的生成器物件。它們的關係就像列表解析和普通的for迴圈一樣。

例如,將字母重複4次的生成器物件,可以寫成下面兩種格式:

# 生成器表示式
t1 = ( x * 4 for x in "hello" )

# 生成器函式
def time4(chars):
  for x in chars:
    yield x * 4

t2 = time4("abcd")

使用生成器模擬map函式

map()函式的用法:

map(func, *iterables) --> map object

要想模擬map函式,先看看map()對應的for模擬方式:

def mymap(func,*seqs):
  res = []
  for args in zip(*args):
    res.append( func(*args) )
  return res

print( mymap(pow, [1,2,3], [2,3,4,5]) )

對此,可以編寫出更精簡的列表解析方式的map()模擬程式碼:

def mymap(func, *seqs):
  return [ func(*args) for args in zip(*seqs) ]

print( mymap(pow, [1,2,3], [2,3,4,5]) )

如果要用生成器來模擬這個map函式,可以參考如下程式碼:

# 生成器函式方式
def mymap(func, *seqs):
  res = []
  for args in zip(*args):
    yield func(*args)

# 或者生成器表示式方式
def mymap(func, *seqs):
  return ( func(*args) for args in zip(*seqs) )

相關文章