デザインパターンの視点から見たPythonのジェネレータ


Pythonのジェネレータの詳細な説明や使い道については以下の記事が参考になります。

計算コストやメモリ等のリソースに関するメリットについては上の記事で述べられていますので、ここではデザインパターンにおける「Iteratorパターン」としてジェネレータを捉えてみようと思います。

使用したPythonのバージョン: 3.5.6

(2019/2/11 具体例の記載を追加しました。)

Iteratorパターンとジェネレータ

大まかに言うと、「集合から要素を順番に取り出す仕組みを、取り出す側から見た内部処理を隠蔽した形で実装したもの」と言えるでしょう。

参考: デザインパターン - Iterator

以下のような、イテラブルオブジェクトの合計を返す簡単な関数を考えてみます。ここでは、このtotal関数が「取り出す側」になります。

def total(iterable):
    total = 0
    for v in iterable:
        total += v
    return total

Pythonのリストはイテラブルなので、この関数に渡すことができます。

iterable = [1, 10, 100]
total(iterable) # => 111

ジェネレータもイテラブルなので、この関数に渡すことができます。
(ジェネレータの動きについては Pythonのジェネレータの動きだけを簡単に理解する を参照)

def generator1():
    yield 1
    yield 10
    yield 100

iterable = generator1()
total(iterable) # => 111

引数の回数だけ10を返すジェネレータを渡してみます。

def generator2(n):
    for i in range(n):
        yield 10

iterable = generator2(5)
total(iterable) # => 50

単語のリストを受け取り、単語の文字数を順に返すジェネレータはこうなります。

def generator3(word_list):
    for word in word_list:
        yield len(word)

iterable = generator3(['hoge', 'fuga', 'piyo'])
total(iterable) # => 12

さて、total関数から見た場合、引数は「数値を返すイテラブルオブジェクト」であれば、その実装の詳細に関係なく処理を行うことができます。ファイルからデータを読み込んでもいいですし、データベースやWeb APIから取得したデータを加工して数値を返してもいいでしょう。

このように、実装の隠蔽(抽象化)を簡単な記述で行うことができるのもジェネレータのメリットだと思います。

ジェネレータの使いどころ

実のところ、Pythonの組み込み関数には「iterable」を引数に取るものが数多くあり、これらの関数にはジェネレータを引数として渡すことができます。

例えばsum関数の引数は

sum(iterable[, start])

となっています。なので、例で作成したtotal関数は本来は不要で、sum関数にジェネレータを渡せば同じ結果が得られます。

このような関数を使用する場合に、ジェネレータを用いることですっきりと記載できるケースは多々あるのではないかと思います。以下の記事はPHPのイテレータとジェネレータについてのものですが、考え方は普遍的なものであり非常に参考になります。

参考: コードをまとめる技術としてのイテレータとジェネレータ

(以下、2019/2/11に追記)

もう少し具体的な例として、CSVファイルを1行ずつ読み込んで、全ての行を結合して一つのリストにまとめるというタスクを考えてみます。
CSVファイルは以下の2つを使用します(ヘッダ行はありません)。

file1.csv
a,b,c
d,e,f
file2.csv
g,h,i
j,k,l

一つのファイルを読み込んで処理する関数をジェネレータを使わずに普通に書くと以下のようになります。

import csv

# 1つのCSVファイルを読み込んで全ての行を1つのリストに結合する関数
def concat_csv_rows(file_path):
    result = []
    with open(file_path) as f:
        reader = csv.reader(f)
        for row in reader:
            result.extend(row) # 各行の結合
    return result

print(concat_csv_rows('file1.csv'))
# =>
# ['a', 'b', 'c', 'd', 'e', 'f']

この関数を用いて、複数のファイルをリストで受け取れるように拡張します。

# 複数のファイルをリストで受け取れるように拡張した関数
def concat_csv_rows_multi(file_path_list):
    result = []
    for file_path in file_path_list:
        result.extend(concat_csv_rows(file_path)) # 各ファイルの処理結果を結合
    return result

print(concat_csv_rows_multi(['file1.csv', 'file2.csv']))
# =>
# ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l']

同様の処理をジェネレータを用いて書く場合、ジェネレータを受け取る関数を以下のように定義することができます。

# 1行ずつ行を返すイテレータを受け取って結合する関数
def concat_rows(iter_rows):
    result = []
    for row in iter_rows:
        result.extend(row) # 各行の結合
    return result

ジェネレータの定義は以下のようになります。
ジェネレータの中で他のジェネレータを使用する場合は yield from を使うことができます。

import csv

# 1つのファイルを読み込んで1行ずつイテレートするジェネレータ
def get_csv_rows(file_path):
    with open(file_path) as f:
        reader = csv.reader(f)
        for row in reader:
            yield row

print(concat_rows(get_csv_rows('file1.csv')))
# =>
# ['a', 'b', 'c', 'd', 'e', 'f']

# 複数のファイルをリストで受け取れるように拡張したジェネレータ
def get_csv_rows_multi(file_path_list):
    for file_path in file_path_list:
        yield from get_csv_rows(file_path) # ジェネレータをループで呼び出すのと等価

print(concat_rows(get_csv_rows_multi(['file1.csv', 'file2.csv'])))
# =>
# ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l']

なお、yield from を使わずに書くと以下のようになります。

def get_csv_rows_multi(file_path_list):
    for file_path in file_path_list:
        for row in get_csv_rows(file_path):
            yield row

プログラムの長さはジェネレータを使ったほうが少し長いですが、「1行ずつのデータ取得」が抽象化されており、見通しが良くなっていると思います。

そのため、複数のファイルからデータを取得する場合でも各ファイルごとのデータの結合を考慮する必要がありません。新たに別のデータの取得方法を追加する際にも、「イテラブルなデータを返す」ことに集中することができます。

ジェネレータを受け取る関数はイテラブルオブジェクトなら何でも受け取ることができるので、以下のように普通のリストを渡すことも可能です。

rows = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]
print(concat_rows(rows))
# =>
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

おわりに

最近までジェネレータの使い方が良くわかっていなかったのですが、使ってみると非常に便利でした。
計算コストやメモリ等のリソース面で問題がない場合でも、コードの見通しを良くする目的で積極的に使ってみる価値はあるのではないでしょうか。