🙌

Pythonのジェネレータを理解する

2024/03/25に公開

これはなに

Pythonのジェネレータを理解するための記事。

ジェネレータとは

Pythonにおけるジェネレータとは、「次のデータが必要になったら、そのときにデータをつくる仕組み」である。ジェネレータが呼び出されると、関数が実行されて値を返し、関数は一時停止する。次に呼び出されると、一時停止した箇所から再開される。

比喩を使うと、ジェネレータとは、頭から1ページずつ本を読むようなものである。

ふつう、本というものは最初から最後まですべてのページが手元にある。そのため、好きなときに好きなページを開けるし、好きなページを何度も読める。しかし、本が非常に分厚いと、置く場所がなくなるし、持ち運ぶのも大変である。

ジェネレータの場合は、読みたいときだけ次のページを作り出す。ゆえに、手元には1ページしかない。そのため、置く場所を取らないし、持ち運びも楽である。しかし、途中から読み始められないし、同じページを何度も読むことはできない。

もう少しプログラム的な話をする。たとえば、1から10までの数値を順番に得るプログラムを考える。リストを使うと、1から10までの数値をすべて作成することになる。

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# この時点で1から10までの数値がすべてメモリに格納される

# すでにメモリに格納された値を取り出すだけ
print(numbers[0])  # 1
print(numbers[1])  # 2
print(numbers[2])  # 3

しかし、ジェネレータを使うと、1から10までの数値を必要なときに1つずつ作成できる。

def generate_numbers():
    for i in range(1, 11):
        yield i


numbers = generate_numbers()
# この時点では1から10までの数値はまだ生成されていない

# `next`で呼ばれるたびに1つずつ数値を生成する
print(next(numbers))  # 1
print(next(numbers))  # 2
print(next(numbers))  # 3

もっと真面目な話をすると、Pythonにおけるジェネレータとは、遅延評価の性質を持つ特別なイテレータの一種である。イテレータの一種なので、__iter__()__next__()の2つのメソッドを持つ。また、遅延評価の性質を持つため、__next__()メソッドが呼ばれるまで次の要素を生成しない。__next__()メソッドが呼ばれてはじめて次の要素を生成する。

ジェネレータの利点

ジェネレータを利用すると、すべての値をメモリに格納する必要がない。そのため、メモリの使用量を抑えられる。これは、とくに大量のデータを扱う場合に有用である。たとえば、ファイルの内容を1行ずつ読み込む場合、ジェネレータを使うと、ファイル全体をメモリに読み込む必要がなくなる。

def read_file(file_path):
    with open(file_path) as f:
        for line in f:
            yield line

また、すべての値をメモリに格納する必要がないため、無限のデータを扱える。たとえば、フィボナッチ数列をジェネレータで表現できる。

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b


fib = fibonacci()

print(next(fib))  # 0
print(next(fib))  # 1
print(next(fib))  # 1
print(next(fib))  # 2
print(next(fib))  # 3

ジェネレータを利用するシーン

ジェネレータは、次のような場面で利用すると便利である。

  • 大規模なデータを扱う場合
  • 無限のデータを扱う場合
  • メモリを節約したい場合
  • 計算量の大きいシーケンスを扱う場合

とくに、大規模なリストを返す関数では、リストの代わりにジェネレータを使うことを検討すると良い。たとえば、次のような関数は、リストを返すのではなく、ジェネレータを返すようにすると良い。

def generate_numbers():
    for i in range(1000000):
        yield i

また、大量の入力に対してリスト内包表記を使うと、メモリを大量に消費する可能性がある。その場合、ジェネレータ式を使うとメモリを節約できる。

numbers = (i for i in range(1000000))

ジェネレータの欠点

ジェネレータはステートフルなオブジェクトである。次の呼び出しあるいは関数の終了まで関数の実行は保持され、一度next()で呼び出した値は破棄される。そのため、一度値を生成した後だと、再度同じ値を取得したり、それより前に生成した値を取得したりできない。

ジェネレータの作り方

ジェネレータを作る方法は2つある。ひとつは、ジェネレータ関数を使う方法。もうひとつは、ジェネレータ式を使う方法である。

ジェネレータ関数

ジェネレータ関数とは、yield文を使って値を返す関数である。通常の関数と同様にdefで定義するが、returnの代わりにyieldを使用する。yield文を使うことで、関数の実行を一時停止して値を返せる。

def generate_numbers():
    for i in range(1, 11):
        yield i

ジェネレータ式

ジェネレータ式とは、リスト内包表記と似た構文で、ジェネレータを生成する方法である。リスト内包表記との違いは、[]ではなく()を使う点である。基本的な形は以下のようになる。

generator = (expression for item in iterable)

たとえば、1から10までの数値をジェネレータ式で生成する場合は、次のようになる。

numbers = (i for i in range(1, 11))

ジェネレータ式に条件を設けることもできる。たとえば、上記のジェネレータで偶数だけを取り出す場合は、次のようになる。

numbers = (i for i in range(1, 11) if i % 2 == 0)

まとめ

大規模なデータをリストに格納するぐらいなら、ジェネレータを用いたほうがメモリを節約できる。

参考

Discussion