effective_python

【 Effective Python, 2nd Edition 】引数として受け取った値を関数内で複数回「消費」する場合には要注意! イテレータ ( iterator ) とコンテナ ( container ) の違いをちゃんと認識しよう! 投稿一覧へ戻る

Tags: generator , iterable , iterator , protocol , python , effective

Published 2020年6月29日20:44 by T.Tsuyoshi

関数が引数としてリストをとる場合、そのリストを関数内部で複数回参照することはよくあります。


例えば、台湾旅行に行った日本人が訪れた観光地ごとの訪問者数のリストがあるとします (千人/年)。
そして人気度をみるために、それぞれの観光地が占める割合を計算したいとしましょう。


そのためには、まず訪問者数の合計を求め、続けて、それぞれの観光地の訪問数をその合計で割ることになりますね。

def normalize(numbers):
total = sum(numbers)
print(f"合計訪問者数: {total}")
result = []
for value in numbers:
percent = round(100 * value / total, 1)
result.append(percent)
return result


visitors = [56, 18, 26, 8, 19, 42, 61, 112, 33, 29]
percentages = normalize(visitors)

print(percentages)
# 合計訪問者数: 404
# [13.9, 4.5, 6.4, 2.0, 4.7, 10.4, 15.1, 27.7, 8.2, 7.2]



さて、将来的には世界各地の観光地を調査の対象にし、データはファイルから読み込みたいと思っています。
そうすると、どうしても読み込むデータが大きくなりメモリの消費量が心配です。
そこでメモリ消費を抑える面からもジェネレータ ( generator ) を定義することにしました。


def read_visitors(data_path):
with open(data_path, 'r') as f:
for line in f:
yield int(line)



この関数はジェネレータですから、インスタンス化すると実行結果ではなくイテレータ (正確には「ジェネレータイテレータ」です) が返ってきます。
このイテレータを normalize() に渡せば、大きなデータをメモリに読み込む必要もありません、ねっ、私天才でしょ!?


visitors.txt:

56
18
26
8
19
42
61
112
33
29



it = read_visitors("visitors.txt")
percentages = normalize(it)

print(percentages)
#合計訪問者数: 404
# []



??? なんということでしょう、割合の計算結果が返ってきていません!?


これは「イテレータ or ジェネレータ」をちゃんと理解していなかったことが原因です。
イテレータ or ジェネレータは1回コッキリしか結果を返してきません。
つまり、1回最後まで値を取り出し StopIteration 例外を投げると、そのイテレータの仕事は終わっちゃうんです。
つまり上のコードでは、total = sum(numbers) を実行した時点でこのイテレータを消費してしまっており、次の for ループで再び numbers を利用しようとしても役立たずになってしまっていた、ということです。


そしてさらに厄介なことには、すでに消費してしまったイテレータをこのように再度利用しても、StopIteration 例外を処理する Python の標準組み込み関数や構文の多くは「エラーだよ」ということを知らせてきてはくれないんです。
これらの関数や構文では、このイテレータが最初から返すべき値を持っていなかったのか、それとも、値はあったんだけどすでに消費してしまったのか、を区別することはできない、ということです。


では、normalize() がリストなどのコンテナを受け取っても、イテレータを受け取っても動作するようにするにはどうすればいいでしょう?
もっとも簡単な方法は、normalize() で一旦内部変数にコンテナとして保存してしまうことです。


def normalize_copy(numbers):
numbers_copy = list(numbers)
total = sum(numbers_copy)
print(f"合計訪問者数: {total}")
result = []
for value in numbers_copy:
percent = round(100 * value / total, 1)
result.append(percent)
return result


it = read_visitors("visitors.txt")
percentages = normalize_copy(it)

print(percentages)
# 合計訪問者数: 404
# [13.9, 4.5, 6.4, 2.0, 4.7, 10.4, 15.1, 27.7, 8.2, 7.2]



しかしこれでは元の木阿弥です。
メモリ消費を抑えることを主眼としてジェネレータを導入したのに結局リストがメモリに居座ってます...


そこで考えます。
そうか、イテレータが1回だけで消費されてしまうのであれば、イテレータを返す関数を引数として渡せば、同じデータを返すイテレータを何回でも供給できる。
ふっふっ、やっぱり私は天才です。


def normalize_func(get_iter):
total = sum(get_iter())
print(f"合計訪問者数: {total}")
result = []
for value in get_iter():
percent = round(100 * value / total, 1)
result.append(percent)
return result


percentages = normalize_func(lambda: read_visitors("visitors.txt"))

print(percentages)
# 合計訪問者数: 404
# [13.9, 4.5, 6.4, 2.0, 4.7, 10.4, 15.1, 27.7, 8.2, 7.2]



normalize_func() には、ジェネレータを返すラムダ関数 ( lambda function ) を渡しています。
これで消費されていないイテレータをその都度供給することができます。


ですが、ジェネレータを渡すためだけにラムダ関数を記述する、うーん、あまり気分はよくありませんし、ちょっと冗長です。


こういう場合のより良い方法は、イテレータプロトコルを実装した独自のコンテナクラスを定義してしまうことです。


イテレータプロトコル ( iterator protocol ) というのは、Python がいかにしてリスト等のコンテナタイプの各要素にアクセスするのか、どのようにイテレータを消費するのか、という一連の手続きのことです。


