Python for文とイテレータとジェネレータ

Python3 のチュートリアルを流してみたので、その際に面白いと感じたところのメモです。
参考 Python チュートリアル

動作環境

$ python --version
Python 3.7.3

はじめに

python の for 文は、イテレータから値を取り出して繰り返すだけ。 JavaやC言語のように、条件式に基づいて繰り返し判定をすることはない。
Python 言語リファレンス 8.3. for 文

使い方

リストの要素に対して繰り返す。

>>> for x in ['tic', 'tac', 'toe']:
...     print(x)
... 
tic
tac
toe

## reversed で逆順にできる
>>> for x in reversed(['tic', 'tac', 'toe']):
...     print(x)
... 
toe
tac
tic

## sorted で整列できる
>>> for x in sorted(['tic', 'tac', 'toe']):
...     print(x)
... 
tac
tic
toe

## enumerate でインデックスを取得できる
>>> for i, v in enumerate(['tic', 'tac', 'toe'], 1):
...     print(i, v)
... 
1 tic
2 tac
3 toe

指定回数だけ繰り返すときは range を使う。

>>> for x in range(5,10):
...     print(x)
... 
5
6
7
8
9

## 何個ずつ繰り上げるか指定する
>>> for x in range(5, 10, 2):
...     print(x)
... 
5
7
9

文字列に対して繰り返すこともできる。

>>> for x in "hello":
...     print(x)
... 
h
e
l
l
o

辞書型に対して繰り返すこともできる。

>>> house_words = {
...    'Baratheon': 'Ours is the Fury',
...    'Greyjoy': 'We Do Not Sow',
...    'Lannister': 'A Lannister always pays his debts',
...    'Stark': 'Winter is Coming',
... }

# キーだけ取得する
>>> for x in house_words.keys():
...     print(x)
... 
Baratheon
Greyjoy
Lannister
Stark

## 値だけ取得する
>>> for x in house_words.values():
...     print(x)
... 
Ours is the Fury
We Do Not Sow
A Lannister always pays his debts
Winter is Coming

## キーと値を取得する
>>> for k, v in house_words.items():
...     print(k, v)
... 
Baratheon Ours is the Fury
Greyjoy We Do Not Sow
Lannister A Lannister always pays his debts
Stark Winter is Coming

else を使うことで for の終わりに処理できる。

>>> for x in range(2):
...     print(x)
... else:
...     print("done")
... 
0
1
done

# break で抜けると else は処理されない
>>> for x in range(2):
...     break
... else:
...     print("done")
... 

for文 の仕組み

  • for文に指定されたオブジェクトの __iter__() メソッドを呼び出し、イテレータを取得する
  • イテレータの __next__() を呼び出す
  • 戻り値を変数に代入し、forブロックの処理する
  • StopIteration 例外が返ってきたら、繰り返しを中断する

参考 Python ドキュメント 用語集 イテレータ 参考 Python ドキュメント Python 標準ライブラリ イテレータ型

実際に __iter__()__next__()を呼び出してみると、要素が順番に取得できることがわかる。

>>> it = [1,2].__iter__()
>>> it.__next__()
1
>>> it.__next__()
2
>>> it.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> it.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

注意点 __iter__()__next__()の代わりに iternextビルドイン関数が用意されている。これらの関数は引数オブジェクトがイテラブルか(イテレータを返せる)どうかのチェックを行ってくれたりするので、こちらをを利用すること。
参考 Python 標準ライブラリ 組み込み関数 iter
参考 Python 標準ライブラリ 組み込み関数 next

イテレータはいちど StopIteration に達すると、その後は常に StopIteration を返す。ということは、同じシーケンスに対してforを2回以上は呼び出せないの?と思ったが、毎度新しいイテレータを返すため心配はいらない。

>>> x = [1, 2]
>>> x.__iter__()
<list_iterator object at 0x7f17726f9c18>
>>> x.__iter__()
<list_iterator object at 0x7f17726f9b38>

for k, v in ... はどう実現しているの?
イテレータから値を取り出して変数に代入していく、という仕組みは同じ。

複数の変数に代入する操作は、タプルを使うことで実現している。 タプルは以下のように、要素数をそのまま変数に格納できる型。
Python チュートリアル 5.3. タプルとシーケンス

>>> x, y, z = ('apple', 'banana', 'cherry')
>>> print(x)
apple
>>> print(y)
banana
>>> print(z)
cherry

