Python & AI Tutorials Logo
Python プログラミング

36. ジェネレータと遅延イテレーション

第35章では、イテラブル(iterable)とイテレータ(iterator)を通して、Python におけるイテレーション(iteration)の仕組みを学びました。イテレータは要求されたときに値を1つずつ返すため、Python はすべてを一度にメモリへ読み込まずにシーケンスを処理できることを確認しました。ここからは、イテレータを作るための Python で最もエレガントかつ実用的な方法である ジェネレータ(generator) を見ていきます。

ジェネレータは、実行を一時停止して再開できる関数であり、事前にすべての値を計算してメモリに保存するのではなく、要求に応じて値を1つずつ生成します。この方法は 遅延評価(lazy evaluation) と呼ばれ、必要になったときにだけ値が生成されるため、メモリ効率の良いコードを書くうえで Python の最も強力な機能の1つです。

36.1) ジェネレータとは何か、なぜ便利なのか

36.1.1) 大きなリストを作る問題点

まず、ジェネレータが解決する問題を理解しましょう。たとえば100万個の数値からなるシーケンスを処理する必要があるとします。次は、リスト(list)を使った従来のアプローチです。

python
# 100万個の平方数のリストを作成する
def get_squares_list(n):
    """0 から n-1 までの平方数のリストを返す。"""
    squares = []
    for i in range(n):
        squares.append(i * i)
    return squares
 
# これはメモリ上に 1,000,000 個の数を持つリストを作成する
numbers = get_squares_list(1_000_000)
print(f"First five squares: {numbers[:5]}")  # Output: First five squares: [0, 1, 4, 9, 16]

このアプローチには大きな問題があります。たとえ1つずつ処理したいだけでも、100万個の数値をすべて一度に生成し、メモリへ保存してしまいます。より大きなデータセットやより複雑な計算では、膨大なメモリを消費したり、最悪の場合プログラムがクラッシュしたりします。

36.1.2) ジェネレータの導入: 必要に応じて値を計算する

ジェネレータ(generator) は、要求されたときにだけ値を1つずつ生成する特別な種類の関数です。完全なリストを作って返す代わりに、ジェネレータは必要なときに各値を計算し、呼び出しの合間に「どこまで処理したか」を覚えています。

同じ機能をジェネレータとして実装すると次のようになります。

python
# 平方数のジェネレータを作成する
def get_squares_generator(n):
    """0 から n-1 までの平方数を、1つずつ生成する。"""
    for i in range(n):
        yield i * i  # yield は関数を一時停止し、値を返す
 
# これはリストではなく、ジェネレータオブジェクトを作成する
squares_gen = get_squares_generator(1_000_000)
print(squares_gen)  # Output: <generator object get_squares_generator at 0x...>
 
# 値を1つずつ取得する
print(next(squares_gen))  # Output: 0
print(next(squares_gen))  # Output: 1
print(next(squares_gen))  # Output: 4

ジェネレータは100万個の平方数を事前にすべて計算しません。代わりに、next() を呼び出したときにだけ各平方数を計算します。呼び出しの合間、ジェネレータは「一時停止」し、状態(現在の i の値)を覚えています。

36.1.3) メモリ効率: 最大の利点

大きなデータセットでは、リストとジェネレータのメモリ差は劇的になります。比較してみましょう。

python
import sys
 
# リストのアプローチ: すべての値を保存する
def squares_list(n):
    return [i * i for i in range(n)]
 
# ジェネレータのアプローチ: 必要に応じて値を計算する
def squares_generator(n):
    for i in range(n):
        yield i * i
 
# 100,000 個の数に対するメモリ使用量を比較する
list_result = squares_list(100_000)
gen_result = squares_generator(100_000)
 
print(f"List size in memory: {sys.getsizeof(list_result):,} bytes")
# Output: List size in memory: 800,984 bytes (actual size may vary)
 
print(f"Generator size in memory: {sys.getsizeof(gen_result)} bytes")
# Output: Generator size in memory: 200 bytes (actual size may vary)

