Skip to the content.

← 目次← 前: 25次: 27 →

26. ジェネレータとイテレータ

テーマ: 大規模ログをメモリに載せずに集計するストリーム処理

学習点: yield, ジェネレータ式, itertools, パイプライン設計, メモリ使用量の比較

依存: 標準ライブラリのみ / 難易度: 上級

実行方法

uv run 26_generators_stream.py

スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは uv が自動的に仮想環境へ導入します。事前の pip install は不要です。

解説

何をするプログラムか

EC サイトなどの Web サーバが残すアクセスログは、1 日で数百万行・数 GB に達することも珍しくありません。これを丸ごとリストに読み込むとメモリが足りなくなりますが、集計に必要なのは「1 行ずつ読んで捨てる」処理だけです。本スクリプトは 30 万行(約 10.6 MB)の模擬アクセスログを生成し、ジェネレータをつないだパイプラインで、エラー件数の集計・ページ別の応答時間統計・複利計算までを、ファイル全体をメモリに載せずに実行します。

コードの読みどころ

理論的背景

リストは全要素を同時にメモリへ保持するため、メモリ使用量は入力サイズに比例して O(n) で増えます。一方ジェネレータは「現在の状態と次の 1 要素の作り方」だけを保持する遅延評価の仕組みなので、入力が何行あってもメモリ使用量は O(1) のまま一定です。合計・件数・最大値のように 1 パスで求まる統計量なら、この方式でどれほど大きなデータでも処理できます。

実行結果の見方

[1] のオブジェクトサイズの差(8,448,728 バイト対 200 バイト)が、リストとジェネレータの違いを端的に示します。[2] では 30 万行から 500 エラー 8,948 件を抽出し、[5] では年利 3% で元本が 2 倍になるのが 24 年目と出ますが、これは「72 の法則」(72 ÷ 3 = 24 年)と一致します。[6] は同じジェネレータを 2 回 list() すると 2 回目が空になる「使い切り」の性質で、初学者が最もつまずきやすい点です。

ソースコード

# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""26: ジェネレータとイテレータ -------------------------------------------
テーマ: 大規模ログをメモリに載せずに集計するストリーム処理
学習点: yield, ジェネレータ式, itertools, パイプライン設計, メモリ使用量の比較
根拠: リストは全要素をメモリに保持するが、ジェネレータは1要素ずつ
      遅延評価するため、メモリ使用量が入力サイズに依存しない(O(1))。