例えば、for x in foo: というステートメントがあったとき、

1: iter(foo) を呼び出す
2: 組み込み iter 関数は foo.__iter__ 特殊関数を呼び出す
3: __iter__ 特殊関数はイテレータオブジェクト ( __next__ 特殊関数を実装しているオブジェクト) を返す
4: for 文は next() 組み込み関数を使ってイテレータオブジェクトの __next__ 特殊関数を StopIteration 例外が投げられてくるまで呼び出し続ける



という一連の流れで処理されています。


そして独自のイテラブル可能なクラスを定義する際には、__iter__ 特殊関数としてジェネレータを実装するだけで済んじゃいます。
鋭い方はここで「でも __next__ 特殊関数が定義されてないじゃん」と思われるかもしれません。


そうですね、その通りです、すばらしい指摘です。
でも、これこそがジェネレータがやってくれていることなんです。
つまり、ジェネレータは __iter__() と __next__() を自動的に作成してくれるんです。
そして、next() で呼び出されるたびに前回中断した続きから処理を開始し、データがなくなった場合には StopIteration 例外を投げる、ということまで自動でやってくれます。


class PopularSite:
def __init__(self, data_path):
self.data_path = data_path

def __iter__(self):
with open(self.data_path, 'r') as f:
for line in f:
yield int(line)



そしてこのクラスオブジェクトはデータをファイルから読み込む、リストと同様のコンテナタイプですから、一番最初に定義した normalize() に渡すことができます。


visitors = PopularSite("visitors.txt")
percentages = normalize(visitors)

print(percentages)
# 合計訪問者数: 404
# [13.9, 4.5, 6.4, 2.0, 4.7, 10.4, 15.1, 27.7, 8.2, 7.2]



ちゃんと動作しています。
ちゃんと動作している理由ももうお分かりだと思います。


まず sum() で PopularSite.__iter__() が呼び出され、新しいイテレータオブジェクト (これこそが定義したジェネレータ本体です) が返され消費されます。
続いて、for 文においても PopularSite.__iter__() が呼ばれ、2つ目のイテレータオブジェクトが作成、返され消費されます。
そして、これらのイテレータオブジェクトはその都度作成されたまったく別のものですから、それぞれにおいてデータは最初から最後まですべて読み取ることができる、というわけです。


残念ながらこの実装での唯一の欠点は、PopulateSite.__iter__() が実行されるたびにデータ元のファイルにアクセスする必要がある、ということです。
大元のデータをファイルから読み込む仕様にしましたので、これは大目に見て下さい。


さて、ここまでで、Python におけるイテレータプロトコルでは、イテレータを処理する構文や関数が呼び出す iter 関数に、

1: イテレータが渡されたときはそれ自体が消費される (データ取り出しは1回のみ)
2: コンテナタイプが渡されたときはその都度新しいイテレータオブジェクトが作成されて返される


ということが分かったと思います。


つまり、引数として渡されてきたシーケンスデータを処理関数内で複数回「消費」する必要がある場合、引数として受け取るタイプは繰り返し消費が可能なもの、でなければならない、ということです。
イテレータそのものではダメ、ということですね。


この型チェックを実装しておけば normalize() をより安心して実行することができますね。


def normalize_completed(numbers):
# 引数がイテレータそのものであればエラー
if iter(numbers) is numbers:
raise TypeError("引数として受け取れるのはコンテナタイプのみです")

total = sum(numbers)
print(f"合計訪問者数: {total}")
result = []
for value in numbers:
percent = round(100 * value / total, 1)
result.append(percent)
return result



同様のチェック方法として、collections.abc モジュールで定義されている Iterator クラスを利用して isinstance() による確認を行う方法もあります。


from collections.abc import Iterator


def normalize_completed2(numbers):
# 引数がイテレータクラスのオブジェクトであればエラー
if isinstance(numbers, Iterator):
raise TypeError("引数として受け取れるのはコンテナタイプのみです")

total = sum(numbers)
print(f"合計訪問者数: {total}")
result = []
for value in numbers:
percent = round(100 * value / total, 1)
result.append(percent)
return result



最後にこのバージョンが本当にコンテナタイプに対しては正常に動作し、イテレータに対してはエラーを投げるか、を確認しましょう。


# 組み込みのコンテナを渡す
visitors = [56, 18, 26, 8, 19, 42, 61, 112, 33, 29]
print(type(iter(visitors))) # <class 'list_iterator'>
percentages = normalize_completed2(visitors)

print(percentages)
# 合計訪問者数: 404
# [13.9, 4.5, 6.4, 2.0, 4.7, 10.4, 15.1, 27.7, 8.2, 7.2]



# 独自実装のコンテナを渡す
visitors = PopularSite("visitors.txt")
print(type(iter(visitors))) # <class 'generator'>
percentages = normalize_completed2(visitors)

print(percentages)
# 合計訪問者数: 404
# [13.9, 4.5, 6.4, 2.0, 4.7, 10.4, 15.1, 27.7, 8.2, 7.2]



# イテレータを渡す
visitors = [56, 18, 26, 8, 19, 42, 61, 112, 33, 29]
percentages = normalize_completed2(iter(visitors))

print(percentages)
# Traceback (most recent call last):
# TypeError: 引数として受け取れるのはコンテナタイプのみです

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)