閉じる

Pythonジェネレータの基本と使い方: 遅延評価で省メモリ処理

巨大なデータを扱うとき、すべてを一度にメモリへ読み込むと速度低下やメモリエラーを招きます。

そこで役に立つのがジェネレータです。

ジェネレータは必要になった瞬間に要素を1つずつ生成する仕組みで、遅延評価によりメモリ使用量を大幅に抑えられます。

本記事では、ジェネレータの基礎から実用パターン、注意点まで段階的に解説します。

ジェネレータの基礎

ジェネレータとは?

ジェネレータとは、yieldを含む関数や、括弧を使ったジェネレータ式により作られる、要素を逐次的に生成するオブジェクトです。

作成されたジェネレータはイテレータプロトコル( __iter____next__ )に準拠し、forループやnext()で1要素ずつ取り出せます。

すべての要素を前もって保持しないため、巨大なデータ列でも省メモリに扱える点が特徴です。

遅延評価で省メモリ

ジェネレータの中核は遅延評価です。

next()forで要素が要求された瞬間にだけ値を作ります。

これにより、例えば1億件の数値を処理しても、一度に保持するのは多くても数個分です。

すべてをリストにしてから処理するやり方は、メモリを圧迫するため避けるべき場合が多いです。

イテレータとの違い

ジェネレータはイテレータの一種ですが、次の点が実務での違いとして意識されます。

  • ジェネレータはyieldかジェネレータ式で簡潔に書けます。イテレータは通常、クラスで__iter__/__next__を実装します。
  • ジェネレータは状態管理や停止条件をyieldの位置で直感的に表現でき、読みやすいコードになりやすいです。
  • どちらも1回限りの順次消費である点は共通です。

以下に簡単な比較表を示します。

項目ジェネレータ一般的なイテレータ(クラス)
作り方yieldやジェネレータ式__iter__/__next__を自前実装
記述量少ない多い
状態管理ローカル変数とyieldで自然に表現フィールドで明示管理
再利用不可(使い切り)不可が基本(使い切り)

使いどころ(巨大データ/I/O)

ジェネレータが特に効力を発揮するのは次のような場面です。

I/Oを伴う処理は待ち時間が発生しやすく、生成と消費を重ねるストリーミングで全体の効率を上げられます。

  • 巨大ファイルを1行ずつ解析する
  • ネットワークやDBからの逐次取得を加工しながら処理する
  • 画像やログなどをチャンク分割しながら変換する
  • 無限列(ストリーム)を必要な分だけ取り出す

使い方の基本

yieldの最小例

yieldを使った最小のジェネレータ関数です。

呼び出すとジェネレータオブジェクトが返り、next()で要素を取り出します。

Python
# 最小のジェネレータ例
def simple_gen():
    # 値を1つずつ「遅延生成」して返す
    yield 1
    yield 2
    yield 3

g = simple_gen()
print(next(g))  # 1つ目を取り出す
print(next(g))  # 2つ目
print(next(g))  # 3つ目

# これ以上は要素がないので StopIteration が送出される
try:
    print(next(g))
except StopIteration:
    print("StopIteration が発生しました")
実行結果
1
2
3
StopIteration が発生しました

ジェネレータ式(generator expression)

内包表記に似た書き方で、括弧を用いるとジェネレータ式になります。

リスト内包表記と異なり、結果を逐次生成します。

Python
# リスト内包表記は全要素をメモリに載せる
squares_list = [x * x for x in range(5)]  # [0, 1, 4, 9, 16]

# ジェネレータ式は要素を都度生成
squares_gen = (x * x for x in range(5))

print("list:", squares_list)
print("gen 1つ目:", next(squares_gen))
print("gen 2つ目:", next(squares_gen))
実行結果
list: [0, 1, 4, 9, 16]
gen 1つ目: 0
gen 2つ目: 1

next()とforで消費する

ジェネレータはnext()で手動消費しても、forで自動消費しても構いません。

通常は可読性の高いforを使います。

Python
def up_to(n):
    for i in range(n):
        yield i

g = up_to(3)

# next()で手動消費
print(next(g))  # 0
print(next(g))  # 1

# 残りをforで消費
for x in g:
    print("for:", x)  # 2 が出力されて終了