"""
import itertools
import random
import sys
from collections import Counter
from pathlib import Path

random.seed(42)
OUT = Path("out_26")
OUT.mkdir(exist_ok=True)
LOG = OUT / "access.log"

PAGES = ["/", "/products", "/products/detail", "/cart", "/checkout",
         "/login", "/api/search", "/help"]
STATUS = [200] * 88 + [301] * 4 + [404] * 5 + [500] * 3


def make_log(path: Path, n: int = 300_000) -> None:
    with path.open("w") as f:
        for i in range(n):
            uid = random.randrange(1, 8000)
            f.write(f'{1735689600 + i * 2} u{uid:05d} '
                    f'{random.choice(PAGES)} {random.choice(STATUS)} '
                    f'{random.randint(20, 3000)}\n')


# --- ジェネレータのパイプライン ---
def read_lines(path: Path):
    """1行ずつ読み出す(ファイル全体をメモリに載せない)。"""
    with path.open() as f:
        yield from f


def parse(lines):
    for line in lines:
        ts, uid, page, status, ms = line.split()
        yield {"ts": int(ts), "uid": uid, "page": page,
               "status": int(status), "ms": int(ms)}


def where(records, **cond):
    for r in records:
        if all(r[k] == v for k, v in cond.items()):
            yield r


def pluck(records, key):
    return (r[key] for r in records)


def main() -> None:
    make_log(LOG)
    print(f"ログ生成: {LOG} ({LOG.stat().st_size / 1e6:.1f} MB, "
          f"{sum(1 for _ in read_lines(LOG)):,} 行)\n")

    print("[1] メモリ効率の比較")
    lst = [x ** 2 for x in range(1_000_000)]          # リスト内包表記
    gen = (x ** 2 for x in range(1_000_000))          # ジェネレータ式
    print(f"  リスト内包表記のオブジェクト: {sys.getsizeof(lst):>10,} bytes")
    print(f"  ジェネレータ式のオブジェクト: {sys.getsizeof(gen):>10,} bytes")
    print("  ※ 合計値だけ欲しいなら sum(gen) でよく、リスト化は不要。\n")
    del lst

    print("[2] パイプライン集計 — エラー(5xx)応答の分析")
    errs = list(where(parse(read_lines(LOG)), status=500))
    print(f"  500エラー: {len(errs):,} 件")
    print(f"  発生ページ上位: {Counter(r['page'] for r in errs).most_common(3)}")

    print("\n[3] ページ別の応答時間統計(1パスで集計)")
    agg = {}
    for r in parse(read_lines(LOG)):
        s = agg.setdefault(r["page"], [0, 0, 0, 10**9])
        s[0] += 1
        s[1] += r["ms"]
        s[2] = max(s[2], r["ms"])
        s[3] = min(s[3], r["ms"])
    print(f"  {'ページ':<20}{'件数':>9}{'平均ms':>9}{'最大':>8}{'最小':>8}")
    for page, (c, tot, mx, mn) in sorted(agg.items(), key=lambda kv: -kv[1][0]):
        print(f"  {page:<20}{c:>9,}{tot/c:>9.1f}{mx:>8}{mn:>8}")

    print("\n[4] itertools の活用")
    first10 = list(itertools.islice(parse(read_lines(LOG)), 3))
    print(f"  islice で先頭3件だけ取得: {[r['page'] for r in first10]}")

    # groupby は「連続する」同じキーをまとめる(事前ソートが前提)
    seq = "AAABBBCCAAB"
    print(f"  groupby('{seq}') -> "
          f"{[(k, len(list(g))) for k, g in itertools.groupby(seq)]}")
    print(f"  accumulate 累積和: "
          f"{list(itertools.accumulate([10, 20, 30, 40]))}")
    print(f"  pairwise 隣接ペア: "
          f"{list(itertools.pairwise([1, 4, 9, 16]))}")
    print(f"  chain 連結: {list(itertools.chain([1, 2], [3], [4, 5]))}")
    print(f"  combinations 組合せ: "
          f"{list(itertools.combinations('ABCD', 2))[:4]} ...")

    print("\n[5] 無限ジェネレータと遅延評価 — 複利で元本が2倍になる年")
    def compound_stream(pv, r):
        while True:
            yield pv
            pv *= 1 + r
    doubling = next(i for i, v in enumerate(compound_stream(100, 0.03))
                    if v >= 200)
    print(f"  年利3%: {doubling} 年目に2倍を超える")

    print("\n[6] ジェネレータの状態と使い切り")
    g = (i for i in range(3))
    print(f"  1回目の list(): {list(g)}")
    print(f"  2回目の list(): {list(g)}  <- 使い切ると空になる")
    print("  ※ 再利用したい場合はリスト化するか、ジェネレータ関数を呼び直す。")


if __name__ == "__main__":
    main()

実行結果

ログ生成: out_26/access.log (10.6 MB, 300,000 行)

[1] メモリ効率の比較
  リスト内包表記のオブジェクト:  8,448,728 bytes
  ジェネレータ式のオブジェクト:        200 bytes
  ※ 合計値だけ欲しいなら sum(gen) でよく、リスト化は不要。

[2] パイプライン集計 — エラー(5xx)応答の分析
  500エラー: 8,948 件
  発生ページ上位: [('/checkout', 1206), ('/help', 1161), ('/', 1130)]

[3] ページ別の応答時間統計(1パスで集計)
  ページ                        件数     平均ms      最大      最小
  /api/search            37,820   1508.7    3000      20
  /                      37,800   1509.5    3000      20
  /login                 37,652   1512.7    3000      20
  /checkout              37,602   1512.6    3000      20
  /products              37,548   1519.7    3000      20
  /cart                  37,222   1506.6    3000      20
  /help                  37,199   1517.2    3000      20
  /products/detail       37,157   1502.1    3000      20

[4] itertools の活用
  islice で先頭3件だけ取得: ['/products', '/cart', '/products']
  groupby('AAABBBCCAAB') -> [('A', 3), ('B', 3), ('C', 2), ('A', 2), ('B', 1)]
  accumulate 累積和: [10, 30, 60, 100]
  pairwise 隣接ペア: [(1, 4), (4, 9), (9, 16)]
  chain 連結: [1, 2, 3, 4, 5]
  combinations 組合せ: [('A', 'B'), ('A', 'C'), ('A', 'D'), ('B', 'C')] ...

[5] 無限ジェネレータと遅延評価 — 複利で元本が2倍になる年
  年利3%: 24 年目に2倍を超える

[6] ジェネレータの状態と使い切り
  1回目の list(): [0, 1, 2]
  2回目の list(): []  <- 使い切ると空になる
  ※ 再利用したい場合はリスト化するか、ジェネレータ関数を呼び直す。

← 目次← 前: 25次: 27 →