Python のジェネレータでメモリを節約する

ジェネレータの最大の利点は、メモリ効率の良さです。リストがすべての要素を一度にメモリに展開するのに対し、ジェネレータは必要なときに1つずつ値を生成します。この「遅延評価」により、大量のデータを扱う際にメモリを大幅に節約できます。

メモリ使用量の比較

100万個の数値を扱う場合の違いを見てみましょう。

import sys

# リスト:すべてメモリに展開
numbers_list = [x ** 2 for x in range(1_000_000)]
print(f"リスト: {sys.getsizeof(numbers_list):,} bytes")
# リスト: 8,448,728 bytes(約8MB)

# ジェネレータ:計算式だけを保持
numbers_gen = (x ** 2 for x in range(1_000_000))
print(f"ジェネレータ: {sys.getsizeof(numbers_gen)} bytes")
# ジェネレータ: 200 bytes

ジェネレータはデータそのものではなく「どうやってデータを生成するか」だけを覚えているため、要素数に関わらずサイズがほぼ一定です。

大きなファイルの処理

巨大なファイルを処理する際、全行をリストに読み込むとメモリ不足になることがあります。

リストで読み込む(危険)

メモリに全行を展開する。数GBのファイルで OutOfMemoryError の可能性。

ジェネレータで処理(安全)

1行ずつ読み込んで処理。ファイルサイズに関係なく一定のメモリで動作。

# 悪い例:全行をリストに読み込む
def read_all_lines(filename):
    with open(filename) as f:
        return f.readlines()  # 全行がメモリに

# 良い例:ジェネレータで1行ずつ
def read_lines(filename):
    with open(filename) as f:
        for line in f:
            yield line.strip()

# 使用例
for line in read_lines("huge_file.txt"):
    process(line)  # 1行ずつ処理

データパイプラインの構築

ジェネレータを連結すると、メモリ効率の良いデータパイプラインを作れます。

def read_numbers(filename):
    with open(filename) as f:
        for line in f:
            yield int(line.strip())

def filter_positive(numbers):
    for n in numbers:
        if n > 0:
            yield n

def double(numbers):
    for n in numbers:
        yield n * 2

# パイプラインを構築(まだ何も実行されない)
pipeline = double(filter_positive(read_numbers("data.txt")))

# ここで初めて1行ずつ処理が実行される
for result in pipeline:
    print(result)

各ジェネレータは1要素ずつ処理するため、ファイル全体がメモリに載ることはありません。

sum() や max() との組み合わせ

集計処理でもジェネレータを活用できます。

# リスト内包表記(一度リストを作成)
total = sum([x ** 2 for x in range(1_000_000)])

# ジェネレータ式(リストを作らない)
total = sum(x ** 2 for x in range(1_000_000))

後者はリストを作らずに直接合計を計算するため、メモリ使用量が大幅に少なくなります。

いつジェネレータを使うべきか

ジェネレータが適している

大量のデータを1回だけ処理する。全データを同時に必要としない。ストリーム処理やパイプライン。

リストが適している

データに何度もアクセスする。インデックスでランダムアクセスしたい。データ量が小さい。

メモリが限られた環境や、巨大なデータを扱う場合は、ジェネレータの活用を検討してください。