2019-05-30

[Python] 部屋とYシャツとイテレータとジェネレータと私

タイトルは適当です。そしてパーマリンクからroomが抜けました。

今回は初学者にとってわかりづらい イテレータ(iterator), ジェネレータ(generator) などの概念について簡単に解説しようと思います。 適切なデータ構造を選択するのはプログラマの必須スキルだとばっちゃが言ってたのでぜひマスターしておきましょう。

想定する読者のレベルは初中級者です。 ジェネレータのあたりは少し難しいかも。

なんで初学者にとっての記事なのに初中級者向けなんでしょう。私にも訳が分かりません。 ちょっとずつ読み進めてください..

本題に入る前に用語について簡単に解説していきます。

info
  • 対象とするPythonのバージョンは2.7以上(もちろん3.0以上も含む)です。

イテラブル

イテレーション可能な構造を イテラブル(iterable) といいます。

ではイテレーションとは何かと言われれば 繰り返すこと, 次要素にアクセスすること と言えるでしょうか。

簡単に言うのであればfor文で回せるオブジェクト。厳密にいうのであれば イテレータにできるオブジェクトかな。

今回の記事の中では一番大きな概念というか外側の集合を指します。

コンテナ

要素をもつ入れ物オブジェクトを コンテナ(container)と表現することがよくあります。

正式にそういったものが定義されているわけではなく、そのように表現されることが多い、という話です。

基本的な型でいえば list, tuple, dict, set, frozenset です。

これらの拡張型であるcollectionsモジュールのオブジェクトなどもコンテナ型です。

上記で挙げた コンテナ はいずれもイテレーション可能、つまりイテラブルなオブジェクトであると言えます。

逆にイテラブルじゃないコンテナは見たことがないので、 関係は「イテラブル ⊃ コンテナ」という理解でも今のところ間違いはないでしょう。

シーケンス

シーケンス型はイテレーションしたときに要素の取り出し順が保証されているオブジェクトを指します。 必ずしもコンテナ型ではありません。

文字列(Unicode)型、リスト、タプルといった一次元のオブジェクトがシーケンスです。 これらはdictとは異なり、インデックス(数値)のみによって添え字を指定できます。

イテレータ

では イテレータ とはなんでしょうか。 イテレータは状態(イテレーションした場所)を記憶している イテラブルなオブジェクトです。

イテレータはイテラブルなオブジェクトの一つですが、 イテラブルなオブジェクトからイテレータを作ることも可能なのです。

よくわからないですよね。

ちょっと イテレータを作成するiter関数を使ってイテレータの動作を見てみましょう。

>>> l = [1, 2, 3] >>> i = iter(l) >>> next(i) 1 >>> next(i) 2 >>> next(i) 3 >>> next(i) Traceback (most recent call last): File "", line 1, in StopIteration

イテレータはnextメソッドによって状態を進めることができ、 イテレーションが不可能な状態になる(この場合だと最後の要素に達する)とStopIterationという例外を発生させます。

StopIterationに限らず、一度例外を送出した イテレータオブジェクトはもうイテレーションできないので注意です。

これがなんの役に立つのでしょうか? たった一つの例ですべてを表すことはもちろんできないのですが、例えばリストを読み進めたインデックスを覚えておきたいときを考えてみましょう。 リストでこれを実現しようとすればどこまで読み進めたのかを示すインデックスを変数で管理しなければなりませんが、 イテレータ変数はそれ自体が状態を記憶しているので別途管理しなくてもよいのです。

さて、勘の言い方はもうお気づきかもしれませんが、実はイテレータを進めることができるのはnext関数だけではありません。

for文もイテレーションをすすめることができます。

次の例ではfor文に イテレータ を与えています。

  • >>> l = [1, 2, 3] >>> for j in l: print(j) ... 1 2 3 >>> for j in l: print(j) ... 1 2 3
  • >>> i = iter([1, 2, 3]) >>> for j in i: print(j) ... 1 2 3 >>> for j in i: print(j) ... >>>

