検索
連載

[Pythonクイズ]このコード、実はメモリをムダ遣いしてるかも? もっとスマートに書けませんか?Pythonステップアップクイズ

1万個の整数値をテキストファイルに書き出すPythonコードがあるとして、メモリ消費量を少なくするにはどうすればよいのかを考えてみてください。

PC用表示 関連情報
Share
Tweet
LINE
Hatena
「Pythonステップアップクイズ」のインデックス

連載目次

もっとメモリ効率が良い書き方はあるのかな?
もっとメモリ効率が良い書き方はあるのかな?

【問題】

 以下は0から9999までの整数値をテキストファイルに書き込むPythonコードである(テキストファイルの各行には1つの整数が書き込まれる)。このコードは問題なく動作するが、実はメモリ効率はあまり良くない。もっと良い方法があるかを考えてみよう(ファイルへの書き込みはwritelinesメソッドを使い、例外処理などについては考えなくてもよいものとする)。

numbers = range(10000)
lines = [f'{n}\n' for n in numbers]
with open('data.txt', 'w') as f:
    f.writelines(lines)

もっとメモリ効率の良い書き方はあるのかな?


かわさき

 どうもHPかわさきです。

 S原B太さんに面と向かって「効率的って何かね」と問われたら、自分にもよく分からないところがあります。なので、今回の問題ではメモリ効率、メモリ消費量に着目したいと思います。


【答え】

 正解のコード例を以下に示します。

numbers = range(10000)
lines = (f'{n}\n' for n in numbers)
with open('data.txt', 'w') as f:
    f.writelines(lines)

リスト内包表記をジェネレータ式にすることで、1万個の要素を持つリストを作らないようにしている

 リスト内包表記よりもメモリ消費量を抑えられるということで、ジェネレータ式を使った上記のコードを正解としましょう。上で述べたようにメモリ効率を主眼に置いたのでそのような答えになっていますが、メモリ効率は悪くても実行速度が重要な場面もあるかもしれません。

 実行速度に着目すると、「ファイル書き込みが実行速度に与える影響が大きいハズなので、その辺に手を入れるべき」とか「f文字列を使うのではなくstr(n) + ‘\n’と書く方法もある」と考える人もいるかもしれませんね(何をもって「効率的」とするかはホントに難しいところですねぇ)。ですが、今回は中間的なオブジェクトであるリストの必要性について考えたいと思います。

リスト内包表記をジェネレータ式にすることで、1万個の要素を持つリストを作らないようにしている
リスト内包表記をジェネレータ式にすることで、1万個の要素を持つリストを作らないようにしている

【解説】

 リスト内包表記は何らかのコレクションに格納されているデータに定型の処理を加えたものを要素とするリストを生成するもので、Pythonでは広く使われる記法といえます。良いことばかりのようですが、要素の数が多いと、新たに作成されるリストのために多くのメモリを消費することがデメリットとして挙げられます。

 問題文のリストはまさにそうした例になっています(以下に再掲)。

numbers = range(10000)
lines = [f'{n}\n' for n in numbers]
with open('data.txt', 'w') as f:
    f.writelines(lines)

問題文のコード(再掲)

 このコードでは、0から9999までの整数値を表すrangeオブジェクト(これはリストではありません)を基に1万個の要素を持つリストを(中間的なオブジェクトとして)生成しています。この例の1万個ならたいしたことはなくとも、もっと大きなコレクションに対してリスト内包表記を使用すれば、メモリ消費量もそれに合わせて大きくなるでしょう。

 これに対して、ジェネレータ式は反復(イテレート)するたびに、その要素が生成されるため、リスト内包表記よりもメモリ消費量の面で効率が良くなります。リスト内包表記とよく似た書き方(角かっこ「[]」の代わりにかっこ「()」を使用)をしながらメモリ消費量を低減できるのがよいところといえるでしょう。

 よって、問題文に挙げたコードを次のようにジェネレータ式を使うように修正することで、消費するメモリを節約できます。

numbers = range(10000)
lines = (f'{n}\n' for n in numbers)
with open('data.txt', 'w') as f:
    f.writelines(lines)

ジェネレータ式を使用

 では、どのくらい節約できるのでしょう? というわけで、Pythonに標準で付属するtracemallocモジュールを使って、Python処理系により割り当てられたメモリブロックのサイズを計測してみます。以下がそのコードです。

import tracemalloc

# リスト内包表記の場合
tracemalloc.start()
numbers = range(10000)
lines = [f'{n}\n' for n in numbers]
with open('data.txt', 'w') as f:
    f.writelines(lines)
