2021年11月6日 星期六

Python 學習筆記 : 產生器 (generator)

產生器 (generator) 是由產生器函式 (generator function) 所建立的物件, 主要用來產生迭代器物件, 因此較精確地說, 產生器應該稱為迭代器的產生器 (iterator's generator), 它通常是做為迭代器的資料來源, 被動產出序列資料, 但卻不需要像串列或元組那樣在記憶體中馬上就建立整個序列, 而是透過迭代一次只產生全部序列中的單一個元素, 因此產生器所建立的序列可以說是一種 "虛擬序列" (virtual sequence), 它只在被呼叫時才產出整體序列中的一個元素, 因此即使是建立龐大的序列也只會耗用一點點記憶體而已 (以時間換取空間). 這種應要求 (on demand) 一次只建立一筆資料的方式稱為 lazy evaluation (惰式求值法), 與此相對的, 就是像串列, 字典, 與集合生成式的 greedy evaluation (貪婪求值法), 它們會立即佔據一大塊記憶體 (all at once). 

本篇測試參考了如下書籍 : 

精通 Python (碁峰, 2020) 第 9 章
Python 與量化投資 (博碩, 2017, 蔡立耑) 第 9-2 節
# Python 也可以這樣學 (博碩, 2017, 董付國) 2.2.2 節
Python 資料運算與分析實戰 (旗標, 2017, 中久喜健司) 第 4 章


1. 產生器函式 (generator function) :    

產生器物件是由產生器函式所建立, 這是 Python 的一種特殊函式, 它與一般函式不同之處是使用 yield 關鍵字而非 return 來將值回傳給呼叫者, 產生器函式中可以有一個以上的 yield 敘述, 當執行到一個 yield 敘述時會先將函式內的區域變數記錄下來並將特定值傳回給呼叫者, 然後暫停函式的執行, 直到此函式下一次被呼叫時再從暫停處繼續執行. 一般函式不會記錄狀態, 每次被呼叫時都是從頭開始執行, 但產生器函式的狀態 (即區域變數) 會被記錄下來, 直到函式結束為止. 

其實 yield 並不是像 return 那樣直接將值傳回給產生器函式的呼叫者, 而是回傳給 __next__() 或 next() 函式的呼叫者, yield 只是描述這種特殊的回傳行為而已 (很容易被誤認為是一般函式的 return), 真正回傳資料的不是 yield, 而是 next() 函式的呼叫者或 for 迴圈. 呼叫產生器函式是建立一個產生器物件, 它傳回的是一個 generator 物件, 而非一個序列. 

產生器函式的結構與一般函式一樣, 只要將傳回敘述由 return 改為 yield 即可 :

def generator_func(para1, para2, ...):
    .....
    yield a
    .....
    yield b
    ....
    yield c
    .....
    [return]

每遇到一個 yield 函式都會將值回傳並暫停執行, 直到下一次被呼叫再繼續, 產生器一次只傳回一個值, 只要用 next() 或 for 迴圈不斷迭代產生器物件即可產生一個序列, 直到全部 yield 敘述被執行完畢為止, 這就是產生器為何可以不消耗大量記憶體卻能處理龐大序列資料的原因. 例如 range() 函式所建立的 range 物件即為一種產生器物件, 在 Python 2.x 時它是傳回串列, 但那樣並不適合處理大量資料, 因此到 Python 3.x 時改成傳回產生器. 參考 :  


例如下面這個 count_down() 函式就是一個產生器函式, 可以用來建立一個產生器物件 : 

>>> def count_down(i):              # 定義產生器函式
    while i > 0:                               # 當 i 為 0 時終止迴圈
        yield i                                    # 紀錄區域變數 i 之值後將其傳回, 並暫停執行 
        i -= 1                                     # 再次呼叫時由此恢復執行
        
>>> cnt_gen=count_down(5)     # 建立產生器物件   
>>> type(cnt_gen)                       # 型態為 generator
<class 'generator'>   
>>> cnt_gen                                 # 此產生器物件是由 count_down() 建立
<generator object count_down at 0x0000024D07CC8DE0>     
>>> hasattr(cnt_gen, '__iter__')       # 產生器物件是可迭代物件
True
>>> hasattr(cnt_gen, '__next__')      # 產生器物件是迭代器物件
True   
>>> next(cnt_gen)         # 用 next() 迭代取出下一個序列元素                     
5
>>> next(cnt_gen)         # 用 next() 迭代取出下一個序列元素
4
>>> next(cnt_gen)         # 用 next() 迭代取出下一個序列元素
3
>>> next(cnt_gen)         # 用 next() 迭代取出下一個序列元素
2
>>> next(cnt_gen)         # 用 next() 迭代取出下一個序列元素
1
>>> next(cnt_gen)         # 用 next() 迭代取出下一個序列元素
Traceback (most recent call last):  
  File "<pyshell>", line 1, in <module>   
StopIteration    
>>> cnt_gen=count_down(5)     
>>> for i in cnt_gen:        
    print(i)   
    
5
4
3
2
1
>>> for i in cnt_gen:   
    print(i)   
    
>>> cnt_gen=count_down(5)        # 重新建立產生器物件
>>> list(cnt_gen)                            # 轉成串列
[5, 4, 3, 2, 1]  
>>> cnt_gen=count_down(5)       # 重新建立產生器物件
>>> tuple(cnt_gen)                        # 轉成元組 
(5, 4, 3, 2, 1)

可見在呼叫產生器函式建立產生器物件時會自動加上 __iter__() 與 __next__() 方法, 因此產生器物件是可迭代與迭代器物件, 可以用 next() 取出下一個序列元素 (但序列耗盡時會拋出 StopIteration 例外); 也可以用 for 迴圈走訪全部序列; 或者將產生器物件傳入 tuple() 與 list() 將全部序列轉成元組與串列. 與迭代器一樣, 產生器只能走訪或轉換一次, 必須重新建立產生器物件才能再次走訪或轉換. 

此例中的產生器函式 count_down(i) 裡面有 i 個 yield 敘述, 因此它會被動地產出 i 個序列. 其實 range() 函式也是一個產生器, 它可以在迭代中產出任意長度的序列資料, 但卻不會耗掉大量記憶體. 但是 range() 函式傳回的是 range 物件不是 generator 物件, 它只是可迭代物件而不是迭代器物件, 因此不能傳給 next(), 必須放在 for 迴圈去迭代, 因為 for 迴圈會自動加上 __next__() 方法讓它變成迭代器; 也可以將其傳入 list() 或 tuple() 轉成串列或元組. 

range 物件與 generator 物件的差別是, range 物件不會因為被走訪或轉換過就耗盡序列元素, 因此可以重複地被走訪會轉換, 不需要重新建立 range 物件, 例如 :

>>> range1=range(5)                      # 建立 range 物件 ( 0~4 序列的產生器)
>>> type(range1)                             # range() 的傳回值是 range 物件 
<class 'range'>     
>>> range1                                       # range 物件
range(0, 5)    
>>> hasattr(range1, '__iter__')      # range 物件是可迭代物件  
True    
>>> hasattr(range1, '__next__')     # range 物件不是迭代器物件
False      
>>> next(range1)                             # next() 只能走訪迭代器物件
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
TypeError: 'range' object is not an iterator     
>>> for i in range1:                  # 用 for 迴圈走訪 range 物件 (自動轉成迭代器)
    print(i)    
    
0
1
2
3
4
>>> for i in range1:                  # range 物件可以重複走訪, 不需重新建立物件
    print(i)    
    
0
1
2
3
4
>>> list(range1)               # 轉換成串列物件
[0, 1, 2, 3, 4]
>>> list(range1)               # range 物件可以重複轉換, 不需重新建立物件
[0, 1, 2, 3, 4]

可見 range() 只是使用了 yield 來產出序列, 它傳回的 range 物件與一般的 generator 物件不同的是它不是迭代器物件, 無法傳給 next() 迭代; 但好處是可以重複被走訪與轉換而不需要重新建立 range 物件.  

我們也可以用 generator 自訂一個簡單版的 range() 函式, 例如 : 

>>> def myrange(begin=0, end=100, step=1):      # 自訂的 range() 函式
    n=begin                     # 起始值
    while n < end:           # 判斷是否已到終點
        yield n                    # 保存 n 並將其傳回, 暫停執行
        n += step                # 增值, 下次呼叫時從這裡開始執行
        
>>> range1=myrange(0, 5)       # 建立產生器物件
>>> range1                                 
<generator object myrange at 0x0000024D07CC86D8>    
>>> type(range1)                       # 這是 myrange() 函式建立的產生器
<class 'generator'>
>>> for i in range1:                   # 用 for 迴圈走訪 generator 物件
    print(i)    
    
0
1
2
3
4
>>> for i in range1:                    # 序列元素已耗盡, 需重新建立產生器物件
    print(i)   
    
>>> range1=myrange(0, 5)        # 重新建立產生器物件
>>> list(range1)                           # 轉成串列
[0, 1, 2, 3, 4]    
>>> list(range1)                           # 序列元素已耗盡, 需重新建立產生器物件
[]
 
下面的範例是使用產生器來產生 Fibonacci 數列, 此數列從 1, 1 開始, 後續的元素是前兩個元素之和, 例如 1, 1, 2, 3, 5, 8, 13, .... 等. 

Fibonacci 數列的產生器函式 : 

>>> def fib():   
    a, b=1, 1               # 數列初始值
    while True:          # 無窮迴圈
        yield a              # 將 a 傳回後紀錄 a, b 之值並暫停執行
        a, b=b, a + b    # 產生下一個元素   
        
>>> fib1=fib()                     # 建立產生器物件 (也是一種迭代器)
>>> type(fib1)                    # 是個 generator 物件
<class 'generator'>    
>>> hasattr(fib1, '__next__')     
True
>>> hasattr(fib1, '__iter__')      
True
>>> for i in range(10):       # 用 for 迴圈走訪前 10 個序列元素
    next(fib1)                        # 呼叫 next() 取得迭代器的下一個元素
    
1
1
2
3
5
8
13
21
34
55

可見雖然產生器函式 fib() 裡面有無窮迴圈, 但它不會一直不停地跑一次, 而是每傳回一個元素就會暫停, 直到下次有人呼叫 next() 再產生下一個元素. 


2. 用 yield from 遞迴產生器函式 :    

遞迴函式常用來處理階層式資料, 例如要將多層的串列拉平 (even) 為一層串列必須使用遞迴函式才能處理任何多層串列, 例如 :

>>> def flatten(sequence):                                     # 定義產生器函式 
    for i in sequence:                                             
        if isinstance(i, list) or isinstance(i, tuple):     # 此元素是個串列或元組 
            for j in flatten(i):                                         # 呼叫 flatten() 遞迴到下一層
                yield j                                                        # 傳回最底層序列元素
        else:                                                                   # 此元素不是容器 (串列或元組)
            yield i                                                            # 傳回序列元素
            
>>> list1=['a', 'b', ('aa','bb'), ['cc', ('ddd', 'eee')]]        # 多層的序列資料
>>> list1    
['a', 'b', ('aa', 'bb'), ['cc', ('ddd', 'eee')]]
>>> flat_list1=flatten(list1)                                    # 建立產生器物件
>>> flat_list1    
<generator object flatten at 0x0000024D07D5A048>
>>> type(flat_list1)     
<class 'generator'>
>>> list(flat_list1)                                                   # 轉成串列
['a', 'b', 'aa', 'bb', 'cc', 'ddd', 'eee']

可見傳入 flatten() 的多層序列物件已經透過遞迴呼叫被拉平為一層序列了. 此處必須使用兩層 for 迴圈來處理階層序列資料, 第一層 for 迴圈為非遞迴走訪, 若元素不是容器就用 yield i 直接輸出 (else 的部分); 若元素是一個容器則進入第二層 for 迴圈以遞迴方式走訪 (先深後廣), 到達底層後就用 yield j 傳回元素, 逐層向上返回. 這種雙層迴圈結構使程式碼可讀性變差, 因此 Python 3.3 版新增了 yield from 語句直接遞迴, 如此即可減少一層 for 迴圈, 例如 : 

>>> def flatten(sequence):          # 定義產生器函式
    for i in sequence:    
        if isinstance(i, list) or isinstance(i, tuple):          
            yield from flatten(i)          # 若元素是容器就用 yield from 遞迴
        else:                                         # 此元素不是容器 (串列或元組)
            yield i                                  # 傳回序列元素
            
>>> flat_list1=flatten(list1)          # 建立產生器物件
>>> list(flat_list1)                         # 轉成串列
['a', 'b', 'aa', 'bb', 'cc', 'ddd', 'eee']

可見改用可直接遞迴的 yield from 使程式碼更簡潔, 可讀性更高. 


3. 產生器表達式 (generator expression) :    

產生器表達式 (generator expression) 是產生器函式的簡化寫法, 但卻不需要用到 yield 語句, 其語法與串列與字典的生成式類似, 但它是用小括弧括起來, 雖然看起來好像是元組的生成式 (注意, Python 沒有 tuple comprehension), 但它並非傳回 tuple, 而是傳回一個 generator 物件, 語法如下 :

generator=(運算式 for i in 可迭代物件)

也可以在 for 迴圈後面附加 if 判斷式 : 

generator=(運算式 for i in 可迭代物件 if 條件式)

for 迴圈若有多層, 內層就串在外層的後面 :

generator=(運算式 for i in 可迭代物件1 for j in 可迭代物件2 if 條件式)

此處 i 為外層 for 迴圈變數, j 為內層 for 迴圈變數 (越後面月內層). 由於傳回值為產生器物件, 故可用 for 迴圈或 next() 函式走訪, 或者傳入 tuple() 或 list() 轉成元組或串列, 所以產生器表達式雖然不是傳回 tuple, 但透過 tuple() 還是可以得到 tuple. 

下面利用產生器表達式來輸出一個平方序列 : 

>>> a=[1, 2, 3, 4, 5]                        
>>> squared=(i * i for i in a)         # 產生器表達式
>>> squared                                    # 傳回產生器物件
<generator object <genexpr> at 0x00000255D8734138>   
>>> type(squared)                          # 傳回產生器物件 
<class 'generator'>   
>>> hasattr(squared, '__iter__')        # 是可迭代物件
True   
>>> hasattr(squared, '__next__')       # 是迭代器物件
True    
>>> for i in squared:                      # 用 for 迴圈走訪
    print(i)       
    
1
4
9
16
25
>>> for i in squared:                    # 產生器只能走訪一次
    print(i)     
    
>>> squared=(i * i for i in a)        # 重新建立產生器
>>> tuple(squared)                       # 轉成元組
(1, 4, 9, 16, 25)       
>>> tuple(squared)                       # 產生器只能轉換一次
()
>>> squared=(i * i for i in a)        # 重新建立產生器
>>> list(squared)                           # 轉成串列
[1, 4, 9, 16, 25]      
>>> list(squared)                           # 產生器只能轉換一次  
[]
>>> squared=(i * i for i in a)        # 重新建立產生器
>>> next(squared)                         # 用 next() 走訪產生器
1
>>> next(squared)                         # 用 next() 走訪產生器   
4
>>> next(squared)                         # 用 next() 走訪產生器
9
>>> next(squared)                         # 用 next() 走訪產生器   
16
>>> next(squared)                         # 用 next() 走訪產生器
25
>>> next(squared)                         # 序列元素耗盡拋出例外
  Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
StopIteration     

下面範例以 if 判斷式過濾生成之序列, 只輸出偶數序列 : 

>>> even=(i for i in range(10) if i%2==0)    
>>> type(even)   
<class 'generator'>   
>>> for item in even:    
    print(item)   
    
0
2
4
6
8

參考 :


沒有留言 :