リストは800KBを超えるメモリを消費しますが、ジェネレータは最終的にいくつ値を生成するかに関係なく、200バイトしか使いません。ジェネレータが保存するのは、実際の値の列ではなく、関数の状態( i の現在値と、どこから再開するか)だけです。

36.1.4) ジェネレータが役立つ場面

ジェネレータは、いくつかの一般的なシナリオで特に威力を発揮します。

大きなファイルの処理:

python
def read_large_file(filename):
    """ファイルの各行を1行ずつ生成する。"""
    with open(filename, 'r') as file:
        for line in file:
            yield line.strip()
 
# 巨大なログファイルを、すべてメモリに読み込まずに処理する
for line in read_large_file('huge_log.txt'):
    if 'ERROR' in line:
        print(line)

無限シーケンス:

python
def fibonacci():
    """フィボナッチ数を無限に生成する。"""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b
 
# フィボナッチ数を永遠に生成する(または要求するのをやめるまで)
fib = fibonacci()
print([next(fib) for _ in range(10)])
# Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

36.1.5) ジェネレータはイテレータである

第35章で学んだとおり、ジェネレータは実はイテレータの一種です。イテレータプロトコル(__iter__()__next__())を自動的に実装しているため、for ループとシームレスに連携します。

python
def countdown(n):
    """n から 1 までカウントダウンを生成する。"""
    while n > 0:
        yield n
        n -= 1
 
# ジェネレータは for ループでそのまま使える
for num in countdown(5):
    print(num)
# Output:
# 5
# 4
# 3
# 2
# 1

for ループでジェネレータを使うと、Python はジェネレータが尽きて(StopIteration を送出して)終了するまで、自動的に next() を繰り返し呼び出します。

36.2) yield を使ってジェネレータ関数を作る

36.2.1) yield 文: 一時停止と再開

yield 文が、関数をジェネレータにします。Python が yield に遭遇すると、特別な動作をします。値を返して関数を終了するのではなく、関数を 一時停止 して値を返します。次にジェネレータに対して next() を呼び出すと、実行は yield 文の直後から再開されます。

この一時停止と再開の挙動を示す簡単な例を見てみましょう。

python
def simple_generator():
    """yield が実行を一時停止する仕組みを示す。"""
    print("Starting generator")
    yield 1
    print("Resuming after first yield")
    yield 2
    print("Resuming after second yield")
    yield 3
    print("Generator finished")
 
gen = simple_generator()
print("Created generator")
# Output:
# Created generator
 
print(f"First value: {next(gen)}")
# Output:
# Starting generator
# First value: 1
 
print(f"Second value: {next(gen)}")
# Output:
# Resuming after first yield
# Second value: 2
 
print(f"Third value: {next(gen)}")
# Output:
# Resuming after second yield
# Third value: 3
 
try:
    next(gen)
except StopIteration:
    print("Generator exhausted - no more values")
# Output:
# Generator finished
# Generator exhausted - no more values

関数の実行が next() の呼び出しと交互に進むことに注目してください。各 yield が関数を一時停止し、各 next() が中断した場所から再開します。

36.2.2) ジェネレータの状態: ローカル変数を覚える

ジェネレータは、yield の間でローカル変数をすべて覚えています。この性質により、複数回の呼び出しにまたがって状態を維持する用途に便利です。

python
def counter(start=0):
    """start から始まる連番を生成する。"""
    current = start
    while True:
        yield current
        current += 1
 
# ジェネレータは yield の間に 'current' を覚えている
count = counter(10)
print(next(count))  # Output: 10
print(next(count))  # Output: 11
print(next(count))  # Output: 12
 
# 各ジェネレータはそれぞれ独立した状態を持つ
count1 = counter(0)
count2 = counter(100)
print(next(count1))  # Output: 0
print(next(count2))  # Output: 100
print(next(count1))  # Output: 1
print(next(count2))  # Output: 101

