イテラブル、ジェネレータ、yield from
for がどう動作するかを扱います。イテラブルプロトコル、ジェネレータ関数と式、yield from による委譲、send/throw までを整理します。
第4章 コレクションと内包表記 の最後に少しだけ見たジェネレータ式 (x for x in iter) が本章のテーマです。for がどう動作するか から始めて、ユーザー定義のイテラブル、ジェネレータ関数、yield from までを扱います。
本章のジェネレータは2ヶ所で再び出会います。第一に、第10章 コンテキストマネージャ の @contextmanager が実はジェネレータの上に作られた道具です。本章の最後にその正体を解きます。第二に、第14章 非同期入門 (asyncio) の async def / await が結局は同じ一時停止 / 再開モデルの上にあります。
for in の正体 — イテラブルプロトコル
#
for x in [1, 2, 3]:
print(x)この一行が内部で何をしているのかを解いてみると:
items = [1, 2, 3]
it = iter(items) # 1) イテラブル → イテレータ
while True:
try:
x = next(it) # 2) 次の値を要求
except StopIteration:
break # 3) 終わったら止まる
print(x)中心となる2つのステップ:
iter(obj)— イテラブルからイテレータを得る (__iter__呼び出し)next(it)— 次の値を要求 (__next__呼び出し)、終わったらStopIterationを投げる
イテラブル vs イテレータ #
用語が紛らわしいので整理。
| 定義 | メソッド | 例 | |
|---|---|---|---|
| イテラブル (Iterable) | iter() ができるすべて | __iter__ | list、dict、str、range、ファイル、ジェネレータ |
| イテレータ (Iterator) | 「次の値」を返せるもの | __next__(と __iter__) | iter([1,2,3]) の結果、ジェネレータ |
すべてのイテレータはイテラブル です(自身を返す __iter__ があります)。逆は違います — list はイテラブルですがイテレータではありません。next(my_list) はエラーになります。
ユーザー定義のイテラブル — クラスで #
class MyRange:
def __init__(self, start: int, stop: int):
self.start = start
self.stop = stop
def __iter__(self):
return MyRangeIterator(self.start, self.stop)
class MyRangeIterator:
def __init__(self, current: int, stop: int):
self.current = current
self.stop = stop
def __iter__(self):
return self
def __next__(self):
if self.current >= self.stop:
raise StopIteration
value = self.current
self.current += 1
return value
for x in MyRange(0, 3):
print(x)
# 0, 1, 22つのクラス — イテラブルとイテレータを分離します。イテラブルは 複数回反復可能 ですが、イテレータは一度使い切ると終わりです。
r = MyRange(0, 3)
list(r) # [0, 1, 2]
list(r) # [0, 1, 2] ← 毎回新しいイテレータジェネレータ関数 — 同じことを一つの関数で #
上のコードの2つのクラスを yield が入った関数1つ にまとめられます。
def my_range(start: int, stop: int):
current = start
while current < stop:
yield current
current += 1
for x in my_range(0, 3):
print(x)
# 0, 1, 2関数本体に yield が一度でも入ると、その関数は呼び出し時に 通常の値を返すのではなくジェネレータオブジェクトを返します。上のクラス2つと同じ仕事をします。
yield の動き方
#
最も紛らわしい部分がこれです。
def gen():
print("step 1")
yield 1
print("step 2")
yield 2
print("step 3")
g = gen()
# ここまでは関数本体はまだ実行されない!g = gen() だけでは 関数本体は実行されません。 最初の next(g) が来てから始まります。
print(next(g))
# step 1
# 1
print(next(g))
# step 2
# 2
print(next(g))
# step 3
# StopIteration ← yield がもうなければ各 yield で関数が 一時停止 します。次の next() の呼び出しが来ると、その地点から再開します。関数が実行される時間が呼び出し側とインターリーブ されるのがジェネレータの核心です。同じ一時停止 / 再開モデルが第14章 非同期入門 (asyncio) の await で再登場します。
ジェネレータ式とどう違うか #
第4章 で見た (x for x in iter) と同じ動作をしますが、関数の形の方が表現力が大きいです。
# 一行で表現可能 — 式が合う
squares = (x ** 2 for x in range(10))
# 複雑なロジック — 関数が合う
def squares_evens_only():
for x in range(10):
if x % 2 != 0:
continue
yield x ** 2遅延評価の価値 — メモリと速度 #
ジェネレータの最大の価値は すべての値を一度に作らない という点です。
# リスト内包表記 — 100 万個を即時生成、メモリを使い切る
squares_list = [x ** 2 for x in range(1_000_000)]
# ジェネレータ — 必要なときだけ生成、メモリはほとんど使わない
squares_gen = (x ** 2 for x in range(1_000_000))
total = sum(squares_gen) # 一度の反復で終わり無限シーケンスも可能 #
def counter(start: int = 0):
n = start
while True:
yield n
n += 1
# 最初の 5 個だけ使う
from itertools import islice
first_five = list(islice(counter(), 5))
print(first_five) # [0, 1, 2, 3, 4]リストでは不可能なケースです。ジェネレータは 要求された分だけ 値を作るので、無限でも問題ありません。
パイプライン — ジェネレータをつなげる #
各段階がジェネレータのデータ処理パイプラインを作ると、メモリ効率がよく意図が明確になります。
def read_lines(path: str):
with open(path) as f:
for line in f:
yield line.rstrip()
def filter_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
def parse_timestamp(lines):
for line in lines:
ts, _, msg = line.partition(" ")
yield (ts, msg)
# 組み合わせて使う
errors = parse_timestamp(filter_errors(read_lines("app.log")))
for ts, msg in errors:
print(ts, msg)各段階が 一行ずつだけ処理 します。100GB のファイルでもメモリに全部は載せません。
yield from — ジェネレータの委譲
#
他のイテラブルの値をそのまま流したいとき。
def chain_two(a, b):
for x in a:
yield x
for y in b:
yield ydef chain_two(a, b):
yield from a
yield from b同じ仕事をしますが yield from の方が短く、その他にも2つの追加の効用があります:
- send / throw が自動的に委譲される(下で扱う)
- 下位ジェネレータの戻り値を受け取れる
木 / 再帰の走査に自然 #
def flatten(items):
for item in items:
if isinstance(item, list):
yield from flatten(item)
else:
yield item
result = list(flatten([1, [2, [3, [4]], 5]]))
print(result) # [1, 2, 3, 4, 5]yield from flatten(...) 一行が再帰を自然に解いてくれます。
send、throw、close — コルーチン機能
#
ジェネレータは 値を受け取ることもできます。 yield の結果を変数に受け取れば、外部から send で値を送れます。
def echo():
while True:
received = yield
print(f"受信: {received}")
g = echo()
next(g) # 最初の yield まで進む (priming)
g.send("hello") # 受信: hello
g.send("world") # 受信: worldこのメカニズムの上に非同期(第14章 非同期入門)と協調マルチタスキングが作られています。ただし通常のコードで send を直接扱うことはほとんどありません。概念だけ認識 しておけばよいです。
g.throw(ValueError, "例外を注入") # ジェネレータの中で raise と同じ
g.close() # ジェネレータ終了 (GeneratorExit を投げる)close() はよく使われます — リソース整理が必要なジェネレータで try/finally に整理コードを入れれば、close の時点で実行されます。
def read_lines(path):
f = open(path)
try:
for line in f:
yield line
finally:
f.close()このコードはジェネレータを最後まで回さなくても (break で抜けても) ガベージコレクションの時点で close() が呼ばれ、ファイルが閉じられます。
itertools — 標準ライブラリの宝石
#
データパイプラインでよく使う道具が itertools にあります。
from itertools import (
count, cycle, repeat, # 無限
islice, # スライス
chain, # 連結
groupby, # グルーピング
accumulate, # 累積
combinations, permutations, product, # 組み合わせ
starmap, filterfalse, dropwhile, takewhile, # 変換 / フィルタ
)
# 最初の N 個
list(islice(count(), 5)) # [0, 1, 2, 3, 4]
# 複数のイテラブルをつなげる
list(chain([1, 2], [3, 4])) # [1, 2, 3, 4]
# 累積和
list(accumulate([1, 2, 3, 4])) # [1, 3, 6, 10]
# グルーピング (ソートが必要)
data = [("a", 1), ("a", 2), ("b", 3)]
for key, group in groupby(data, key=lambda x: x[0]):
print(key, list(group))
# a [('a', 1), ('a', 2)]
# b [('b', 3)]データパイプラインを組む機会が多いなら一度見ておけば一生役立ちます。
標準ライブラリのコレクションも同じプロトコル #
collections.abc の ABC は このプロトコルを形式化したもの です。第9章 typing 本格 で見た Protocol は、標準ライブラリではこのように使われています。
from collections.abc import Iterable, Iterator
def consume(items: Iterable[int]) -> int:
total = 0
for x in items:
total += x
return total
# list、tuple、set、generator、range、... すべて通過
consume([1, 2, 3])
consume(range(10))
consume(x for x in [1, 2, 3])関数が受け取る型を Iterable[T] で書けば最も広く安全です。あえて list[T] に狭める必要はありません — 呼び出し側がジェネレータを渡してもセットを渡しても同じように動作します。
@contextmanager は実はジェネレータ
#
第10章 の @contextmanager がどう動作するか、これで分かります。
from contextlib import contextmanager
@contextmanager
def chdir(path):
old = os.getcwd()
os.chdir(path)
try:
yield path
finally:
os.chdir(old)この関数は yield を持つので ジェネレータ関数 です。@contextmanager がそのジェネレータを受け取って __enter__ が最初の next を呼び出し、__exit__ が2回目の next または throw を呼び出す オブジェクトに包みます。コンテキストマネージャがジェネレータの上に作られた道具であることがここで確認できます。
練習問題 #
def fibonacci() -> Iterator[int]:のシグネチャを持つ無限ジェネレータを書いてください。最初の2値 0、1 から始めて、毎回直前の2値の和を yield します。from itertools import islice後list(islice(fibonacci(), 10))が[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]になるかを確認します。def read_log_errors(path: str) -> Iterator[str]:を書いてください。ファイルを開いて1行ずつ読みながら “ERROR” を含む行だけを yield します。ファイルの close が保証されるように関数の中でwith open(...)を使ってください。100GB の擬似ファイルでもメモリ使用量が一定であることを意識して書きます。yield fromでdef flatten(items)を再帰で書いてください。flatten([1, [2, [3, [4]], 5], 6])が[1, 2, 3, 4, 5, 6]に平坦化されることを確認します。一方でyield fromなしにfor ... yield ...で解いた版も書いて、2つのコードの行数を比較します。
一行まとめ:
forはiter()+next()+StopIterationのシュガー。イテラブル (__iter__) ⊃ イテレータ (__iter__+__next__)。yieldが一度でもあればジェネレータ関数になり、yieldのたびに一時停止。遅延評価がメモリ / 無限シーケンス / パイプラインを可能にする。yield fromは委譲と再帰に自然。関数引数の型はIterable[T]で広く。@contextmanagerはジェネレータの上のシュガー。
次の章 #
次の 第12章 デコレータパターン では、関数とクラスを包む道具 — デコレータ のすべてのパターンを扱います。@contextmanager、@dataclass もその一形態でした。