実行結果
0
1
for: 2

一度きりの消費と再利用の注意

ジェネレータは使い切りです。

同じジェネレータを2回ループしても、2回目は何も出てきません。

Python
def nums():
    for i in range(3):
        yield i

g = nums()
print(list(g))  # [0, 1, 2] で使い切り
print(list(g))  # 空になる(すでに消費済み)
実行結果
[0, 1, 2]
[]

再度使うには新しいジェネレータを作り直すか、どうしても複数回使いたい場合はitertools.teeの利用を検討します(後述の注意点参照)。

rangeとの使い分け

rangeはメモリ効率の良い数列オブジェクトで、全要素を保持せずに長さやインデックスアクセスが可能です。

一方、ジェネレータは計算やI/Oを伴う任意の生成処理を記述できます。

比較項目rangeジェネレータ
対象整数の等差数列任意の生成処理(計算やI/Oを含む)
メモリ効率高い(一定)高い(逐次生成)
インデックス可能(例: range(10)[3])不可
長さ取得可能(len(range(...)))不可
再利用可能(何度でもイテレート可)不可(使い切り)
  • 数の連続列だけが必要ならrangeが最適です。
  • ファイル読み込みやフィルタ、動的生成が絡むならジェネレータが適しています。

実用パターン

大きなファイルを1行ずつ処理

巨大ファイルを丸ごとread()で読むのではなく、1行ずつ処理します。

以下はデモのためio.StringIOを使いますが、実運用ではopen("path")に置き換えてください。

Python
from io import StringIO

def iter_lines(handle):
    # with のスコープはジェネレータの寿命に合わせて維持されます
    # 実ファイルなら: with open(path, "r", encoding="utf-8") as f:
    with handle as f:
        for line in f:
            line = line.rstrip("\n")
            # 空行をスキップしつつ、必要なら加工
            if line:
                yield line

# デモ用: メモリ上のファイル風オブジェクト
fake_file = StringIO("alpha\n\nbeta\ngamma\n")

for ln in iter_lines(fake_file):
    print(ln)
実行結果
alpha
beta
gamma

ポイント: 1行ずつ処理することで、ファイルが巨大でも常に少量のメモリで済みます。

変換とフィルタのパイプライン

ジェネレータを段階的につなぐと、ストリーミング処理のパイプラインが作れます。

各段階が都度1件ずつ処理するため、待ち時間やメモリを抑えつつ読みやすい構造になります。

Python
from io import StringIO

def read_lines(handle):
    with handle as f:
        for line in f:
            yield line.strip()

def parse_ints(lines):
    for s in lines:
        if s:  # 空行を除外
            # 変換に失敗したものはスキップ
            try:
                yield int(s)
            except ValueError:
                continue

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

def square(nums):
    for n in nums:
        yield n * n

# デモ入力
fake = StringIO("10\n0\n-3\nfoo\n5\n")

pipeline = square(filter_positive(parse_ints(read_lines(fake))))
for v in pipeline:
    print(v)
実行結果
100
25

チャンク分割で逐次処理

データを一定サイズの塊(チャンク)ごとに処理すると、I/Oやバッチ処理を効率化できます。

Python
def chunked(iterable, size):
    """iterableをsize件ずつのリストにまとめて逐次返すジェネレータ"""
    chunk = []
    for item in iterable:
        chunk.append(item)
        if len(chunk) >= size:
            # ここでチャンクを1つ返してリセット
            yield chunk
            chunk = []
    if chunk:
        # 端数があれば最後に返す
        yield chunk

# 例: 100件のIDを10件ずつDBにバルク挿入するイメージ
ids = range(1, 21)  # デモとして20件
for group in chunked(ids, size=6):
    print("insert bulk:", group)
実行結果
insert bulk: [1, 2, 3, 4, 5, 6]
insert bulk: [7, 8, 9, 10, 11, 12]
insert bulk: [13, 14, 15, 16, 17, 18]
insert bulk: [19, 20]

無限シーケンスの生成

無限列もジェネレータなら容易です。

必要な分だけ取り出します。

Python
def fib():
    """フィボナッチの無限列"""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# 先頭10個だけ消費