変数 current は、ジェネレータが yield で一時停止し、次の next() 呼び出しで再開するたびに保持されます。これにより、ジェネレータは直前の値から数え上げを継続できます。各ジェネレータインスタンスは、それぞれ独立した状態を維持します。

36.2.3) ループ内で yield する: 最も一般的なパターン

ジェネレータの最も一般的な使い方は、ループ内で値を yield することです。このパターンは値のシーケンスを生成します。

python
def even_numbers(start, end):
    """指定した範囲内の偶数を生成する。"""
    current = start if start % 2 == 0 else start + 1
    while current <= end:
        yield current
        current += 2
 
# ジェネレータを使う
evens = even_numbers(1, 20)
print(list(evens))
# Output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

ループの各反復で1つの値を yield し、その後 next() が再度呼ばれたときに次の反復へ進みます。

36.2.4) 複数の yield 文

ジェネレータはコード内の異なる箇所に複数の yield 文を持てます。実行は順番にそれらを通って進みます。

python
def process_data(data):
    """ステータスメッセージとともに、処理済みデータを生成する。"""
    yield "Starting processing..."
    
    cleaned = [item.strip().lower() for item in data]
    yield f"Cleaned {len(cleaned)} items"
    
    unique = list(set(cleaned))
    yield f"Found {len(unique)} unique items"
    
    for item in sorted(unique):
        yield item
 
# データを処理する
data = ["  Apple  ", "Banana", "apple", "Cherry", "BANANA"]
processor = process_data(data)
 
for result in processor:
    print(result)
# Output:
# Starting processing...
# Cleaned 5 items
# Found 3 unique items
# apple
# banana
# cherry

このパターンは、セットアップ処理を行い、ステータス情報を yield し、その後に実データを yield する必要があるジェネレータに便利です。

36.3) ジェネレータ式とリスト内包表記の比較

36.3.1) ジェネレータ式の導入

第34章では、リスト内包表記(list comprehension)という、リストを作るための簡潔な方法を学びました。ジェネレータ式(generator expression) はほぼ同じ構文を使いますが、リストではなくジェネレータを作成します。

ジェネレータ式は、本質的には単純なジェネレータ関数をコンパクトに書く方法です。次の2つの同等のアプローチを比べてみましょう。

python
# ジェネレータ関数
def squares_function(n):
    for x in range(n):
        yield x * x
 
# ジェネレータ式 - 同じことをする
squares_expression = (x * x for x in range(10))
 
# どちらもジェネレータオブジェクトを作る
gen1 = squares_function(10)
gen2 = squares_expression
 
print(type(gen1))  # Output: <class 'generator'>
print(type(gen2))  # Output: <class 'generator'>
 
# どちらも同じ値を生成する
print(list(squares_function(10)))  # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(list(squares_expression))  # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

構文はリスト内包表記とほぼ同じです。違いは、角括弧 [] ではなく丸括弧 () を使うこと、そしてリスト内包表記はリストを作るのに対し、ジェネレータ式はジェネレータを作ることです。

python
# リスト内包表記 - メモリ上にリスト全体を作る
squares_list = [x * x for x in range(10)]
print(squares_list)
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
 
# ジェネレータ式 - ジェネレータオブジェクトを作る
squares_gen = (x * x for x in range(10))
print(squares_gen)
# Output: <generator object <genexpr> at 0x...>
 
# 値を見たいときは list に変換する
print(list(squares_gen))
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

ジェネレータ式は、リスト内包表記と同じ簡潔な構文を、ジェネレータのメモリ効率と組み合わせて提供します。

36.3.2) メモリ比較: 重要になる場面

小さなシーケンスでは、リスト内包表記とジェネレータ式のメモリ差は無視できます。しかし大きなシーケンスでは、差が顕著になります。

python
import sys
 
# 小さなシーケンス - 差はわずか
small_list = [x for x in range(100)]
small_gen = (x for x in range(100))
 