list_cur, list_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

# ジェネレータ式の場合
tracemalloc.start()
numbers = range(10000)
lines = (f'{n}\n' for n in numbers)
with open('data.txt', 'w') as f:
    f.writelines(lines)
gen_cur, gen_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(f'{list_cur=}, {list_peak=}')
print(f'{gen_cur=}, {gen_peak=}')

確保されるメモリブロックサイズの比較

 上のコードでは、tracemalloc.start関数とtracemalloc.stop関数でメモリ割り当てのトレースのスタート/ストップを行っています(2箇所)。これら2つの関数の間に、リスト内包表記を使ったコードまたはジェネレータ式を使ったコードを挟み込むことで、それぞれのコードで確保されるメモリブロックのサイズを計測しています。そして、トレースをストップする直前にtracemalloc.get_traced_memory関数を呼び出して、トレースしているメモリブロックの現在のサイズと最大のサイズを取得し、最後にこれを表示するのが上記コードでやっていることです。

 筆者の手元の環境でこのコードを実行すると、次のような結果になりました。

ジェネレータ式を使ったコードの方が確保されるメモリブロックのサイズは少ない
ジェネレータ式を使ったコードの方が確保されるメモリブロックのサイズは少ない

 リスト内包表記を使った場合、ファイルへの書き込みが終わった時点でのメモリブロックのサイズは547250、最大のサイズは635892となりました。一方、ジェネレータ式を使った場合は前者のサイズが5819、後者が94683となっています。どちらのサイズもリスト内包表記の場合と比べてかなり小さなものになりました(環境によって、これらのサイズは変化することには留意してください)。

 この結果を見ると、メモリ消費の観点からはジェネレータ式を使った方がよさそうなことが分かります。でも、メモリ効率は良くなったけれど、実行時間が大きく遅くなってはいけません。そこで、timeモジュールのtime関数を使った簡易的な計測をしてみることにしましょう。

from time import time

st = time()
numbers = range(10000)
lines = [f'{n}\n' for n in numbers]
with open('data.txt', 'w') as f:
    f.writelines(lines)
ed = time()
list_time = ed - st

st = time()
numbers = range(10000)
lines = (f'{n}\n' for n in numbers)
with open('data.txt', 'w') as f:
    f.writelines(lines)
ed = time()
gen_time = ed - st

print(f'{list_time=}')
print(f'{gen_time=}')

time.time関数で実行時間を計測

 両者のコードの前後でtime.time関数を呼び出して、実行にかかる時間を計算しているだけです。筆者の環境での実行結果を以下に示します。

実行速度はほぼ同じ
実行速度はほぼ同じ

 実行速度はほぼ同じという結果になりました。最初にまとめて作成するか、その都度作成するかの違いはあっても、結局、1万個の文字列を作成することに変わりはないと考えてもよいのかもしれませんね。

 というわけで、リストがどうしても必要になるという場面以外ではリスト内包表記ではなく、ジェネレータ式を使うとメモリ効率が良くなりそうです。筆者は普段からリスト内包表記を多用しているので、ちょっと心に留めておこうと思いました。


かわさき

 しかし、ジェネレータ式を使ってもメモリ消費量をそれほど抑えられない場面もあります。例えば、文字列のjoinメソッドを使うときがそうです。

numbers = range(10000)
s = ', '.join(f'{n}' for n in numbers)

joinメソッドでは文字列を結合するタイミングで1万個の文字列を作成することになる

 joinメソッドはその呼び出しに使用した文字列をセパレーターとして、メソッドに渡された反復可能オブジェクトの要素を結合した文字列を生成します。上のコードではメモリを節約しようとしてジェネレータ式を渡していますが、joinメソッドの内部では最終的な文字列のサイズを事前に知る必要があるために大量の文字列が一度に生成されるようです。

 全てがうまくいくというわけではない点には注意しましょう。

 今回は問題を考えるまではわりと気楽だったんですが、いざ原稿を書き出すと「効率的とはなんだ?」「終着点が分からん」となってしまいまして、ちょっと中途半端な感じの回になってしまったかもしれません。いつか似たようなネタで速度を追求してみるとか、やってみてもいいかもしれませんね。

 それはさておき、リスト内包表記とジェネレータ式についてはPython入門の以下の回で取り上げているので、ご興味のある方はご覧ください。

 それではまた、次回にお目にかかりましょう!


「Pythonステップアップクイズ」のインデックス

Pythonステップアップクイズ

Copyright© Digital Advantage Corp. All Rights Reserved.

[an error occurred while processing this directive]