イテレータでタプルを返すことで、変数が2以上の場合に対応している。

>>> it = { 'alice': 'apple', 'bob': 'banana'}.items().__iter__()
>>> type(it)
<class 'dict_itemiterator'>
>>> it.__next__()
('alice', 'apple')
>>> type(it.__next__())
<class 'tuple'>

注意点その1

変数スコープが for の外側と同じ。

>>> del x
>>> for x in range(5):
...     print(x)
... 
0
1
2
3
4
>>> print(x)
4

これは、そもそもpythonにはブロックスコープという考え方がないため。forに限らず、ifやelseにもブロックスコープがない点は、個人的には面食らった。
参考 TauStation Python3 ? 変数のスコープ

注意点その2

リストの要素が途中で変更された場合、繰り返し項目に反映される。 下手に元のリストに変更を加えると、無限ループを起こす可能性がある。

>>> import time
>>> l = [1, 2, 3]
>>> for x in l:
...     print(x)
...     time.sleep(1)
...     l.insert(1, -99)
... 
1
-99
-99
-99
^CTraceback (most recent call last):
  File "<stdin>", line 3, in <module>
KeyboardInterrupt

初回のリストで固定したければ、コピーしたものを渡す。

>>> import time
>>> l = [1, 2, 3]
>>> for x in l[:]:
...     print(x)
...     time.sleep(1)
...     l.insert(1, -99)
... 
1
2
3

ジェネレータ

イテレータ( __iter__(), __next__()を実装したもの)をお手軽に作ることができる仕組み。
参考 Python ドキュメント 用語集 ジェネレータ
参考 Python 言語リファレンス yield式

以下のようにジェネレータを作成する。

>>> def gen():
...     for item in [1, 2, 3]:
...         print("generated ", item)
...         yield item # 1, 2, 3 を順番に返す

ジェネレータを実行することで、イテレータ(厳密にはジェネレータイテレータ)が取得できる。

>>> import collections
>>> isinstance(gen(), collections.Iterable)
True

宣言を確認すると、イテレータに必要な__iter__()__next__()が勝手に実装されていることがわかる。

>>> help(gen())
Help on generator object:

gen = class generator(object)
 |  Methods defined here:
 |
 |  __del__(...)
 |
 |  __getattribute__(self, name, /)
 |      Return getattr(self, name).
 |
 |  __iter__(self, /)
 |      Implement iter(self).
 |
 |  __next__(self, /)
 |      Implement next(self).
 |
 |  __repr__(self, /)
 |      Return repr(self).
 |
 |  close(...)
 |      close() -> raise GeneratorExit inside generator.
 |
 |  send(...)
 |      send(arg) -> send 'arg' into generator,
 |      return next yielded value or raise StopIteration.
 |
 |  throw(...)
 |      throw(typ[,val[,tb]]) -> raise exception in generator,
 |      return next yielded value or raise StopIteration.
 |

イテレータは、__next__()が呼ばれると yield の値を返す。

>>> def gen():
...     for item in [1, 2, 3]:
...         print("generated ", item)
...         yield item
...
>>> for x in gen():
...     print(x)
...
# 要素が必要になったタイミングでジェネレータが動く(=遅延評価される)
generated 1
1
generated 1
2
generated 1
3

要素の生成は遅延評価されるため、メモリ使用量の節約に役立つ。

遅延評価

ジェネレータに限らず、python のかなりの関数は遅延評価されるようになっている。(ように感じた)

例えば map なんかは、遅延評価されていることが分かりやすい。

>>> for x in map(lambda x: print('mapped'), [1,2,3]):
...     print(x)
...
mapped
None
mapped
None
mapped
None

このような遅延処理の仕組みのベースになっているのが、イテレータ。 要素が必要になったときにイテレータの__next__()を呼び出すことで、必要なものを、必要なときに、必要なだけ処理することができる。
参考 Python: range is not an iterator!
参考 Python2からPython3.0での変更点

>>> import collections

# rangeの戻り値はイテレータ
>>> isinstance(range(1,5), collections.Iterable)
True
# mapの戻り値もイテレータ
>>> mapped = map(lambda x: x**2, [1,2,3])
>>> isinstance(mapped, collections.Iterable)
True
# filterの戻り値もイテレータ
>>> filtered = filter(lambda x: x % 2 == 0, [1, 2, 3])
>>> isinstance(filtered, collections.Iterable)
True

Just In Timeに無駄なく処理する感じがかっこいい:crown: