CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: ジェネレータをイテレータから理解する

Python のイテレータとジェネレータという概念は意外と分かりにくい。 今回は、実は深い関わり合いを持った両者についてまとめてみることにする。 というのも、最終的にジェネレータを理解するにはイテレータへの理解が欠かせないためだ。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G1036
$ python --version
Python 3.6.3

イテレータとは

まず、そもそもイテレータとは何者だろうか。 それについて、いくつかの側面から考えてみることにしよう。

使い方から考える

最初は、使い方という側面からイテレータとは何かを考えてみよう。 このとき、答えは「要素を一つずつ取り出すことのできるオブジェクト」になる。

実際に、使い方からイテレータについて見ていこう。 そのために、まずは整数がいくつか入ったリストを用意する。 ここでは、まだイテレータという概念は何も登場していない。

>>> l = [1, 2, 3]

次に、上記のリストから今回の主役であるイテレータを取り出す。 これには組み込み関数の iter() を用いる。

>>> iterator_object = iter(l)

「リストからイテレータを取り出す」と書いたように、リスト自体はイテレータではない。 あくまで、リストはイテレータを取り出すための存在に過ぎない。 こういった存在のことをイテレータの「コンテナオブジェクト」と呼ぶ。 詳しくは後述するものの、コンテナオブジェクトはリスト以外にもたくさんある。

上記で得られたイテレータからは、要素を一つずつ取り出すことができる。 ここで取り出される要素というのは、イテレータの元になったリストに含まれていたオブジェクトになる。 要素の取り出しには組み込み関数の next() を用いる。

>>> next(iterator_object)
1
>>> next(iterator_object)
2
>>> next(iterator_object)
3

要素を全て取り出しきった状態で、さらに next() 関数を使うと StopIteration 例外になる。 こうなると、もう新しい要素は取り出すことはできない。

>>> next(iterator_object)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

もし、もう一度リストに含まれていた要素を取り出し直したいときは、元のリストからイテレータを作り直す必要がある。 つまり、イテレータというのは使い捨てのオブジェクトということ。

上記は全てのイテレータに共通の考え方・使い方になっている。 なので、辞書 (dict) や集合 (set) といった別のコンテナからイテレータを作ったときも同じことが言える。

実は、日頃からお世話になっている構文の中にも、上記イテレータの仕組みを利用しているものがある。 次のスニペットを見てもらいたい。 以下では、リストの内容を for 文で回して、得られた要素をプリントしている。

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

実は、上記の for 文こそ最も身近なイテレータの利用例といえる。

なぜなら、上記の for 文が内部的にやっていることは以下と等価なため。 for 文は以下を簡単に書くためのシンタックスシュガーと捉えても構わない。

>>> iterator_object = iter(l)
>>> while True:
...     try:
...         i = next(iterator_object)
...         print(i)
...     except StopIteration:
...         break
... 
1
2
3

実は気づかないうちにイテレータを活用していたのだ。

作り方から考える

次は、作り方という側面からイテレータについて考えてみよう。 イテレータを作るにはイテレータプロトコルと呼ばれる特殊メソッドをクラスに実装する必要がある。

特殊メソッドというのは、前後にアンダースコア (_) が二つ入った特定の名前を持つメソッドのことを言う。 Python では、そういった名前を持つメソッドが特殊な振る舞いをする決まり (仕様) になっている。

オブジェクトがイテレータとして振る舞うには、そのオブジェクトに __next__()__iter__() という二つの特殊メソッドが必要になる。 この二つをまとめてイテレータプロトコルと呼ぶ。 ちなみに蛇足だけど Python 2 では __next__()next() だった。 バージョン 2 と 3 の両方に対応させるときは、どっちも実装しちゃうのが手っ取り早い。

それでは、実際にイテレータを作ってみよう。 次のスニペットでは MyCounter という名前でイテレータを作るクラスを定義している。 このクラスにはイテレータプロトコルが実装されているので、生成したインスタンスがイテレータとして振る舞うことができる。

>>> class MyCounter(object):
...     """整数を連番で提供するイテレータクラス"""
...     def __init__(self, start, stop):
...         self._counter = start
...         self._stop = stop
...     def __iter__(self):
...         # 自分自身を返す
...         return self
...     def __next__(self):
...         if self._counter > self._stop:
...             # 最後まで到達したときは StopIteration 例外を上げる
...             raise StopIteration()
...         ret = self._counter
...         self._counter += 1
...         return ret
...     def next(self):
...         # Python 2 対応
...         return self.__next__()
... 

実際にインスタンス化して使ってみよう。 前述した通り、上記のクラスをインスタンス化したオブジェクトはイテレータとして扱うことができる。

>>> c = MyCounter(start=1, stop=3)
>>> next(c)
1
>>> next(c)
2
>>> next(c)
3
>>> next(c)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 9, in __next__
StopIteration

そして、もうお気づきかもしれないけど組み込み関数 next() で呼ばれたときの処理の実体が __next__() メソッドに対応する。

ちなみに iter() 関数も同じで、今回の場合だと __iter__() で自分自身を返すことになる。

>>> c = MyCounter(start=1, stop=3)
>>> next(c)
1
>>> c2 = iter(c)
>>> next(c2)
2
>>> next(c2)
3

また、イテレータプロトコルの中で __iter__() だけを実装したものがイテレータの「コンテナオブジェクト」になる。 コンテナオブジェクトというのは、例えば最初に出てきたリストがそうだった。 前述した通り、イテレータオブジェクトは使い捨てなので使うたびに新しく作り直さないといけない。 そこで、設定 (状態) を別の場所に保持しておいて、そこからイテレータを何度も生成できるようにするのがコンテナオブジェクトの役割だ。

試しに、先ほど定義した MyCounter クラスに対応するコンテナオブジェクトを作ってみよう。

>>> class MyContainer(object):
...     """MyCounterを生成するコンテナクラス"""
...     def __init__(self, start, stop):
...         self._start = start
...         self._stop = stop
...     def __iter__(self):
...         # コンテナクラスの __iter__() ではイテレータオブジェクトを返す
...         return MyCounter(start=self._start, stop=self._stop)
... 

まずは上記で定義したコンテナクラスからインスタンスを生成する。 生成したインスタンスがコンテナオブジェクトになる。

>>> container_object = MyContainer(start=1, stop=3)

コンテナオブジェクトからは iter() 関数を使ってイテレータオブジェクトを得る。

>>> c = iter(container_object)
>>> next(c)
1
>>> next(c)
2

ちなみに for 文に渡すものはイテレータオブジェクトそのものでも良いし、コンテナオブジェクトでも構わない。 ただし、コンテナオブジェクトの場合はイテレータオブジェクトを何度でも作り直せるので使いまわすことができる。

>>> for i in container_object:
...     print(i)
... 
1
2
3

for 文にイテレータオブジェクトを直接渡している場合だと、こうはいかない。 イテレータオブジェクトは使い捨てなので、一度 for 文で回したら再度使うことができない。

使う理由から考える

続いては、イテレータを使う理由から考えてみたい。 イテレータを使う理由は、主に二つ挙げられる。

まず、一つ目は for 文の例にあるようにインターフェースの統一という点がある。 Python にはリスト (list) や辞書 (dict)、集合 (set)、その他色々なコンテナオブジェクトがある。 それらの値を列挙しようとしたとき、もしもインターフェースが統一されていなかったらどうなるだろうか? それぞれのオブジェクトごとに、バラバラのメソッドを使い分けて値を取り出すことになって大変なはず。 イテレータプロトコルという統一されたインターフェースがあるおかげで、値の列挙に同じ操作が適用できる。

二つ目は空間計算量の問題、ようするにメモリの節約がある。 例えば先ほどの MyCounter のように整数を連番で生成することを考えてみよう。 もし、それを用意するのにリストを使うとしたら、あらかじめ全ての要素をメモリに格納しなければならない。 要素の数が少なければ問題ないだろうけど、もし膨大な数を扱うとすればメモリを大量に消費することになる。 それに対しイテレータを使えば、そのつど生成した値を使い終わったら後は覚えておく必要はない。 変数から参照されなくなったら要素はガーベジコレクションの対象となるためメモリの節約につながる。

以上、イテレータについて使い方、作り方、使う理由という側面から解説した。

ジェネレータとは

続いてはジェネレータの説明に入る。 とはいえ、実はイテレータについて理解できた時点でジェネレータについての説明は半分以上終わったようなものだったりする。 というのも、ジェネレータというのはイテレータを簡単に作るための手段に過ぎないため。

先ほどのイテレータの作り方の説明を読んで「意外とめんどくさいな」と思った人もいるんじゃないだろうか。 それはその通りだと思っていて、クラスに特殊メソッドを実装してイテレータを作るのは、ぶっちゃけめんどくさい。 そんなとき、簡単にイテレータを作れるのがジェネレータという方法だったりする。

ジェネレータというのは、実のところイテレータオブジェクトを生成するための特殊な関数に過ぎない。 通常の関数との違いは、値を返すのに return ではなく yield を使うところだけ。 この yield の呼び出しが、イテレータプロトコルの __next__() に対応している。

説明だけ聞いてもよく分からないと思うので、実際にサンプルコードを見てみよう。 先ほどの MyCounter を、クラスではなくジェネレータを使って実装してみる。

>>> def mycounter(start, stop):
...     counter = start
...     while True:
...         if counter > stop:
...             break
...         yield counter
...         counter += 1
... 

クラスを使った場合に比べると、ずいぶんスッキリしていることが分かる。

上記のジェネレータを呼び出して、まず返ってくるのはイテレータオブジェクトだ。

>>> iterator_object = mycounter(1, 3)

その証拠に組み込み関数 dir() を使ってアトリビュートを確認すると __iter__()__next__() があることが分かる。

>>> dir(iterator_object)
['__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__next__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'gi_code', 'gi_frame', 'gi_running', 'gi_yieldfrom', 'send', 'throw']

そのイテレータオブジェクトに next() 関数を使うと yield を使って返された値が得られる。 ちなみに、ジェネレータが返すイテレータオブジェクトは、ジェネレータイテレータと呼ばれる。

>>> next(iterator_object)
1
>>> next(iterator_object)
2
>>> next(iterator_object)
3
>>> next(iterator_object)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

先ほどの「イテレータの説明が終わったらジェネレータの説明も半分以上は終わっている」という言葉はこういうわけだった。 ジェネレータという特殊な関数は、イテレータオブジェクトを簡単に作るためのラッパーかシンタックスシュガーに過ぎない。

まとめ

ジェネレータを理解するのは難しい。 その理由は、まずイテレータを理解する必要があるからだと感じている。 そこで、今回はイテレータとジェネレータについて一通り解説する記事を書いてみた。

ちなみに、手前味噌だけどここらへんの話は拙著の「スマートPythonプログラミング」にも書いてある。 もし、Python に登場する特徴的な概念への理解がふんわりしているという場合には読んでみると良いかもしれない。