Pythonのジェネレータ:yield、イテレータ、リスト・辞書の内包表記
Pythonのジェネレータは、値を一度に作らず、要求されるたびに1つずつ返すオブジェクトです。yield を使う関数、または丸かっこで囲んだジェネレータ式で作ります。リスト・辞書・集合の内包表記は、コレクションを1行で作る短い書き方です。例:[x * 2 for x in xs]、{k: v for k, v in pairs}、{x for x in xs}。
Pythonのジェネレータは、値を1つずつ、必要になったときにだけ返すオブジェクトです。すべての列をメモリに保持することはありません。作り方は2つあります。yield を使う関数と、丸かっこで囲んだジェネレータ式です。近い関係にあるのが、リスト、辞書、集合の内包表記です。これは完成したコレクションを1行で組み立てる短い書き方です。ここでは、yield の仕組み、ジェネレータとイテレータの違い、内包表記で読みやすさを保つ方法を見ていきます。
イテレータ:for文の土台
ジェネレータを理解するには、まずイテレータを知る必要があります。反復可能オブジェクト(iterable)は、for文で回せるもの、つまりリスト、文字列、辞書、ファイルなどです。イテレータは、現在の位置を覚えていて、next() を呼ぶたびに次の要素を返すオブジェクトです。要素がなくなると StopIteration を送出します。
nums = [10, 20, 30]
it = iter(nums) # リストからイテレータを取得
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
next(it) # StopIteration
for文は、まさにこれを行っています。iter() を呼び出し、StopIteration が出るまで next() を繰り返します。自分でイテレータを作るには、__iter__ と __next__ のメソッドを持つクラスを書きます。
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
print(list(Countdown(3))) # [3, 2, 1]
これで動きますが、定型的なコードが多くなります。ジェネレータなら、同じことを3行で書けます。
Pythonのジェネレータとは:yieldの仕組み
ジェネレータ関数は、本体の中に yield がある通常の関数です。呼び出しても本体は実行されず、ジェネレータオブジェクトが返されます。next() を呼ぶたびに、本体は次の yield まで実行され、その値を返して、ローカル変数ごと一時停止します。次の next() は、まさにその位置から再開します。
def countdown(start):
while start > 0:
yield start
start -= 1
gen = countdown(3)
print(gen) # <generator object countdown at 0x...>
print(next(gen)) # 3
print(list(gen)) # [2, 1] — 止まった位置から再開
関数が最後まで進む(または return に達する)と、ジェネレータは自動的に StopIteration を送出し、for文は正常に終わります。ここで yield と return の大きな違いが分かります。return は関数を終了させ、1つの値を返します。yield は関数を一時停止し、何度でも値を返せます。
ジェネレータとイテレータの違い
ジェネレータは、すべてイテレータです。__iter__ と __next__ を持っています。しかし、イテレータがすべてジェネレータとは限りません。違いは作り方と機能にあります。
| イテレータ(クラス) | ジェネレータ | |
|---|---|---|
| 作り方 | __iter__ と __next__ を持つクラス | yield を使う関数、または (x for x in ...) |
| 状態 | 自分で属性に保存する | ローカル変数に自動的に保存される |
| StopIteration | 自分で送出する | 関数が終わると自動的に出る |
| 追加機能 | 自分で書いたもの | send()、throw()、close() |
どちらにも共通する重要な性質があります。一度しか回せないということです。使い終わったジェネレータをもう一度 for文で回しても、何も出てきません。新しく作り直す必要があります。
無限ジェネレータと大きなファイルの読み込み
ジェネレータが終わる必要はありません。無限の列も、必要な数だけ取り出す限りは安全です。
from itertools import islice
def ids():
n = 1
while True:
yield f"ID-{n:04d}"
n += 1
print(list(islice(ids(), 3))) # ['ID-0001', 'ID-0002', 'ID-0003']
最も実用的な例は、大きなファイルを1行ずつ処理することです。ジェネレータはパイプラインのようにつなげられます。どの時点でも、メモリに保持されるのは1行だけで、ファイル全体は読み込まれません。
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
def only_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
for line in only_errors(read_lines("app.log")):
print(line)
yield fromとsend
yield from は、別の反復可能オブジェクトの値をすべて渡します。入れ子のループを書かずに済みます。
def flatten(groups):
for g in groups:
yield from g
print(list(flatten([[1, 2], [3], [4, 5]]))) # [1, 2, 3, 4, 5]
send() は、ジェネレータの内側に値を送ります。その値は yield 式の結果になります。最初の send の前には、next() でジェネレータを開始しておく必要があります。例えば、平均値を計算する次のようなコードです。
def averager():
total, count = 0, 0
avg = None
while True:
value = yield avg
total += value
count += 1
avg = total / count
a = averager()
next(a) # 最初の yield まで進める
print(a.send(10)) # 10.0
print(a.send(20)) # 15.0
実際のコードで send を使うことは少ないですが、面接でよく聞かれる内容です。
rangeはジェネレータか?
いいえ。range は遅延評価される列です。数字を全部保持はしませんが、インデックス、len()、in による確認、何度でも回すことができます。ジェネレータはそのどれもできません。
r = range(5)
print(r[2], len(r), 3 in r) # 2 5 True
print(list(r), list(r)) # どちらも [0, 1, 2, 3, 4]
リスト内包表記
リスト内包表記(list comprehension)は、[式 for x in コレクション] の形で書きます。新しいリストを作り、元のリストは変更しません。左から右へ読むと分かりやすく、まず「何を入れるか」、次に「どこから取るか」を表しています。
amounts = [100, 250, 400]
# 通常のループ
with_markup = []
for a in amounts:
with_markup.append(a * 1.2)
# 内包表記で同じことを書く
with_markup = [a * 1.2 for a in amounts]
print(with_markup) # [120.0, 300.0, 480.0]
「リスト内包表記」という名前ですが、結果は完成したリストであり、ジェネレータではありません。すべての要素はその場で計算されます。
内包表記のifとif else
ここで最も混乱しやすいのです。フィルタとしての条件は末尾に置き、要素を捨てます。選択としての条件(三項演算子)は先頭に置き、各要素を2つの値のどちらかに変えます。要素は捨てられません。
amounts = [100, 250, 400]
big = [a for a in amounts if a > 200] # [250, 400]
labels = ["多い" if a > 200 else "少ない" for a in amounts]
# ['少ない', '多い', '多い']
# フィルタと選択を組み合わせる
odd_marks = ["偶数" if n % 2 == 0 else "奇数" for n in range(6) if n > 2]
# ['奇数', '偶数', '奇数']
[a for a in amounts if a > 200 else 0] のような書き方は文法エラーです。else は、先頭にある条件式でのみ使えます。
入れ子の内包表記と二次元リスト
二重の for は、入れ子のループと同じ順序で、上から下へ書きます。リストのリストをつなげるときに、順番を間違えないための目安になります。
groups = [[1, 2], [3], [4, 5]]
flat = [x for g in groups for x in g]
# 以下と同じ:
# for g in groups:
# for x in g:
# flat.append(x)
print(flat) # [1, 2, 3, 4, 5]
二次元リスト(行列)は、内包表記の中に内包表記を書いて作ります。よくあるミスは、リストの掛け算を使うことです。その場合、各行が同じオブジェクトになってしまいます。
bad = [[0] * 3] * 2
bad[0][0] = 1
print(bad) # [[1, 0, 0], [1, 0, 0]] — 両方の行が変わってしまう
grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
print(grid) # [[1, 0, 0], [0, 0, 0]]
# 転置
m = [[1, 2, 3], [4, 5, 6]]
print([[row[i] for row in m] for i in range(3)]) # [[1, 4], [2, 5], [3, 6]]
辞書と集合の内包表記
同じ仕組みが、ほかのコレクションにも使えます。波かっこでコロンを使うと辞書(dict comprehension)、コロンを使わないと集合(set comprehension)になります。
spent = [("食費", 500), ("タクシー", 300), ("食費", 200)]
categories = {cat for cat, _ in spent}
print(categories) # {'食費', 'タクシー'} — 集合の順序は保証されない
last = {cat: amount for cat, amount in spent}
print(last) # {'食費': 200, 'タクシー': 300}
squares = {n: n ** 2 for n in range(4)} # {0: 0, 1: 1, 2: 4, 3: 9}
swapped = {v: k for k, v in squares.items()}
last の例に注目してください。キーが重複すると最後の値が残るため、500 円の合計は失われます。合計したいなら、内包表記は向いていません。ループを書くか、collections.Counter や defaultdict を使いましょう。
タプルの内包表記は?
Pythonには「タプルの内包表記」はありません。丸かっこは、ジェネレータ式になります。タプルを得るには、それを tuple() に渡します。
t = tuple(n * 2 for n in range(3)) # (0, 2, 4)
ジェネレータ式:保持せず、順に渡す
角かっこを丸かっこに変えると、ジェネレータ式になります。事前に計算はせず、要素を1つずつ返します。100万要素ならメモリの差は非常に大きくなります。
import sys
as_list = [n for n in range(1_000_000)]
as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list)) # 約8MB
print(sys.getsizeof(as_gen)) # 数百バイト
ジェネレータ式は、多くの場合、集計関数の中で使われます。ジェネレータが唯一の引数であれば、丸かっこを二重に書く必要はありません。any() と all() は、最初に結論が出た時点で処理を止め、残りを調べません。
spent = [("食費", 500), ("タクシー", 300), ("食費", 200)]
total = sum(a for _, a in spent) # 1000
has_big = any(a > 400 for _, a in spent) # True、500 の時点で終了
biggest = max(spent, key=lambda p: p[1]) # ('食費', 500)
選び方は簡単です。結果を何度も使う、インデックスや len() が必要であれば、リストを使います。一度だけ回して何かを計算すればよいなら、ジェネレータを使います。
スコープ:変数は外に漏れない
経験者でもつまずく問題です。
x = 10
squares = [x for x in range(3)]
print(x) # 10
内包表記の中のループ変数は、独自のスコープに存在し、外側の変数を上書きしません(Python 3の挙動です。Python 2では違いました)。通常のforループであれば、ループの後の x は2になります。Python 3.12(PEP 709)からは、内包表記は囲んでいる関数に組み込まれ、動作が速くなりましたが、スコープの規則は変わっていません。
Python 3.15の新機能:内包表記での * と ** のアンパック(PEP 798)
関数呼び出しやリストのリテラルでは、アスタリスクによるアンパックが以前から使えました。しかし内包表記の中ではこれが SyntaxError でした。Python 3.15 でこの制限がなくなりました。*要素 は「この要素のすべてをここに入れる」という意味になります。リスト、集合、ジェネレータで使え、非同期のものも含まれます。
# Python 3.15以降
groups = [[1, 2], [3], [4, 5]]
flat = [*g for g in groups] # [1, 2, 3, 4, 5]
uniq = {*g for g in groups} # 集合
lazy = (*g for g in groups) # 遅延評価のジェネレータ
二重のアスタリスクは、辞書に対して同じことをします。「デフォルト設定 → ファイル → フラグ」のような多段階の設定で役立ちます。後の層が、前の層のキーを上書きします。
# Python 3.15以降
layers = [{"debug": False, "port": 80}, {"port": 8080}, {"debug": True}]
config = {**layer for layer in layers}
# {'debug': True, 'port': 8080}
講座で扱った注意点が2つあります。1つ目は、条件式を丸かっこなしで使うとアスタリスクと組み合わせられないことです。[*w if w else 0 for w in weeks] は文法エラーになります。条件は丸かっこで囲みましょう。2つ目は、このコードは3.14以前では動かないことです。古いバージョンをサポートするライブラリでは、二重のforを使うか、itertools.chain.from_iterable を使ってください。
非同期ジェネレータ
async def の関数に yield があると、非同期ジェネレータになります。async for で回せ、内部では await でネットワークやディスクの処理を待てます。非同期の内包表記もあります。
import asyncio
async def ticks(n):
for i in range(n):
await asyncio.sleep(0.1)
yield i
async def main():
result = [i async for i in ticks(3)]
print(result) # [0, 1, 2]
asyncio.run(main())
よくあるミス
- 3階層の内包表記。 for が2つ、if が2つ、三項演算子が1つ、それが1行に入っていると、1週間後には誰も読めません。1行に収まらないなら普通のループを書きましょう。PEP 8 も簡潔さより読みやすさを優先しています。
- ジェネレータを2回回す。 2回目の
sum(gen)は、ジェネレータが使い切られているため 0 を返します。 - 副作用のために内包表記を使う。
[print(x) for x in xs]は、Noneだけのリストを無駄に作ります。処理には for ループを使いましょう。 - ジェネレータ式の要素を取り出そうとする。
(x for x in xs)[0]はTypeErrorになります。ジェネレータにはインデックスがありません。
練習問題
- フィボナッチ数を無限に返す
fib()ジェネレータを書き、isliceで最初の10個を取り出す。 - 3文字より長い単語について、「単語 → 文字数」の辞書を1行で作る。
- 100万までの偶数の2乗の合計を、リストを作らずに求める。
- リストを
n個ずつの塊に分けて返すchunks(seq, n)ジェネレータを書く。
まとめ
イテレータは、next() で要素を1つずつ返します。ジェネレータは、それを最も簡単に書く方法です。yield を使う関数か、丸かっこの式で作れます。リスト、辞書、集合の内包表記は、コレクションを1行で組み立てます。フィルタの if は末尾、選択の if else は先頭に置きます。メモリを節約したい、データの流れを一度だけ処理したい場合はジェネレータを、結果を何度も使う場合はリストを選びましょう。内包表記、ジェネレータ、Python 3.15の新しいアンパックは、家計簿ツールの例を使った講座の実践で扱っています。
よくある質問
ジェネレータの中で return を使えますか?
はい。値を伴わない return はジェネレータを終了させるだけです。値を伴う return は、StopIteration 例外の value 属性に入ります。それを受け取るのは yield from 式です。
ジェネレータをリストに変換するにはどうしますか?
list() に渡します。例:list(gen)。ただし、その後ジェネレータは使い切られ、すべての値がメモリに載ります。
リスト内包表記と append を使うループでは、どちらが速いですか?
通常は内包表記の方が少し速いです。毎回 append メソッドを呼び出さないためです。ただし、差はわずかなことが多いので、選ぶ基準は読みやすさにしましょう。
オブジェクトがジェネレータかどうか、どうやって確かめますか?
オブジェクトなら inspect.isgenerator(obj)、関数なら inspect.isgeneratorfunction(f) を使います。イテレータかどうかは isinstance(obj, collections.abc.Iterator) で確認できます。
ジェネレータ式とリスト内包表記の違いは何ですか?
角かっこは、すべての要素をすぐにメモリ上のリストとして作ります。丸かっこは、要求されたときに要素を計算するジェネレータを作ります。一度しか回せません。