目次
11 章

イテラブル、ジェネレータ、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つのステップ:

  1. iter(obj) — イテラブルからイテレータを得る (__iter__ 呼び出し)
  2. next(it) — 次の値を要求 (__next__ 呼び出し)、終わったら StopIteration を投げる

イテラブル vs イテレータ #

用語が紛らわしいので整理。

定義メソッド
イテラブル (Iterable)iter() ができるすべて__iter__listdictstrrange、ファイル、ジェネレータ
イテレータ (Iterator)「次の値」を返せるもの__next__(と __iter__)iter([1,2,3]) の結果、ジェネレータ

すべてのイテレータはイテラブル です(自身を返す __iter__ があります)。逆は違います — list はイテラブルですがイテレータではありません。next(my_list) はエラーになります。

ユーザー定義のイテラブル — クラスで #

Range を自作
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, 2

2つのクラス — イテラブルとイテレータを分離します。イテラブルは 複数回反復可能 ですが、イテレータは一度使い切ると終わりです。

複数回反復可能な理由
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) と同じ動作をしますが、関数の形の方が表現力が大きいです。

ジェネレータ式 vs 関数
# 一行で表現可能 — 式が合う
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 万個処理
# リスト内包表記 — 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 y
✅ yield from
def chain_two(a, b):
    yield from a
    yield from b

同じ仕事をしますが yield from の方が短く、その他にも2つの追加の効用があります:

  1. send / throw が自動的に委譲される(下で扱う)
  2. 下位ジェネレータの戻り値を受け取れる

木 / 再帰の走査に自然 #

再帰で木を平坦化
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(...) 一行が再帰を自然に解いてくれます。

sendthrowclose — コルーチン機能 #

ジェネレータは 値を受け取ることもできます。 yield の結果を変数に受け取れば、外部から send で値を送れます。

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 を直接扱うことはほとんどありません。概念だけ認識 しておけばよいです。

throw と close
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 にあります。

よく使う 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 は、標準ライブラリではこのように使われています。

collections.abc — インターフェース
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 がどう動作するか、これで分かります。

@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 を呼び出す オブジェクトに包みます。コンテキストマネージャがジェネレータの上に作られた道具であることがここで確認できます。

練習問題 #

  1. def fibonacci() -> Iterator[int]: のシグネチャを持つ無限ジェネレータを書いてください。最初の2値 0、1 から始めて、毎回直前の2値の和を yield します。from itertools import islicelist(islice(fibonacci(), 10))[0, 1, 1, 2, 3, 5, 8, 13, 21, 34] になるかを確認します。
  2. def read_log_errors(path: str) -> Iterator[str]: を書いてください。ファイルを開いて1行ずつ読みながら “ERROR” を含む行だけを yield します。ファイルの close が保証されるように関数の中で with open(...) を使ってください。100GB の擬似ファイルでもメモリ使用量が一定であることを意識して書きます。
  3. yield fromdef flatten(items) を再帰で書いてください。flatten([1, [2, [3, [4]], 5], 6])[1, 2, 3, 4, 5, 6] に平坦化されることを確認します。一方で yield from なしに for ... yield ... で解いた版も書いて、2つのコードの行数を比較します。

一行まとめ: foriter() + next() + StopIteration のシュガー。イテラブル (__iter__) ⊃ イテレータ (__iter__ + __next__)。yield が一度でもあればジェネレータ関数になり、yield のたびに一時停止。遅延評価がメモリ / 無限シーケンス / パイプラインを可能にする。yield from は委譲と再帰に自然。関数引数の型は Iterable[T] で広く。@contextmanager はジェネレータの上のシュガー。

次の章 #

次の 第12章 デコレータパターン では、関数とクラスを包む道具 — デコレータ のすべてのパターンを扱います。@contextmanager@dataclass もその一形態でした。

X