JEPA4Japan · チュートリアル

イテレーターとジェネレーター

1,946文字 6分で読めます #Python

データを遅延処理し、Pythonのforループを支える仕組みを理解します。

コース進捗 コース目次 24レッスン中 24件を公開中

本棚としおりは別の物

リストは、何度も使える値の本棚です。**iterable(反復可能オブジェクト)**は、しおりを渡せる物です。**iterator(イテレーター)**は、次の値がどこにあるかを覚える、状態付きのしおりです。

  1. Iterable 訪ねられる本棚
  2. iter() しおりを作る
  3. next() 進んで一つ受け取る
本棚はしおりを作れます。位置を持つのは、それぞれのしおりです。
topics = ["iterables", "iterators", "generators"]
bookmark = iter(topics)

print(next(bookmark))
print(next(bookmark))
print(list(bookmark))
print(list(bookmark))

出力:

iterables
iterators
['generators']
[]

イテレーターは一回限りなので、二回目のlist(bookmark)は空です。元のリストは再利用できます。もう一度iter(topics)を呼べば、新しいイテレーターを作れるからです。ファイル、文字列、rangeなどもiterableです。

StopIterationは「道の終わり」

次の値がないと、next()はStopIterationを送出します。forループは、この普通の終了合図を内側で処理してくれます。

  1. iter(iterable) イテレーターを一つ得る
  2. next(iterator) 値を一つ受け取る
  3. 繰り返す もう一度たずねる
  4. StopIteration ループを終える
すべてのforループの後ろに、この小さな約束があります。
bookmark = iter([10, 20])

while True:
    try:
        value = next(bookmark)
    except StopIteration:
        print("終了")
        break
    else:
        print(value)

出力:

10
20
終了

普通の仕事には普通のforを使います。正確な境界が大切なときだけ、next()を直接使いましょう。next(bookmark, "空")なら既定値を返せますが、本物のデータと混同しない印を選びます。

ジェネレーターの中からStopIterationを直接送出してはいけません。returnするか末尾まで進みます。外へ漏れたStopIterationはRuntimeErrorになります。

yieldは一時停止ボタン

yieldを含む関数はジェネレーター関数です。呼び出すとジェネレーターを作りますが、まだ本体は動きません。最初に引かれると開始し、yieldのたびに値を一つ渡してlocalな状態を凍らせます。

  1. 動く yieldまで仕事をする
  2. 止まる localな値を覚える
  3. 渡す 値を一つ外へ出す
次に引かれると、止まったyieldのすぐ後から再開します。
def count_to(limit):
    print("開始")
    for number in range(1, limit + 1):
        yield number


numbers = count_to(3)
print("作成")
print(next(numbers))
print(list(numbers))

出力:

作成
開始
1
[2, 3]

リスト内包表記はすべてを今すぐ作ります。(n * n for n in range(5))のようなジェネレーター式は、引かれた分だけ作ります。繰り返し走査、長さ、添字、スナップショットが必要ならリストを選びます。一回でよく、作業を遅らせたり最大メモリを減らしたりしたいなら遅延ストリームを選びます。遅延だから自動的に速いわけではありません。

小さなストリームを注意してつなぐ

yield fromは、別のiterableから来る値をそのまま渡します。

def whole_course():
    yield from ["基礎", "コレクション"]
    yield from ["ジェネレーター", "テスト"]


print(list(whole_course()))

出力:

['基礎', 'コレクション', 'ジェネレーター', 'テスト']

遅延処理はエラーも遅らせます。不正な行は、ジェネレーターを作った場所から離れた所で失敗するかもしれません。作成だけでなく、消費もテストします。ジェネレーターがファイルを開くと、一時停止中も開いたままになることがあります。リソースのwith内で消費するか、所有者とtry / finallyによる閉じ方を明確にします。ストリームが分類できる例外だけを捕捉しましょう。

generator.send(value)なら双方向に話せますが、道具箱の端に置いてください。新しいジェネレーターは、まずnext(generator)で最初のyieldまで進めます。その前にNone以外を送るとTypeErrorです。普通のパイプラインでは、設定は引数で入れ、値はyieldで出す一方向の形がずっと明確です。

遅延読書コンベヤーを作る

次の標準ライブラリだけの完全なプロジェクトをreading_conveyor.pyとして保存します。

from dataclasses import dataclass
from itertools import islice


@dataclass(frozen=True)
class Reading:
    topic: str
    minutes: int


def joined(*groups):
    for group in groups:
        yield from group


def parse_readings(lines):
    for line_number, line in enumerate(lines, start=1):
        parts = [part.strip() for part in line.split("|")]
        if len(parts) != 2 or not parts[0]:
            raise ValueError(f"行{line_number}:topic|minutesの形が必要です")
        try:
            minutes = int(parts[1])
        except ValueError as cause:
            raise ValueError(f"行{line_number}:分数が不正です") from cause
        if minutes <= 0:
            raise ValueError(f"行{line_number}:分数は正である必要があります")
        yield Reading(parts[0], minutes)


def at_least(readings, minimum):
    for reading in readings:
        if reading.minutes >= minimum:
            yield reading


raw_lines = joined(
    ["イテレーター | 25", "ジェネレーター | 40"],
    ["壊れた行"],
)
selected = at_least(parse_readings(raw_lines), minimum=20)

first_two = list(islice(selected, 2))
assert first_two == [Reading("イテレーター", 25), Reading("ジェネレーター", 40)]
print("最初の取得:", first_two)

try:
    next(selected)
except ValueError as error:
    print("次の取得:", error)

python3 reading_conveyor.pyで実行します。

最初の取得: [Reading(topic='イテレーター', minutes=25), Reading(topic='ジェネレーター', minutes=40)]
次の取得: 行3:topic|minutesの形が必要です

islice()が要求するのは二つだけなので、最初は壊れた三行目に触れません。次の取得でそこへ到達し、遅れてエラーになります。assertは、情報源全体をリストにせず、順序と中身を確かめます。

3つの小さなチャレンジ

  1. イテレーターを一つ作り、next()を二回呼び、残りを二回リストへ変換する。
  2. joined()へ三つ目のグループを足し、実行前に全体の順番を予想する。
  3. islice(selected, 2)をislice(selected, 1)へ変え、次の取得がどの行へ進むか説明する。

第18章へ進めるかな?

  • iterableとiteratorを区別できる
  • forの後ろにあるiter()、next()、StopIterationを説明できる
  • ジェネレーターがいつ始まり、yieldが何を止めるか分かる
  • 再利用できるリストと、一回限りの遅延ストリームを選び分けられる
  • リソース所有を隠さず、yield fromで段階をつなげられる
  • 読書コンベヤーを実行し、引かれたときだけエラーが届くのを確認した

次章では、デコレーターで関数呼び出しの周りに、コンテキストマネージャーでコードブロックの周りに方針を置きます。