from itertools import islice
first10 = list(islice(fib(), 10))
print(first10)
実行結果
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

注意点とベストプラクティス

list化は最後に行う

安易にlist(gen)すると、せっかくの省メモリ性が失われます

可視化や最終結果としての材料化が必要な段階でのみリスト化しましょう。

Python
def numbers(n):
    for i in range(n):
        yield i * i

# 悪い例: 最初に全部リスト化してしまう
# big = list(numbers(1_000_000))

# 良い例: ストリーミングで合計だけ計算
total = sum(numbers(1_000_000))
print("sum:", total)
実行結果
sum: 333332833333500000

複数回使うなら再生成する

ジェネレータは1回限りです。

複数回必要なら、関数をもう一度呼んで新しいジェネレータを作る設計にするか、itertools.teeを使います。

ただしteeは内部バッファを持つため、片方の消費が極端に遅いとメモリ消費が増える点に注意が必要です。

Python
from itertools import tee

def gen():
    for i in range(5):
        yield i

g1, g2 = tee(gen(), 2)
print(list(g1))  # [0, 1, 2, 3, 4]
print(list(g2))  # [0, 1, 2, 3, 4]
実行結果
[0, 1, 2, 3, 4]
[0, 1, 2, 3, 4]

例外時のクリーンアップ(close)

ジェネレータはclose()で明示的に終了させられます。

また、ジェネレータ内部でtry/finallyを使うと、消費途中で例外が起きても確実に後始末できます。

Python
def managed_resource():
    print("open resource")
    try:
        for i in range(3):
            yield i
    finally:
        # 例外やclose()呼び出し時に必ず実行される
        print("cleanup resource")

g = managed_resource()
print(next(g))      # open
g.close()           # 明示クローズ -> finally 実行

# 再デモ: 途中で例外が発生するケース
g2 = managed_resource()
try:
    for x in g2:
        print("use:", x)
        if x == 1:
            raise RuntimeError("oops")
except RuntimeError:
    print("handled error")
実行結果
open resource
0
cleanup resource
open resource
use: 0
use: 1
cleanup resource
handled error

実ファイルやネットワークリソースを扱う場合は、withブロックをジェネレータ内に置く、または外側でコンテキストマネージャ化するなど、クリーンアップが自動で走る構造にしましょう。

メモリ/速度の簡易計測方法

time.perf_counter()tracemallocで簡易比較が可能です。

リスト内包表記とジェネレータ式の差を測ってみます。

Python
import time
import tracemalloc

N = 2_000_00  # 20万 (デモ用に控えめ)

def calc_list():
    # 全要素を先に作る
    return sum([i * i for i in range(N)])

def calc_gen():
    # 都度生成して合計
    return sum(i * i for i in range(N))

def timed(func):
    tracemalloc.start()
    t0 = time.perf_counter()
    result = func()
    t1 = time.perf_counter()
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return {
        "result": result,
        "time_sec": t1 - t0,
        "peak_kb": peak / 1024.0,
    }

print("list:", timed(calc_list))
print("gen :", timed(calc_gen))

例としての出力(環境により変動します):

実行結果
list: {'result': 2666666666700000, 'time_sec': 0.18, 'peak_kb': 8200.5}
gen : {'result': 2666666666700000, 'time_sec': 0.14, 'peak_kb': 0.7}

このようにピークメモリ使用量はジェネレータ式が圧倒的に小さいことが分かります。

速度はケースにより変わりますが、I/Oや大規模データではメモリ節約が全体の効率を改善しやすいです。

まとめ

ジェネレータは遅延評価により巨大データを省メモリで扱える実践的な仕組みです。

yieldとジェネレータ式で簡潔に書け、パイプラインやチャンク処理、無限列など多彩なパターンに適用できます。

ポイントは次の通りです。

ジェネレータは使い切りであること、list()による材料化は最後に限定すること、必要なら再生成やitertools.teeを検討すること、そしてtry/finallyclose()でクリーンアップを確実にすることです。

これらを踏まえ、巨大なデータ列やI/O中心の処理をストリーミング思考へ切り替えると、コードはより効率的で堅牢になります。

Python 実践TIPS - コーディング効率化・Pythonic
この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!