print(f"Small list: {sys.getsizeof(small_list)} bytes")
# Output: Small list: 920 bytes (actual size may vary)
print(f"Small generator: {sys.getsizeof(small_gen)} bytes")
# Output: Small generator: 192 bytes (actual size may vary)
 
# 大きなシーケンス - 大きな差
large_list = [x for x in range(1_000_000)]
large_gen = (x for x in range(1_000_000))
 
print(f"Large list: {sys.getsizeof(large_list):,} bytes")
# Output: Large list: 8,448,728 bytes (actual size may vary)
print(f"Large generator: {sys.getsizeof(large_gen)} bytes")
# Output: Large generator: 192 bytes (actual size may vary)

ジェネレータのサイズは、いくつ値を生成するかに関係なく一定で、式と現在の状態だけを保持します。一方、リストはすべての値をメモリに保持する必要があるため、要素数に比例してサイズが増えます。

36.3.3) 関数呼び出しでのジェネレータ式

ジェネレータ式は、イテラブルを消費する関数に直接渡すときに特にエレガントです。ジェネレータ式が唯一の引数である場合、余分な括弧を省略できます。

python
# リストを作らずに平方和を計算する
total = sum(x * x for x in range(100))  # Note: no extra parentheses needed
print(total)
# Output: 328350
 
# 変換後の値の最大値を求める
numbers = [1, 2, 3, 4, 5]
max_square = max(x * x for x in numbers)
print(max_square)
# Output: 25
 
# いずれかの値が条件を満たすか確認する
data = [10, 15, 20, 25, 30]
has_large = any(x > 100 for x in data)
print(has_large)
# Output: False

このパターンはメモリ効率が高く、読みやすいです。sum()max()min()any()all() のような関数は、ジェネレータを1つずつ処理し、中間リストを作りません。

36.3.4) ジェネレータ式によるフィルタリング

ジェネレータ式は、リスト内包表記と同じ条件ロジックをサポートします。

python
# 偶数をフィルタする
numbers = range(20)
evens = (x for x in numbers if x % 2 == 0)
print(list(evens))
# Output: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
 
# 変換とフィルタを同時に行う
words = ["hello", "world", "python", "programming"]
long_upper = (word.upper() for word in words if len(word) > 5)
print(list(long_upper))
# Output: ['PYTHON', 'PROGRAMMING']

36.3.5) ジェネレータ式だけでは足りない場合

ジェネレータ式は簡潔でエレガントですが、制約もあります。次のような場合はジェネレータ関数を使ってください。

複雑なロジック:

python
# ジェネレータ式には複雑すぎる
def process_log_lines(filename):
    """複雑なロジックでログファイルを処理する。"""
    with open(filename, 'r') as file:
        for line in file:
            line = line.strip()
            if not line or line.startswith('#'):
                continue  # 空行とコメントをスキップする
            
            parts = line.split('|')
            if len(parts) >= 3:
                timestamp, level, message = parts[0], parts[1], parts[2]
                if level in ('ERROR', 'CRITICAL'):
                    yield {
                        'timestamp': timestamp,
                        'level': level,
                        'message': message
                    }

複数の yield または状態:

python
# ジェネレータ式は反復間で状態を維持できない
def running_total(numbers):
    """数値の累積合計を生成する。"""
    total = 0
    for num in numbers:
        total += num
        yield total
 
numbers = [1, 2, 3, 4, 5]
print(list(running_total(numbers)))
# Output: [1, 3, 6, 10, 15]

エラーハンドリング:

python
# ジェネレータ式では例外処理を扱えない
def safe_divide(numbers, divisor):
    """エラー処理をしながら除算結果を生成する。"""
    for num in numbers:
        try:
            yield num / divisor
        except ZeroDivisionError:
            yield float('inf')

36.4) リストではなくジェネレータを使うべきとき

36.4.1) 大きなデータセット: 最重要のユースケース

ジェネレータを使う最も説得力のある理由は、大量のデータを扱うときです。数百万件のレコードを処理する場合、ジェネレータは「快適に動くプログラム」と「クラッシュするプログラム」の分かれ目になり得ます。

