Kodokon kodokon.com

イテレータとジェネレータ:yieldとパイプライン

イテレーションのプロトコルとジェネレータを使いこなし、データのストリームを遅延評価で、メモリを一定に保ったまま処理しましょう。

10 分 · 3 問

このレッスンを Kodokon で開く

Pythonのforは、ひとつのプロトコルにすぎません。iter(obj)イテレータを要求し、次にStopIteration例外が出るまでnext()が呼ばれます。イテラブル(リストのように、そのたびに新しいイテレータを渡せるもの)とイテレータ(一度しかたどれないカーソル)をきちんと区別しましょう。このプロトコルを理解することは、なぜあるオブジェクトが「消費されてしまう」のかを理解することです。

PYTHON
numbers = [10, 20, 30]
iterator = iter(numbers)
print(next(iterator))
print(next(iterator))
print(next(iterator))
# next(iterator) would raise StopIteration
forループがしていることを手作業で

yieldを含む関数は、呼び出しても実行されません。ジェネレータ、つまり本体が必要に応じて実行されるイテレータを返します。next()のたびに次のyieldまで進み、そこで実行がローカルな状態もろとも中断されます。その直接の帰結として、ストリームの大きさがどうであれメモリは一定のままです。完全なリストを作り上げるかわりに、値をひとつずつ生み出すからです。

PYTHON
def squares(limit):
    n = 0
    while n < limit:
        yield n * n
        n += 1

gen = squares(4)
print(next(gen))  # 0
print(list(gen))  # [1, 4, 9]
yieldは二つの値のあいだで関数を中断する

実務での主な利点はパイプラインです。各段階は前の段階のストリームを消費し、自分のストリームを遅延評価で生み出します。最後に消費される(list()sum()for)まで、何も実行されません。心に留めておくべきトレードオフがあります。リストは何度もたどれるしスライスもできますが、ジェネレータにはできません。その一方で、ジェネレータは10 GBのファイルをRAMを飽和させずに処理します。ジェネレータ式、つまり(f(x) for x in xs if cond)は、関数を定義せずに単純な段階をまかなってくれます。

PYTHON
lines = ["  12 ", "x", " 7", "", "30"]

def clean(rows):
    for row in rows:
        row = row.strip()
        if row.isdigit():
            yield int(row)

def keep_from(values, floor):
    return (v for v in values if v >= floor)

pipeline = keep_from(clean(lines), 10)
print(list(pipeline))  # [12, 30]
遅延評価のパイプライン:list()まで何も実行されない

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. 本体にyieldを含む関数を呼び出すと、何が得られますか?
    • 最初のyieldの結果
    • ジェネレータ。本体は実行されない
    • 生み出されるすべての値のリスト
  2. ジェネレータのパイプラインが中間リストに対して持つ、決定的な利点は何ですか?
    • 結果が自動的にソートされる
    • メモリが一定のまま。値は必要に応じて生み出される
    • 処理が複数のコアで並列化される
  3. 一度目のlist(gen)のあと、同じジェネレータに対する二度目のlist(gen)は何を返しますか?
    • 一度目の呼び出しと同じリスト
    • 空のリスト
    • 例外が伝播する