cover_effective_python

【 Effective Python, 2nd Edition 】入力元のデータサイズが大きい場合は、リスト内包表記 ( list comprehension ) ではなくジェネレータ式 ( generator expression ) の利用を検討しよう! 投稿一覧へ戻る

Tags: Python , generator , comprehension , Effective

Published 2020年6月30日21:59 by T.Tsuyoshi

リスト内包表記では、入力元シーケンスの各要素に1対1で対応した要素からなる新たなリストインスタンスが作成される場合もあります。
この場合、入力元のデータが非常に大きければメモリ消費もそれに伴って大きくなってしまいます。


例えば、テキストファイルを読み込み、各行に含まれる文字数を取得するリスト内包表記を記述するとしましょう。
最終的には、ファイルの行数文の文字数をセットするためのメモリ領域が必要です。
対象とするファイルが巨大であったり、対象が延々と送られてくるネットワークソケットであったりしたらどうなっちゃうんでしょう?


対象のファイルが小さい場合であれば以下のような実装で問題ないでしょう。

my_file.txt:
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Nam in varius dolor. Ut quam quam, lobortis quis odio eu, consectetur gravida purus.
Mauris elit purus, tristique ut auctor sed, accumsan vel purus.
Nulla ornare aliquam augue, vitae gravida augue sodales non.
Nullam laoreet enim orci.
Vel interdum justo convallis a. Aenean sit amet commodo libero. Vestibulum ante.
Ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae.



value = [len(x) for x in open('my_file.txt', 'r')]

print(value)
# [57, 85, 64, 61, 26, 81, 71]



巨大なデータを扱う際に生じる問題に対処するために、Python にはジェネレータ式 ( generator expression ) が用意されています。


ジェネレータ式は通常のジェネレータと同様に実行時に全ての出力結果をメモリに保存するわけではなく、ジェネレータイテレータを返すだけです。


it = (len(x) for x in open('my_file.txt', 'r'))

print(it)
# <generator object <genexpr> at 0x0000000002468900>



そして以降の next() を利用した呼出し毎に式を1回だけ実行しその結果を返します。
つまり、入力元のデータがどれほど大きかろうが、式を1回実行しそこから返される値を保持するためだけのメモリしか消費しません。


print(next(it))
# 57

print(next(it))
# 85



ジェネレータ式の特筆すべきもう1つの点は、複数のジェネレータ式をネスト表記できる、ということです。


# 内部のジェネレータイテレータ (it) から取得した値について、その値そのものとその値の平方根からなるタプルを返すジェネレータイテレータ (roots)
roots = ((x, x**0.5) for x in it)

print(next(roots))
# (64, 8.0)

print(next(roots))
# (61, 7.810249675906654)



このイテレータ ( roots ) を1つ進めると、含まれているイテレータ ( it ) も1つ進められる、というように連鎖的に動作が実行されます。
結果的に、通常の内包表記と同様 for 文 -> if 文 -> 値の代入文 の順番で式の処理が進み、値が1つ返された時点で全体が停止、メモリもこの分だけが消費されるだけ、ということになります。
そして、Python におけるこのような連鎖的ジェネレータ式 ( chaining generators ) の実行速度は非常に速いんです。


もし、巨大なストリームデータを安全に、効率的に処理する方法が必要な場面に出くわした場合は、是非ジェネレータ式の活用を考えてみてください。


ただし、ジェネレータ式から返ってくるのはあくまでもイテレータである、ということを忘れてはいけません。


ある関数への引数としてジェネレータ式からの返却値 (イテレータ) を渡し、もしその関数内で total = sum(iterator) と for x in iterator: というイテレータを参照する2つの処理を行っていた場合、2回目にイテレータを参照する for 文では値を受け取ることはできません。


それは、ジェネレータからのイテレータが stateful (どこまで実行したのかを記憶している) であり、データの最後まで実行され StopIteration 例外を投げると、再度先頭から実行を繰り返すことができないためです。


このことについては 前回の記事 で詳しく取り上げていますので、興味のある方は読んでみてください。


まとめ:

1: リスト内包表記で大きな入力データを扱う場合はメモリ消費への注意を怠ってはいけません。

2: ジェネレータ式はイテレータを返し、1回の実行毎に1つの値を返す分のメモリしか消費しません。

3: 1つのジェネレータ式をもう1つほかのジェネレータ式の for 文の入力元としてネストさせて利用することができます。

4: 3 のような連鎖的ジェネレータ書式の処理速度は非常に速く、またメモリも効率的に利用できます。

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

0 comments

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

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