2020年3月25日 星期三

python 初學者速理解 yield與generator

這我很久以前碰過,但是也不清不楚,return 與 yield 都能回傳值,那為什麼有甚麼區分?

yeild用於function中回傳值,回傳的類型是產生器generator,這個產生器會產生回傳值,但本身並不是想要的回傳值。

所以很直覺地使用len()或是[:]切片在產生器上,會回報錯誤。

yeild產生器像是在函式中的斷點,可以得到當下變數的數值。意味著你可以在函式外影響函式內的值,也會直接牽連到yield。

要從產生器內取出回傳值,用 next() 或用 for迴圈 都可以,for迴圈會跑到產生器停止,無法再用next()取得值就跳出。

而generator進度是會保存下來的,因此一旦全部跑完一次,想要重頭,那就得重新產生generator一次。ex1a(Github)

yield能用在哪?從generator取值感覺並不直覺好用。

當資料量小的時候,使用時間並不會很明顯,一旦資料量變大,就能看出yield在某些時刻能起到龐大的優勢。

這裡假設一個情境:批次batch。 ex1(Github)

有一個長度10000000的串列,要切成每份大小10的串列,然後取出。(這算是一維變成二維,用numpy也可以輕易做到,萬一無法整除呢?

不過呢,當你生成一個新的(1000000,10)大小的二維串列,也代表於記憶體內有兩份一樣並不同形式的資料。

批次是為了解決一次性傳輸碰上量大問題,切小再送避免卡頓。在量大的前提,要是再生出一份對應整理好的資料,也勢必會影響執行時間。

用yield去做切割,原資料不變,也不會產生整理過的新資料,相對就會快上許多。
"""https://stackoverflow.com/questions/8290397/how-to-split-an-iterable-in-constant-size-chunks"""
def batch_yield(iterable, n=1):
    l = len(iterable)
    for ndx in range(0, l, n):
        yield iterable[ndx:min(ndx + n, l)]
如果 range(10) 要切成每份大小為 3, batch_yield(range(10), n=3)。

用 sys.getsizeof() 查看大小,使用generator是比起新產生一個整理過的資料還省下不少空間,執行速度也相對更快速。

不過「批次」這樣情形不一定得要用到yield,用[:]迴圈也可以不佔用太多記憶體空間,只是yield可以展開過程,會比較容易理解。

因為yield可以先取部分,再判斷情況去影響函式內部,讓generator提前結束或是再延長,也就是說長度可以變動,所以len()跟[:]就派不上用場。

講到一個經典情況:抽球。 程式碼:Github

今天有一個球池,裡面一開始只有紅球、黃球。每次抽球前會先攪拌打散順序,要是抽出黃球就放入黃球跟紅球;抽到紅球就放入藍球;抽到藍球就放入綠球;抽到綠球就放入白球;最後抽到白球就結束了。

進行一次 next() 抽球動作generator,會抽出 yield (產生)一個球。請問這個抽球動作長度len()多少?第5次抽球[4]是甚麼顏色?沒有產生結果之前,誰也說不準。

(球池補充,使用類別:MyBallPool(GitHub) MyBallPool2(GitHub)

如果有更多的狀況、更多的條件判斷與回饋,用上function yeild就能變成一行呼叫函式的代碼,這能使得版面看起來整潔許多。

接下來講的常見的迴圈加刪除的例子,個人認為對新手是相當難理解的。
a = list(range(10)) # a = [0,1,...,9]

for item in a:
    print(item, end = " ")
    item = 9999
    del a[0]

print("\n" + " ".join([str(item) for item in a]))   

->0 2 4 6 8
5 6 7 8 9
新手可能覺得這個for迴圈會執行10次,但是結果只執行了5次。

這是不是跟 yeild 外部影響內部非常相似?想一想,「for item in a」的 a,到底是那串資料還是另有甚麼?

其實是從 a 中取出 a.__iter__() 的回傳值:迭代器iterator。這個迭代器用 next() 依序傳值,運作跟for index in range(len(a)) a[index]是類似的。ex2(Github)

由於刪除串列中其中一個項目,但在迭代器的標籤位置不變,因此下次next()往後一格,也只是標籤位置+1,導致看起來像被跳過一格。 ex2a(Github)

若想用 for item in sequence 這種簡便的寫法循環全部項目,那麼加上[:],改用 for item in sequence[:] 也能辦到,即便中間刪了sequence的某個值,也能老老實實有十個就跑十次。

sequence 跟 sequence[:] 是不一樣的,用id()查詢也能知道,就像 sequence[:5] 明顯就不是 sequence。

因此在迴圈中刪掉 sequence 裡的項目,不影響 sequence[:].__itre__() 的迭代器所使用的資料序列,就能乖乖地跑完十次。 (補充:ex2b(Github) 在function yield中刪資料)

總結:

yield本身並不是想要的回傳值,而是一個產生器generator,能產生函式中的yield斷點的函式內變數。想要從產生器取值,需要使用next()。

因為yield本身並不是回傳值,所以能夠節省空間,像是批次處理。

yield產生的值是回傳當下的函式內變數,因此外部影響函式內部的話,yield的值也會受影響。(反之也能在function yield影響外部

也因為外部能夠影響內部,len長度跟slice切片位置可能會在過程中改變,所以這兩項並不重要。(generator內部也沒有這屬性)

for in 迴圈的運作模式,跟使用yield得到的產生器運作是相似的,因此在for in中增減序列的項目,也會影響For迴圈的執行。

而 for each in sequence,實際上是將 sequence.__iter__()回傳的 iterator 不斷 next() 直到無法取值為止,each 為 next(iterator) 的回傳值。

整篇文章的範例程式碼都收錄在 yield(Github)

個人語:

yield 可以把「for in if」濃縮起來,寫 for in if 除了那一行很占版面,也不容易一眼看出,而且[for in if]會產生新的串列,但  yield 不會。若後續要從中一一取出做運用,當然使用 yield 會是更便捷快速於[for in if]。

如果有一筆A資料,提升效率的關鍵就是不要再產生一份相似的資料。要是沒有很明白到底變數都導向去哪裡,除了可能產生冗贅資料,也可能對資料產生改動而牽動整體,導致實際運行出乎自己的預期。

我也不是很會說物件導向觀念到底哪裡容易讓人誤會,不過一開始學物件導向語言,說 a = b,把 a 指向 b,但是原先 b = 1,接下來 b = 2,但是 a 的值仍是 1 並不是 2 表面上看來也沒有 a = b 去。

雖然寫完這篇,我覺得對一個沒有基礎的初學者,yield還是有一點困難。

因為對於物件導向不是很熟悉的話,就會在各種變數的指向間混淆。yield厲害之處在於不用一次全部跑完,要妥善使用這個特性,在各個指定之間都要清楚掌握資料存在於哪,改動資料會影響所有對該資料的指向,改動指向並不會影響資料。

如果以實體跟指向來看,b並不是實體,b 指向的實體原先是 1, a 要是單純地指向 b 是沒有任何實體的,所以 a 的指向會跟 b 一樣是實體1。如果 b 是實體,像是類別物件等, 那麼 a = b之後,修改 b 也會反映到 a 上。

這裡用「實體」是想給人這是一種真實存在的東西說法,正確該說是「物件」。

int(3)都會指向同一個 id,不論 a=3、b=4-1、c=9/3,abc三者的id會是跟int(3)一樣的。(abc三者都是int的話

虛實問題,也反應到各種傳遞資料問題,到底傳的是實體?還只是一個指向?少用 = ,多用內部方法增修清除。串列是一個可修改自己的實體,裡面是一連串的指向。字串是不可修改自己的實體(設計上),增修則是變成新的實體回傳。

如果把類別的觀念擴大至整個架構,就會懂得 a = int(1) + int(3) ,其中 + 是呼叫該類別 int 的 __add__ ,得到回傳值 int(4)。a 也就指向新的實體 int(4)。

為什麼 a = int(1)、b = a、a = int(1) + int(3)、b = int(1)。一開始,a 指向 int(1)、b 也透過 a 指向 int(1),對 int 加減乘除會得到(回傳)一個 int 的答案,a 指向新的值,並沒有對 int(1) 做修改,故 b 仍然為 int(1)。

也許有天寫 leetcode 的時候,不明白為什麼運行時間這麼慢的時候,就能想想這個物件導向的虛實問題,看看自己的程式碼是不是產生過多不必要的實體。