悪いアプローチ - ファイル全体をメモリに読み込む:

python
# 大きなファイルでこれをやってはいけません
def count_errors_bad(filename):
    """ファイル全体をメモリに読み込む - 大きなファイルではクラッシュする。"""
    with open(filename, 'r') as file:
        lines = file.readlines()  # Loads ENTIRE file into memory
    
    error_count = 0
    for line in lines:
        if 'ERROR' in line:
            error_count += 1
    
    return error_count
 
# ファイルが 10 GB だと、10 GB をメモリに読み込もうとする!

良いアプローチ - ジェネレータを使う:

python
def read_log_lines(filename):
    """ログファイルの各行を1行ずつ生成する。"""
    with open(filename, 'r') as file:
        for line in file:
            yield line.strip()
 
def count_errors_good(filename):
    """ファイル全体をメモリに読み込まずにエラー数を数える。"""
    error_count = 0
    for line in read_log_lines(filename):
        if 'ERROR' in line:
            error_count += 1
    
    return error_count
 
# ギガバイト級のログファイルでも効率良く動作する
# なぜなら、一度にメモリに保持するのは1行だけだから
count = count_errors_good('huge_application.log')
print(f"Found {count} errors")

ジェネレータのアプローチでは1行ずつ処理するため、メモリ使用量はファイルサイズに関係なく一定です。10GBのファイルでも、10KBのファイルと同じだけのメモリで処理できます。

36.4.2) 無限、または長さが不明なシーケンス

ジェネレータは、長さが事前にわからないシーケンスや、概念的に無限のシーケンスに最適です。

python
def user_input_stream():
    """ユーザーが 'quit' と入力するまで、入力を生成する。"""
    while True:
        user_input = input("Enter a number (or 'quit'): ")
        if user_input.lower() == 'quit':
            break
        try:
            yield int(user_input)
        except ValueError:
            print("Invalid number, try again")
 
# 到着したユーザー入力を処理する
total = 0
count = 0
for number in user_input_stream():
    total += number
    count += 1
    print(f"Running average: {total / count:.2f}")

長さが不明なリストは作れませんが、ジェネレータなら自然に扱えます。

36.4.3) 変換の連鎖: データパイプラインを作る

データに複数の変換を適用する必要がある場合、ジェネレータを使うと中間リストを作らずに処理を連結できます。

python
# 複数ステージで数値を変換する
def generate_numbers(n):
    """1 から n までの数値を生成する。"""
    for i in range(1, n + 1):
        yield i
 
def square_numbers(numbers):
    """入力の平方数を生成する。"""
    for num in numbers:
        yield num * num
 
def keep_even(numbers):
    """偶数だけを生成する。"""
    for num in numbers:
        if num % 2 == 0:
            yield num
 
# ジェネレータを連結する - 中間リストは作られない
numbers = generate_numbers(10)
squared = square_numbers(numbers)
even_squares = keep_even(squared)
 
# 結果を処理する
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]

各ステージは値を1つずつ処理し、次のステージへ渡します。これはメモリ効率が良く、利用可能な RAM より大きなデータセットも処理できます。

generate_numbers

square_numbers

keep_even

結果

ジェネレータを使わない場合、中間リストが必要になります:

python
# 非ジェネレータのアプローチ - 中間リストを作成する
numbers = list(range(1, 11))           # [1, 2, 3, ..., 10]
squared = [n * n for n in numbers]     # [1, 4, 9, ..., 100]
even_squares = [n for n in squared if n % 2 == 0]  # [4, 16, 36, 64, 100]
 
# ジェネレータなら - 中間リストなし
numbers = (i for i in range(1, 11))
squared = (n * n for n in numbers)
even_squares = (n for n in squared if n % 2 == 0)
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]

100万件を処理する3段階のパイプラインでは、リストのアプローチは100万要素のリストを3つ作成します。ジェネレータのアプローチは、一度にメモリ上に保持するのは1つの値だけです。

36.4.4) リストのほうがジェネレータより良い場合

