[Python] Generator 特性及實作場景

Talk is cheap, show you the code.

May 13, 2020

前言

因為想要了解更多有關 Python 的底層實作機制,還有更 Pythonic 的寫法,最近在讀 Fluent PythonEffective Python,內容都有提到 Generator,自己實務上比較少運用到,想要隨手紀錄起來順便理一理思緒。


我的開發環境

  • macOS Catalina
  • Python 3.7.7

正文

目前只有想到兩個場景可能會比較常用到 Generator 的特性:

  • 讀取大量資料的檔案
  • 迴圈因為需要迭代且回傳同一變數而耗費大量記憶體

這邊有兩個名詞要分清楚:

  1. 可迭代的 iterable (Adj.)
  2. 迭代器 iterator (Noun.)

要能夠 iterable,必須要實作 iterator 的 iterator.__iter__(),這個 function 會回傳一個 iterator,用來一次回傳一個成員的物件。有些 iterable 的物件 (e.g. List) 是將值存在記憶體中,而其他就是像 iterator,沒有把所有的值存在記憶體,只有正在執行的時候才會產生值。

iterator 除了 iterator.__iter__() 要實作之外,還需要 iterator.__next__(),當調用 next() 的時候,會做幾件事:

  • 更新自身 (iterator) 狀態,將自己指到下一個位址,如果下一個位址沒有值,狀態就會變為 StopIteration
  • 回傳目前的結果(類似於 pop),回傳完之後值就會從 iterator 中消失,都回傳後就變成空的 Generator 容器 (not None)

直接進到場景可能會直觀一點,假設今天有個需求是要讀取一個 data.csv 檔案,並列出這個檔案的行數:

1
2
3
4
5
6
7
8
# 產生 data.csv 的 Function,可以用來直接產生測試檔案

import csv
def new_test_csv():
    with open('data.csv', 'w', newline='') as f:
        writer = csv.writer(f)
        for i in range(1000000000):
            writer.writerow([i])
1
2
3
4
5
6
7
8
9
10
11
def file_reader(file_name):
    with open(file_name) as f:
        return f.read().split('\n')

file_generator = file_reader('data.csv')
row_count = 0

for row in file_generator:
    row_count += 1

print(f"Total row count: {row_count}")

在行數小的情況下,這樣做沒什麼太大的問題,但如果今天如果 data.csv 的行數很多,像是現在的 10 億行,就有可能會產生 MemoryError 的例外狀況,這時候如果我們將 file_reader() 改用 Generator 的邏輯來寫:

1
2
3
4
def file_reader(file_name):
    with open(file_name) as f:
        for row in f:
            yield row

只要一個 Function 有用到 yield,那去呼叫這個 Function 的時候,就會回傳一個 <generator object>,如下圖:

img

那這個又得先提到 for...in... 迭代的條件:for...in... 的對象必須是 可迭代的 (iterable)

如同前面提到的名詞解釋,這個對象必須實現 iterator.__iter__() 這個方法,而透過呼叫這個可迭代對象的 __iter__() 方法,就可以得到這個對象所對應的迭代器,再透過 for 迴圈不斷地調用 __next__() 來去得到所有的值,直到迭代器拋出 StopIteration 的例外狀況,也就代表這個迭代器已經沒有下一個值,for 迴圈就會自動去處理這個例外狀況並跳出迴圈。

在這邊我們改寫了 file_reader(),讓這個 Function 回傳一個 <generator object>,並指給 file_generator 這個變數:

1
2
3
4
5
file_generator = file_reader('data.csv')
row_count = 0

for row in file_generator:
    row_count += 1

同時,將 file_generatorfor...in... 的方式不斷去調用 __next__() 來取得下一次的元素,在每一次的取得元素的時候再去指派記憶體空間給它就可以了,這麼做可以大幅度地減少浪費不必要的記憶體,而不需要像原先用 return 的方法,在進入到 for 迴圈的時候,就必須要先指派給所有資料記憶體空間,就有可能因為資料量太大而導致 MemoryError 的例外狀況發生。


順便提供一個費氏數列 (Fibonacci) 原先的實作方式及用 Generator 的比較:

一般作法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def fibonacci(times):
    n, a, b = 0, 0, 1
    while n < times:
        print(f'[No.{n+1}] Result: {b}')
        a, b = b, a + b
        n = n + 1
    return True

#>>> fibonacci(10)

#[No.1] Result: 1

#[No.2] Result: 1

#[No.3] Result: 2

#[No.4] Result: 3

#[No.5] Result: 5

#[No.6] Result: 8

#[No.7] Result: 13

#[No.8] Result: 21

#[No.9] Result: 34

#[No.10] Result: 55

Generator 作法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def fibonacci(times):
    n, a, b = 0, 0, 1
    while n < times:
        yield (f'[No.{n+1}] Result: {b}')
        a, b = b, a + b
        n = n + 1

results = fibonacci(10)
for result in results:
    print(result)

#[No.1] Result: 1

#[No.2] Result: 1

#[No.3] Result: 2

#[No.4] Result: 3

#[No.5] Result: 5

#[No.6] Result: 8

#[No.7] Result: 13

#[No.8] Result: 21

#[No.9] Result: 34

#[No.10] Result: 55

參考

Python Generator