Python のリスト内包表記 vs ジェネレータ式のパフォーマンス

概要

Python でコレクションを組み立てるとき、リスト内包表記(list comprehension)とジェネレータ式(generator expression)はどちらも似た書き方で使えます。見た目の違いは [] と () だけですが、裏側の動作とメモリの使い方はまったく別物です。この記事では入門者向けに、両者の違い・パフォーマンス特性・使い分けを、計測コード付きで整理します。

リスト内包表記とジェネレータ式の違い

リスト内包表記とジェネレータ式は、評価のタイミングが根本的に異なります。

リスト内包表記 [...] ジェネレータ式 (...)
評価 即時(eager) 遅延(lazy)
戻り値 list generator
メモリ 全要素を保持 1 要素ずつ生成
再利用 何度でも走査可能 1 回で使い切り
添字アクセス result[3] 可 不可
len() 可 不可

リスト内包表記は定義した瞬間に全要素を計算してリストに格納します。一方ジェネレータ式は「次の 1 要素をどう作るか」という手順だけを持ち、next() が呼ばれるたびに 1 件ずつ計算して返します。

問題: 使わない中間リストにメモリを使っている

次のような「数百万件のデータを 1 回だけ回して合計する」処理を考えます。

total = sum([x * x for x in range(10_000_000)])

これは動きますが、[x * x for x in range(10_000_000)] の部分で 1000 万要素のリストをメモリ上に丸ごと作ってから sum() に渡しています。使うのは合計値だけなのに、中間リストのためだけに大量のメモリを確保してしまうのが問題です。

対応: [] を () に変える

全要素をメモリに残す必要がない処理(合計・最大・件数・そのまま 1 回ループするだけ、など)では、[] を () に変えるだけでメモリ消費を劇的に減らせます。

# メモリを食う書き方
total = sum([x * x for x in range(10_000_000)])

# 1 要素ずつ流す書き方(中間リストを作らない)
total = sum(x * x for x in range(10_000_000))

sum() のように引数が関数呼び出しの唯一の引数である場合、ジェネレータ式を囲む括弧は省略できます。

メモリ使用量を計測する

import sys

list_comp = [x * x for x in range(1000)]
gen_expr = (x * x for x in range(1000))

print(sys.getsizeof(list_comp))  # 例: 8856 (要素数に比例して増える)
print(sys.getsizeof(gen_expr))   # 例: 192  (要素数に関係なくほぼ一定)

リストは要素数に比例してサイズが増えますが、ジェネレータは「生成器オブジェクト 1 個分」のサイズしか持ちません。より実際のピークメモリを見るなら tracemalloc が便利です。

import tracemalloc

def with_list():
    return sum([x * x for x in range(1_000_000)])

def with_gen():
    return sum(x * x for x in range(1_000_000))

for label, fn in [("list", with_list), ("gen", with_gen)]:
    tracemalloc.start()
    fn()
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    print(f"{label}: peak = {peak / 1024 / 1024:.1f} MiB")

# list: peak = 約 35 MiB 前後
# gen:  peak = 1 MiB 未満

実行速度を計測する

メモリでは圧倒的にジェネレータ有利ですが、「全要素を必ず使う」場合の速度はまた別の話です。

import timeit

setup = "data = range(1_000_000)"

t_list = timeit.timeit("sum([x * x for x in data])", setup=setup, number=10)
t_gen  = timeit.timeit("sum(x * x for x in data)",   setup=setup, number=10)

print(f"list comp: {t_list:.3f}s")
print(f"gen expr : {t_gen:.3f}s")

環境により差はありますが、全件を最後まで消費する場合はリスト内包表記の方がわずかに速いことが多いです。ジェネレータは next() 呼び出しのオーバーヘッドが 1 要素ごとに乗るためです。

逆に途中で打ち切る処理ではジェネレータが圧勝します。

# 条件を満たす最初の 1 件だけ欲しい
data = range(10_000_000)

# リスト版: 1000 万件を全部作ってから探す → 遅い
first = next(x for x in [v for v in data if v > 5] if x)

# ジェネレータ版: 6 件目で止まる → 一瞬
first = next(x for x in data if x > 5)

ハマりどころ: ジェネレータは 1 回で使い切る

gen = (x for x in range(3))
print(list(gen))  # [0, 1, 2]
print(list(gen))  # []  ← 2 回目は空になる

「1 回目は動いたのに 2 回目で結果が消える」というバグはこれが原因です。複数回使うならリストに変換しておきます。

使い分けチェックリスト

  • この結果を 2 回以上ループするか? → Yes ならリスト
  • 添字 result[i] や len() が必要か? → Yes ならリスト
  • 合計・最大・件数など 1 回集計するだけか? → ジェネレータ
  • 途中で break / 早期 return したいか? → ジェネレータ
  • データが巨大でメモリが心配か? → ジェネレータ
  • 数件〜数百件の小さいデータか? → 差は誤差。読みやすい方で OK

まとめ

  • [] は即時評価で全要素をメモリに持つ。() は遅延評価で 1 件ずつ生成する。
  • 全件消費するだけならリストがわずかに速い。途中打ち切り・巨大データ・メモリ節約ならジェネレータ。
  • 迷ったら「この結果を 2 回以上使うか? 添字でアクセスするか?」を自問する。No ならジェネレータ式が第一候補。

「[] を () に変えるだけ」で済む場面は意外と多いので、sum() / any() / max() などに渡すときは一度見直してみてください。早すぎる最適化は禁物なので、計測してボトルネックだと分かってから置き換えるのが順序です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。