利点がある一方で、ジェネレータが常に正しい選択とは限りません。次が必要な場合はリストを使ってください。

複数回の反復:

python
# リスト - 複数回反復できる
numbers = [1, 2, 3, 4, 5]
print(sum(numbers))      # Output: 15
print(max(numbers))      # Output: 5 (works fine)
 
# ジェネレータ - 1回しか反復できない
numbers_gen = (x for x in range(1, 6))
print(sum(numbers_gen))  # Output: 15
print(max(numbers_gen))  # Output: ValueError: max() iterable argument is empty

同じデータを複数回処理する必要がある場合は、リストを使ってください。

ランダムアクセス:

python
# インデックスで要素にアクセスする必要がある - リストを使う
students = ['Alice', 'Bob', 'Charlie', 'Diana']
print(students[2])  # Output: Charlie
 
# ジェネレータはインデックスアクセスをサポートしない
students_gen = (name for name in students)
# students_gen[2]  # ERROR: 'generator' object is not subscriptable

長さ情報:

python
# 長さを知る必要がある - リストを使う
data = [1, 2, 3, 4, 5]
print(f"Processing {len(data)} items")
 
# ジェネレータには長さがない
data_gen = (x for x in data)
# len(data_gen)  # ERROR: object of type 'generator' has no len()

小さなデータセット:

python
# 小さなデータセットなら、リストで十分で扱いやすい
small_data = [x * 2 for x in range(10)]
 
# ここではジェネレータのメモリ節約は大きくなく、
# リストのほうが柔軟に扱える

36.4.5) 実用的な判断ガイド

ジェネレータとリストのどちらを選ぶかの実用的なガイドです。

ジェネレータを使うべき場合:

  • 大きなファイルやデータセットを処理する
  • データストリームやユーザー入力を扱う
  • データ処理パイプラインを構築する
  • メモリ効率が重要
  • 1回だけ反復すればよい
  • シーケンスが無限、または非常に長い

リストを使うべき場合:

  • データセットが小さい(一般的に < 10,000 件)
  • 複数回反復する必要がある
  • インデックスによるランダムアクセスが必要
  • 長さを知る必要がある
  • リストを期待するコードへデータを渡す必要がある

36.4.6) ジェネレータとリストの相互変換

必要に応じて、ジェネレータとリストは簡単に変換できます。

python
# ジェネレータをリストへ
numbers_gen = (x * 2 for x in range(5))
numbers_list = list(numbers_gen)
print(numbers_list)
# Output: [0, 2, 4, 6, 8]
 
# リストをジェネレータへ(ジェネレータ式を使用)
numbers_list = [1, 2, 3, 4, 5]
numbers_gen = (x for x in numbers_list)

この柔軟性により、効率のためにジェネレータから始め、リスト特有の機能が必要になったときだけリストへ変換できます。

python
# メモリ効率のためにジェネレータから開始する
numbers = (x for x in range(1, 1001))
filtered = (x for x in numbers if x % 7 == 0)
 
# 複数回反復したいときにリストへ変換する
multiples_of_seven = list(filtered)
 
# これでリストの機能が使える
print(f"Count: {len(multiples_of_seven)}")
# Output: Count: 142
 
print(f"First: {multiples_of_seven[0]}")
# Output: First: 7
 
print(f"Last: {multiples_of_seven[-1]}")
# Output: Last: 994
 
# 複数回反復できる
total = sum(multiples_of_seven)
average = total / len(multiples_of_seven)
print(f"Average: {average:.1f}")
# Output: Average: 500.5

ジェネレータは、メモリ効率の高いコードを書くための Python の最もエレガントな機能の1つです。大きなデータセットを処理したり、データパイプラインを構築したり、無限シーケンスを扱ったりしながらも、コードをクリーンで読みやすいままに保てます。経験を積むにつれて、ジェネレータが適切な道具になる場面の直感が身についていくでしょう。

© 2025. Primesoft Co., Ltd.
support@primesoft.ai