Python – ジェネレータ (Generator)
前回の Python – デコレータ (Decorator) に続いて、本記事ではPythonのジェネレータ(Generator)を整理します。
Pythonは比較的習得しやすい言語ですが、初心者が共通して苦戦する概念がいくつかあります。その代表が、ジェネレータと yield です。
ジェネレータの辞書的な意味は「発電機」または「何かを作る人や物」を表します。Wikipediaにはコンピュータサイエンス的な意味として、次のような説明があります。
ジェネレータはイテレータ(iterator)と同じく、ループの動作をコントロールするために使われる特別な関数またはルーチンです。実際、すべてのジェネレータはイテレータです。ジェネレータは配列やリストをreturnする関数に似ており、呼び出し可能なパラメータを持ち、連続した値を作り出す。しかし、一度にすべての値を含む配列を作ってreturnする代わりに、yield構文を使って一度呼び出されるたびに一つの値だけをreturnし、そのため通常のイテレータに比べて非常に少ないメモリで済む。簡単に言うと、ジェネレータはイテレータと同じ役割をする関数です。
通常の関数が呼び出されると、コードの最初の行から始まって return、例外 (exception)、または(return しない関数なら)最後の行に到達するまで実行され、その後は呼び出し元 (caller) にすべての制御を返します。そして関数が持っていた内部状態やローカル変数はメモリ上から消えます。同じ関数が再度呼び出されると、すべては最初からやり直しです。
ところがある日から、プログラマーたちは、一度ですべての仕事を終えて消えてしまう関数ではなく、一つの仕事を終えたらその状態を覚えたまま待機し、再び呼び出されたら前の続きから動ける賢い関数を必要とし始めました。そうして作られたのがジェネレータです。ジェネレータを使うと、通常の関数よりもはるかに良いパフォーマンスを出せ、メモリリソースも節約できます。前置きが長くなりました。例を見ながらジェネレータがどんなものか見ていきましょう。
お好きなディレクトリに generator.py という名前のファイルを作り、次のコードを保存してください。
def square_numbers(nums):
result = []
for i in nums:
result.append(i * i)
return result
my_nums = square_numbers([1, 2, 3, 4, 5])
print(my_nums)ごく簡単な関数を定義して呼び出すコードです。定義された関数は、引数として受け取ったリストを for ループで回しながら、i * i の結果値で新しいリストを作ってreturnする関数です。
ターミナルやコマンドプロンプトを開いて、generator.py ファイルが保存された場所に移動してください。移動したら、次のコマンドでプログラムを実行してください。
$ python generator.py
[1, 4, 9, 16, 25]新しいリストが結果として返されました。
このコードをジェネレータにしてみましょう。
def square_numbers(nums):
for i in nums:
yield i * i
my_nums = square_numbers([1, 2, 3, 4, 5]) #1
print(my_nums)$ python generator.py
<generator object square_numbers at 0x0000016B17E19EB0>ジェネレータというオブジェクトが返されました。ジェネレータは、自分が返すすべての値をメモリに保存しないため、先ほどの通常の関数の結果のように一度にリストとしては見えないのです。ジェネレータは、呼び出されるたびに一つの値だけを返します。つまり、上の#1まではまだ何の計算もせず、誰かが次の値を求めてくるのを待っている状態です。確認してみましょう。
def square_numbers(nums):
for i in nums:
yield i * i
my_nums = square_numbers([1, 2, 3, 4, 5])
print(next(my_nums))$ python generator.py
1next() 関数を使って次の値が何か聞いてみました。次の値は1だそうです。今度はもう何回か聞いてみます。
def square_numbers(nums):
for i in nums:
yield i * i
my_nums = square_numbers([1, 2, 3, 4, 5])
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))$ python generator.py
1
4
9
16
25最初の例の通常の関数がreturnしたリストの値がすべて出力されました。ところで、ここでもう一度 next() 関数を呼び出すとどうなるでしょうか。やってみましょう。
def square_numbers(nums):
for i in nums:
yield i * i
my_nums = square_numbers([1, 2, 3, 4, 5])
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))
print(next(my_nums))$ python generator.py
1
4
9
16
25
Traceback (most recent call last):
File "generator.py", line 12, in <module>
print(next(my_nums))
StopIterationStopIteration 例外が発生しました。これ以上渡す値がないという意味です。
ジェネレータは一般的に for ループを通して呼び出して使うことが多いので、その例を見てみましょう。
def square_numbers(nums):
for i in nums:
yield i * i
my_nums = square_numbers([1, 2, 3, 4, 5])
for num in my_nums:
print(num)$ python generator.py
1
4
9
16
25今度はすべての値が出力され、StopIteration 例外は発生しませんでした。for ループは自分がどこで止まればよいかを知っているからです。
ここで一つ、ジェネレータが通常の関数より優れている点を挙げることができます。それはコードがよりシンプルだということです。Pythonの哲学が込められた「The Zen of Python」の3番目の項目には、こう書かれています。「複雑であるよりもシンプルなほうがよい。」そうです。どうせなら複雑なコードよりもシンプルなコードのほうが良いのです。
ところで、list comprehension(内包表記)を使うと、上のコードよりもさらに簡単なコードを書くことができます。例を見てみましょう。
my_nums = [x*x for x in [1, 2, 3, 4, 5]]
print(my_nums)
for num in my_nums:
print(num)$ python generator.py
[1, 4, 9, 16, 25]
1
4
9
16
25最初の例の通常の関数と同じリストを return しますね。同じ構文を少し変えるだけで、ジェネレータを作ることができます。
my_nums = (x*x for x in [1, 2, 3, 4, 5]) #1
print(my_nums)
for num in my_nums:
print(num)$ python generator.py
<generator object <genexpr> at 0x1007c8f50>
1
4
9
16
25#1の [] を () に変えただけでジェネレータが生成されました。簡単ですね。
ところで、for ループを使わずに一度にジェネレータのデータを見たい場合はどうすればよいでしょうか。そういう場合は、ジェネレータを簡単にリストに変換すればよいのです。
my_nums = (x*x for x in [1, 2, 3, 4, 5]) # ジェネレータ生成
print(my_nums)
print(list(my_nums)) # ジェネレータをリストに変換$ python generator.py
<generator object <genexpr> at 0x0000026FD7A99EB0>
[1, 4, 9, 16, 25]簡単にリストに変換されて出力されました。ここで一つ注意すべき点は、一度リストに変換するとジェネレータが持っていた長所をすべて失うということです。この長所の中で最も重要なのはパフォーマンスです。上でも説明したように、ジェネレータはすべての結果値をメモリに保存しないため、より良いパフォーマンスを出します。例を見ながら確認してみましょう。
from __future__ import division
import os
import psutil
import random
import time
names = ['田中', '佐藤', '鈴木', '高橋', '伊藤', '渡辺']
majors = ['コンピュータ工学', '国文学', '英文学', '数学', '政治']
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
t1 = time.time()
people = people_list(1000000) # 1 people_list を呼び出し
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print('開始前のメモリ使用量: {} MB'.format(mem_before))
print('終了後のメモリ使用量: {} MB'.format(mem_after))
print('合計所要時間: {:.6f} 秒'.format(total_time))$ python generator.py
開始前のメモリ使用量: 13.76171875 MB
終了後のメモリ使用量: 284.30078125 MB
合計所要時間: 1.215000 秒まず#1で people_list(1000000) を呼び出し、100万人の学生情報を入れたリストを作ってみました。メモリ使用量が13 MBから284 MBに増え、時間は1.2秒かかりました。#1の people_list(1000000) を people_generator(1000000) に変更して、ジェネレータのパフォーマンスをテストしてみましょう。
from __future__ import division
import os
import psutil
import random
import time
names = ['田中', '佐藤', '鈴木', '高橋', '伊藤', '渡辺']
majors = ['コンピュータ工学', '国文学', '英文学', '数学', '政治']
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
t1 = time.time()
people = people_generator(1000000) # 1 people_generator を呼び出し
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print('開始前のメモリ使用量: {} MB'.format(mem_before))
print('終了後のメモリ使用量: {} MB'.format(mem_after))
print('合計所要時間: {:.6f} 秒'.format(total_time))$ python generator.py
開始前のメモリ使用量: 13.75390625 MB
終了後のメモリ使用量: 13.7578125 MB
合計所要時間: 0.000000 秒メモリ使用量の変化はなく、時間は0.1秒もかかりませんでした。ジェネレータを使うとメモリ使用量が少なく、ジェネレータオブジェクトを作るのがリストオブジェクトを作るより速いということが確認できました。
しかし、この生成されたオブジェクトを使ってデータ処理をするときはどうでしょうか。
まず、生成されたリストを使って for loop処理をしてみましょう。
from __future__ import division
import os
import psutil
import random
import time
names = ['田中', '佐藤', '鈴木', '高橋', '伊藤', '渡辺']
majors = ['コンピュータ工学', '国文学', '英文学', '数学', '政治']
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
t1 = time.time()
people = people_list(1000000)
# リストを使って for loop を実行
for p in people:
print(p)
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print('開始前のメモリ使用量: {} MB'.format(mem_before))
print('終了後のメモリ使用量: {} MB'.format(mem_after))
print('合計所要時間: {:.6f} 秒'.format(total_time))$ python generator.py
{'id': 999998, 'name': '鈴木', 'major': '英文学'}
{'id': 999999, 'name': '鈴木', 'major': 'コンピュータ工学'}
{'id': 999999, 'name': '鈴木', 'major': 'コンピュータ工学'}
開始前のメモリ使用量: 13.7578125 MB
終了後のメモリ使用量: 285.84765625 MB
合計所要時間: 97.907999 秒メモリ使用量は13 MBから285 MBへと大幅に増加し、時間は97.9秒かかりました。
今度は、生成されたジェネレータを使って for loop処理をしてみましょう。
from __future__ import division
import os
import psutil
import random
import time
names = ['田中', '佐藤', '鈴木', '高橋', '伊藤', '渡辺']
majors = ['コンピュータ工学', '国文学', '英文学', '数学', '政治']
process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024
def people_list(num_people):
result = []
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
result.append(person)
return result
def people_generator(num_people):
for i in range(num_people):
person = {
'id': i,
'name': random.choice(names),
'major': random.choice(majors)
}
yield person
t1 = time.time()
people = people_generator(1000000) # 1 people_generator を呼び出し
# ジェネレータを使って for loop を実行
for p in people:
print(p)
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print('開始前のメモリ使用量: {} MB'.format(mem_before))
print('終了後のメモリ使用量: {} MB'.format(mem_after))
print('合計所要時間: {:.6f} 秒'.format(total_time))$ python generator.py
{'id': 999997, 'name': '鈴木', 'major': 'コンピュータ工学'}
{'id': 999998, 'name': '伊藤', 'major': 'コンピュータ工学'}
{'id': 999999, 'name': '佐藤', 'major': '英文学'}
開始前のメモリ使用量: 13.76171875 MB
終了後のメモリ使用量: 13.75390625 MB
合計所要時間: 102.774121 秒やはりメモリの消費はありませんでしたが、所要時間は102.7秒で、リストより約5秒ほど遅いことが分かりました。
これにより分かる事実は、実行時間よりもメモリ消費を減らさなければならない場合はジェネレータを使い、リソースよりも実行時間を減らさなければならない場合はリストを使うべきということです。
しかし、上のデータよりもはるかに多くの量のデータを並列で処理しなければならないとき、わずかな時間を減らすよりは、限られたリソースを効率的に使わなければならない場合がほとんどだと思います。
次回はオブジェクト指向プログラミング(OOP, Object Oriented Programming)を取り上げます。