2つの違いがわかるでしょうか?

実はイテレータの方は2回めのループで要素へのアクセスが発生していません。

なぜこのような挙動になるのか。

イテレータは状態を持つため、一度目のforループにより最後までアクセスしてしまい、その状態を記録しているのです。 2回目のループに入ったときイテレータはすでに最後まで達しているためすぐにループを脱してしまうというわけです。

対するリストは状態を持たないのでfor文の度に先頭まで戻り再度全要素にアクセスします。 そのため何度for文でループしてもすべての要素を表示できるのです。

info
  • 当然ですが イテレータ はシーケンスとは異なり、添え字によるランダムアクセスができず順次アクセスのみ可能です。
  • >>> [1, 2, 3][0] 1 >>> iter([1, 2, 3])[0] Traceback (most recent call last): File "", line 1, in TypeError: 'listiterator' object has no attribute '__getitem__'

さて、そろそろ イテレータ と イテラブルがゲシュタルト崩壊してきたころではないでしょうか。

大丈夫、ここからは ジェネレータ に登場してもらいます。

ジェネレータ

大丈夫じゃない、問題だ。

いや、でも少しだけ安心してください。実はジェネレータ(オブジェクト)はイテレータ の仲間です。

info
  • (訂正)
  • ジェネレータ オブジェクトを 「ジェネレータ」として書いていましたが、公式に「ジェネレータ」という言葉が指すのはジェネレータ関数でした。 お詫びして訂正します
  • 「ジェネレータ」とだけ言うと場合、「ジェネレータオブジェクト」「ジェネレータ関数」 いずれにも解釈される可能性があるので注意してください。

ジェネレータ オブジェクト は規則に則って値を作りだします。

規則を関数によって記述するのですが、普通の関数とは違い yield文 を用います。 yield文 を含めることにより、その関数はジェネレータ オブジェクト を返すようになります。これを ジェネレータ関数 といいます。

そして、関数によって返ってきたものが ジェネレータ オブジェクト です。

値をインクリメントする ジェネレータ を作ってみましょう!

>>> # まずジェネレータ関数を定義する >>> def counter(start=0, step=1): ... while True: ... yield start ... start += step >>> g = counter(start=5, step=2) >>> g # ジェネレータ オブジェクト <generator object counter at 0x10a421a00> >>> next(g) # 5 5 >>> next(g) # 5 + 2 7 >>> next(g) # 5 + 2 + 2 9

returnの代わりにyieldによって返す値を返却するというわけです。yield文を実行する数がイテレーション可能な回数です。

ただし値を返却するのは呼び出しのタイミングではなくイテレーションしたタイミングです。

上記の例では「初期値(5)に増減値(2)を加算する」という規則をイテレーションのたびに適用して返却しています。

省メモリ

ジェネレータとは上記のように都度値を生成するという性質を持つため、一般的に省メモリと言われます。

個人的にはこれは側面的な特徴だという認識です(絶対ではないので

たとえば10000回ループする際に1インクリメントした値がほしいといった場合に、 [0, 1, 2, ..., 9999] といったリストをメモリに持つよりもループするタイミングで1加算していったほうがメモリにやさしいですよね。

また、先ほどの例のように無限ループをつくり、明確な終了条件を持たせなければ無限にイテレーションするオブジェクトを作成できるのはジェネレータの大きな特徴でありメリットです。

コンテナでループを表現しようとするとその分メモリを使うのでジェネレータはもってこいです。

ただし、省メモリだから必ずしも処理速度も速いというわけではないということにも注意してください。

返却値の生成(関数の実行)時間が速度要件に引っかかるのであれば、作成済のリストを渡すということも時には必要かもしれないということです。 これは少し極端な例ですが

sendメソッド

実はyield文は左辺を持てます。sendメソッドの引数を左辺に渡すといった動作をします。 まぁ実際の動作を見るのが一番わかりやすいでしょう。先ほどのcounterで現在値を変更できるように改修してみます。

>>> def counter(start=0, step=1): ... while True: ... received = yield start ... if received is None: ... start += step ... else: ... start = received ... >>> g = counter(start=5, step=2) >>> next(g) 5 >>> next(g) 7 >>> next(g) 9 >>> g.send(0) 0 >>> next(g) 2

大体のイメージがつかめますか?

この例ではsendで受け取った値でstartをリセットしています。sendメソッドが実行されないときはNoneが入ります。

このように ジェネレータの状態変更用途に適したメソッドです。 注意点としては実行のタイミングです。sendはyieldよりも先に実行されます。そしてイテレーションも進みます。

上記のように例では「受け取った値がNoneの時には加算しない」といった制御をしなければ受け取った値がいきなり変更されてしまうことになります。

sendによって値を受け取るyield値を返却するyieldは別 と考えるのがわかりやすいです。

>>> def test_send(): ... r0 = yield 1 ... print(r0) ... r1 = yield 2 ... print(r1) ... r2 = yield 3 ... print(r2) ... >>> g = test_send() >>> # 始まってないジェネレータに対してsendできない(前のyieldがないからsendできない) >>> g.send(0) Traceback (most recent call last): File "", line 1, in TypeError: can't send non-None value to a just-started generator >>> next(g) 1 >>> g.send(0) 0 2 >>> g.send(0) 0 3

前のyield文で受け取ってからyieldで値を返却していますね。

このように書くと当たり前のように見えますが、ループで表現するとハマる確率アップです。

throw

外側から例外を発生させるのにthrowメソッドを使います。引数に例外オブジェクトを指定します。

>>> g = test_send() >>> g.throw(IndexError) Traceback (most recent call last): File "", line 1, in File "", line 1, in test_send IndexError >>> next(g) Traceback (most recent call last): File "", line 1, in StopIteration

イテレータと同様、一度例外を発生させたジェネレータは再利用不可です。

用途としては ジェネレータを利用不可能にするとか、中の(多重)ループを抜けるのに例外で制御するとかかな。

申し訳ないが想像力がないのであんまり思い浮かばない。

関係を図でまとめると以下のような感じです。今回出てきてないmappingとかもあるけどスルーしてOKです。

あくまで僕の頭の中のイメージなので、間違ってても責任は取りません(\^▽\^)

image1

サブジェネレータへの委譲

Python3.3 で yield from と呼ばれる委譲構文が登場しました。

ドキュメントの説明を見てみましょう。

単純なイテレータに対して、 yield from iterable は本質的には for item in iterable: yield item への単なる速記法です:

>>> def g(x): ... yield from range(x, 0, -1) ... yield from range(x) ... >>> list(g(5)) [5, 4, 3, 2, 1, 0, 1, 2, 3, 4]

What's New In Python 3.3この記事では 3.2 と比較した Python 3.3 の新機能を解説します。 Python 3.3 は2012年9月29日にリリースされました。全詳細については 変更履歴 をご覧ください。 概要 -- リリースハイライト: 新たな文法機能: ジェネレータの委譲. のための新しい yield from 式, str オブジェクト向けの u'unicode' 文法の再適用. 新たなライブラリモ...https://docs.python.org/3/whatsnew/3.3.html

ということで、イテラブルなオブジェクトを指定することで、 それらの各要素を一回分のイテレーションとしたジェネレータが作成できます。

なお、ジェネレータを元に別のジェネレータを作る場合、以下のように簡単にかけます。 これが委譲と言われる所以ですね。この場合 g がサブジェネレータにあたります。

>>> def g2(x): ... yield from g(x) >>> list(g2(5)) [5, 4, 3, 2, 1, 0, 1, 2, 3, 4]

番外編

ここからは脱線です。興味のある方だけご覧ください。

マジックメソッド

イテラブル,イテレータといった特徴はオブジェクトに対して特定のマジックメソッドが存在するかどうかによって識別することができます。

イテラブルなオブジェクトは __iter__ メソッドによって イテレータを返却する必要がありもちろんfor文に入るときもコールされます。

>>> class TestIter(object): ... def __iter__(self): ... print('__iter__が呼び出されました') ... return iter([1, 2, 3]) ... >>> for i in TestIter(): print(i) ... __iter__が呼び出されました 1 2 3 >>> # イテレータじゃないとだめ >>> TestIter.__iter__ = lambda self: [1, 2, 3] >>> for i in TestIter(): print(i) ... Traceback (most recent call last): File "", line 1, in TypeError: iter() returned non-iterator of type 'list'

for文は内部で イテレータ を進めているだけということが理解できれば十分です。

ちなみに イテレータの場合は自分自身を返却するといった動作をします。

当然といえば当然ですね。だって イテレータが必要なだけなんだから。

>>> i = iter([1, 2, 3]) >>> i is i.__iter__() True

またイテレータは「next」メソッド(2系の場合)、 __next__ メソッド(3系の場合)が存在することがイテレータの条件です。

>>> i = iter([1, 2, 3]) >>> i.__next__ Traceback (most recent call last): File "", line 1, in AttributeError: 'listiterator' object has no attribute '__next__' >>> i.next >>> i.next() # 検証に使ってる環境が2系なので.next() 1 >>> next(i) 2

実際にイテレーションを進める場合はインスタンスメソッドを使うよりも、バージョンの違いを吸収できるnext関数を使うべきです。

中身を見たい

イテレータやジェネレータ は状態を持つオブジェクトなので、そのままでは全容が見えません。

これはちょっとした小技ですが、listやtuple関数によってコンテナに直して表示してあげましょう。

>>> i = iter([1, 2, 3]) >>> # 中身がわからない>< >>> i >>> list(i) [1, 2, 3] >>> # 2回目は見られない>< >>> list(i) []

注意点としては、コンテナ化した際に全部の要素にアクセスしているためその後の利用は不可能になるのであくまで確認用として利用しましょう。

コンテキストマネージャ

Pythonでは2.6以上のバージョンで「with」という構文が利用できます。 (2.5では from__future__ import with_statement すれば使える)

有名なのはopen関数で作成されたファイルオブジェクトなどでしょうか。 withコンテキストの区間に出入りするときの処理を __enter__, __exit__ メソッドに記述する必要がありますが、それはめんどくさいですね。

ここで使えるのが contextlib.contextmanager と呼ばれるユーティリティ関数です。

この関数はジェネレータを返す必要があります。

>>> from contextlib import contextmanager >>> @contextmanager ... def test_context(something): ... print('前処理') ... yield something ... print('後処理') ... >>> with test_context('何らかの処理') as process: ... print(process) ... 前処理 何らかの処理 後処理

yield文を挟んで前処理と後処理を記述、yield文ではasによって代入するオブジェクトを返却します。

値を返却した後にも処理が書けるという ジェネレータ の性質をうまく利用しているというわけです。

warning
  • yieldの数は一つでないとエラーになりますので注意してください。
  • >>> from contextlib import contextmanager >>> @contextmanager ... def test_context(something): ... print('前処理') ... yield something ... yield something ... print('後処理') ... >>> with test_context('何らかの処理') as process: ... print(process) ... 前処理 何らかの処理 Traceback (most recent call last): File "", line 2, in File "/usr/lib/python2.7/contextlib.py", line 28, in __exit__ raise RuntimeError("generator didn't stop") RuntimeError: generator didn't stop

いかがだったでしょうか。

ここに書いてあることはほんの一部ですが、様々なオブジェクトの特性を知っていれば効率的できれいなコードをかけるようになります。

一緒に頑張っていきましょう。