blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2022.03.10 技術記事

あなたが知らないかもしれないPythonのTIPS 5つ

by everes

#python #tips #pickle #shelve #counter #string

こんにちは。システム本部CTO室のeveresです。

Python TIPSを5つお届けします。知ってるかもしれないし、知らないかもしれない、そんなTIPSです。

  • 長い文字列を扱うとき
  • import文
  • 関数呼び出し
  • ループカウンター
  • Shelve(Pickle)

動作環境など

本エントリーに登場するサンプルのコードは次の環境で動作を確認しています。

>>> import platform, sys
>>> platform.platform()
'macOS-12.2.1-arm64-arm-64bit'
>>> sys.version
'3.10.2 (main, Feb  2 2022, 05:51:25) [Clang 13.0.0 (clang-1300.0.29.3)]'

1. 長い文字列を扱うとき

プログラムで長い文字列を扱う時にどうしましょう?というときのTIPSです。

三重引用符を使う?

三重引用符を使えば良いのでは?と思ってしまいますよね

>>> lorem = """Lorem ipsum dolor sit amet, 
consectetur adipiscing elit, sed do eiusmod 
tempor incididunt ut labore et dolore magna 
aliqua. Ut enim ad minim veniam, 
quis nostrud exercitation ullamco laboris nisi 
ut aliquip ex ea commodo consequat. Duis aute 
irure dolor in reprehenderit in voluptate velit 
esse cillum dolore eu fugiat nulla pariatur. 
Excepteur sint occaecat cupidatat non proident, 
sunt in culpa qui officia deserunt mollit anim 
id est laborum."""

でも、これでは改行が入ってしまいます。

バックスラッシュを使う?

通常はバックスラッシュを用いて、行が続くことをインタプリターに教えることが多いと思います。書籍なんかもそうなっていることが多いと思います。

>>> lorem = "Lorem ipsum dolor sit amet, " \
+ "consectetur adipiscing elit, sed do eiusmod " \
以下続く

ちょっと面倒くさいですね。

丸括弧でくくってしまおう

丸括弧でくくって、文字列を書いていきます。

>>> lorem = (
  "Lorem ipsum dolor sit amet, consectetur "
  "adipiscing elit, sed do eiusmod "
  "tempor incididunt ut labore et dolore "
  "magna aliqua. Ut enim ad minim veniam, "
  以下続く
)

そうなんです。丸括弧でくくってしまえばバックスラッシュ要らないんです。文字列の連結を表す + も不要です。

実はバックスラッシュにした場合も + は不要なので、バックスラッシュで表すか丸括弧で表すかの違いだったりします。

2. import文

1つのモジュールからいくつも関数やクラスをimportするときのTIPSです。

from views import egg, ham, spam

特にフレームワークを使った開発を進めているとこういったimportが開発の進みに合わせて増えていきますよね。

ある程度の数になってくると、変化を加えたときの diffの結果が見にくく なってきませんか?これの例はまだ数が少ないので良いのですが、アルファベット順にimportしたりしていると途中に挟まれてきます。

-from views import egg, ham, spam
+from views import egg, ham, song, spam

丸括弧でくくってしまおう

文字列と同様にimport文も丸括弧を使いましょう。

from views import (
  egg,
  ham,
  spam,
)

丸括弧を使ってimport対象を1行に1つにすれば、diffも見やすいですね。

 from views import (
   egg,
   ham,
+  song,
   spam,
 )

最後の行にもカンマをつけるようにしておけば、行が後ろに追加になった時にも無駄な差分に気を取られることはありません。

※ dictやlistなどの定義時も同様に行ごとに記載して末尾のカンマをつける癖をつけるのが吉です

3. 関数呼び出し

関数呼び出し時の引数に関するTIPSです。

関数呼び出し…はそもそも丸括弧で括られてます

もう書くまでもありませんね。

複数の引数がある場合には、引数を1行に1つにしても良いでしょう。

もちろん、関数呼び出しも末尾の項目にカンマをつけられます。もちろん関数定義だって最後の項目の後にカンマがあっても大丈夫ですよ。

4. ループでカウンターを使いたい

ループ処理でカウンターを使うときのTIPSです。

シーケンシャルなオブジェクトを順に処理させる構文が書きやすいのは良いですよね。

Pythonのfor文や(list|dict|generator)内包表記の記法も賛否はあれど、 for int i=0; i<length; i++ などと書くより楽な気がします。

for x in sequencial:
  do_something_with(x)

Pythonのループ処理にはカウンターがない

簡易にかける代わりに、Pythonのfor文や各種内包表記にはカウンターがありません。

ループの回数を使って何かをしたい場合には別途カウント保持用の変数を定義してループの中でインクリメントしたりしていませんか?

標準関数のenumerateを使いましょう

>>> for i, x in enumerate(sequencial):
...   print(f'{i}: object is {x}')
...
0: object is spam
1: object is egg
2: object is ham

enumerateは、カウンターと中身をタプルで返すgeneratorです。

>>> z = enumerate(sequencial)
>>> z.__next__()
(0, 'spam')
>>> z.__next__()
(1, 'egg')
>>> z.__next__()
(2, 'ham')

カウンターの開始を1にしたい場合などは第2引数に開始したい値を指定します。

>>> for i, x in enumerate(sequencial, 1001):
...   print(f'{i}: object is {x}')
...
1001: object is spam
1002: object is egg
1003: object is ham

5. Shelve

最後はPythonの永続化に関するTIPSです。

10月にPython3.10がリリースされましたね。せっかくなので3.10に関連したものを、というテイで紹介します。

Python3.10からshelveのdefault pickle protocolが、Pickle.DEFAULT_PROTOCOLを使うようになりました。

実はPython3.8からPickle.DEFAULT_PROTOCOLが4になったのですが、shelveのdefault pickle protocolは3がハードコードされたままでした。 shelveのデフォルトはなぜ3のままなんだ?というイシューが2018年に起票され、しばらくのちに「オット勘違いしてた!」みたいなやりとり がかわされ、Python3.10がリリースされるタイミングで修正されたのです。

これがPython3.10関連だなんて無理矢理過ぎる…というツッコミは甘んじて受けましょう。

shelveってなんだ?

shelveは、PickleというPythonのオブジェクトを直列化する仕組みを使って、オブジェクトを永続化するモジュールです。

簡単に言えば、オブジェクトをそのままファイルに格納し、後から復元できるものです。クラスのインスタンスなども型情報を保ったまま直列化できます。

辞書ライクな記述で簡単に永続化・復元できます。

永続化

shelveモジュールにファイル名を指定してopenし、dictに対する操作のようにオブジェクトを設定するだけです。

>>> with shelve.open('{SHELVE_FILE}') as db:
  db['lst'] = lst

これで、lstというオブジェクトがファイルへ書き出されます。

復元

永続化と同様にファイル名を指定してopenし、dictに対する操作のようにオブジェクトを取り出すだけです。

with shelve.open('{SHELVE_FILE}') as db:
  lst = db['lst']

これで、ファイルからオブジェクトが復元され、lstという変数にアサインされます。

shelveとjson

Pythonのオブジェクトをファイルに保存したいな、となった場合にはjsonを思いつく人が多いのではないでしょうか。

jsonは別のプログラミング言語とのやりとりにも使えるなど適した用途は違うのですが、わかりやすいので少し比べてみましょう。

といっても、jsonでいかに複雑な構造のデータを永続化・復元するかなどは趣旨が違ってしまいます。今回は物量多めなオブジェクトを永続化・復元したときに速度やファイルサイズにどんな違いがあるかを見てみましょう。

timeitを使って、永続化・復元の速度を見てみる

timeitはコード片の実行時間を計測できるモジュールです。 今回は計測の前準備のsetupや計測するコード片のstmtはあらかじめモジュールに定義しておきました。

prepare.py

import timeit

# 永続化で使うコード

SETUP_BASE = """
import json, os, pathlib, random, shelve
pathlib.Path('%(file_name)s').touch()
os.unlink('%(file_name)s')
lst = list(range(100_000_000))
random.shuffle(lst)
"""

SHELVE_FILE = 'number_list'
JSON_FILE = 'number_list.json'

SHELVE_SETUP = SETUP_BASE % {'file_name': SHELVE_FILE}

SHELVE_STMT = f"""
with shelve.open('{SHELVE_FILE}') as db:
  db['lst'] = lst
"""

JSON_SETUP = SETUP_BASE % {'file_name': JSON_FILE}

JSON_STMT = f"""
with open('{JSON_FILE}', 'w') as f:
  json.dump(lst, f)
"""

# 復元で使うコード

READ_SETUP = "import json, shelve"

SHELVE_READ_STMT = f"""
with shelve.open('{SHELVE_FILE}') as db:
  lst = db['lst']
"""

JSON_READ_STMT = f"""
with open('{JSON_FILE}') as f:
  lst = json.load(f)
"""

モジュールを読み込んだ状態でREPLを始めるには -i オプションを使います。

$ python -i prepare.py

この時点では、timeitで使うコード片を文字列で持っているだけなので時間はかかりません。

計測する

timeitは、setupに指定されたコードで前準備をします(計測対象外)。 前準備の後、stmtのコード片をnumber回実施してかかった時間を計測します(指定回数分にかかった合算値を秒で出力します)

100,000,000個の数字のlistを生成して中身をshuffle(これが前準備)したものを永続化・復元するだけです。この永続化と復元をそれぞれ計測しました。

shelveとjsonで永続化を計測

種別 10回の永続化にかかった時間
shelve 46.9秒
json 328.4秒

shelveに比べてjsonは7倍程度の時間がかかりました。

>>> # shelve
>>> timeit.timeit(
...   stmt=SHELVE_STMT,
...   setup=SHELVE_SETUP,
...   number=10,
... )
46.956581500009634
>>> # json
>>> timeit.timeit(
...   stmt=JSON_STMT,
...   setup=JSON_SETUP,
...   number=10,
... )
328.4802591250045

shelveとjsonでそれぞれ復元を計測

種別 10回の復元にかかった時間
shelve 28.4秒
json 62.9秒

shelveに比べてjsonは2倍強の時間がかかりました。永続化に比べると差は小さいですね。

>>> # shelve
>>> timeit.timeit(
...   stmt=SHELVE_READ_STMT,
...   setup=READ_SETUP,
...   number=10,
... )
28.46150470804423
>>> # json
>>> timeit.timeit(
...   stmt=JSON_READ_STMT,
...   setup=READ_SETUP,
...   number=10,
... )
62.958307916997

前準備でしている「listを生成してshuffleする」コードは、10回実施すると514秒時間がかかります。

>>> timeit.timeit(
...   stmt="lst = list(range(100_000_000));random.shuffle(lst)",
...   setup="import random",
...   number=10,
... )
514.6721361249802

Pickle Protocol 3は?

shelveにprotocol 3を指定すると驚きの結果に。

>>> # Pickle Protocol 3で永続化
>>> SHELVE3_FILE = 'number_list_protocol3'
>>> SHELVE3_SETUP = SETUP_BASE % {'file_name': SHELVE3_FILE}
>>> 
>>> SHELVE3_STMT = f"""
... with shelve.open('{SHELVE3_FILE}', protocol=3) as db:
...   db['lst'] = lst
... """
>>> timeit.timeit(
...   stmt=SHELVE3_STMT,
...   setup=SHELVE3_SETUP,
...   number=10,
... )
46.00301795901032

永続化に関しては誤差の範囲内に見えますが…

>>> # Pickle Protocol 3で永続化したものを同様に復元
>>> SHELVE3_READ_STMT = f"""
... with shelve.open('{SHELVE3_FILE}', protocol=3) as db:
...   lst = db['lst']
... """
>>> timeit.timeit(
...   stmt=SHELVE3_READ_STMT,
...   setup=READ_SETUP,
...   number=10,
... )
67.97376979200635

復元が2倍遅くなってしまって、jsonよりも遅いという驚きの結果に…。

結果

jsonを使った永続化の遅さが目立ちます。ファイルサイズもjsonが2倍近いですね。 もちろん、どの永続化の手法を使うのが良いかは用途次第です。目的にあった方法を使いましょう。

種別 永続化1回の時間 復元1回の時間 ファイルサイズ
shelve 4.7秒 2.8秒 500154368 byte
json 32.8秒 6.2秒 988888890 byte
shelve (Pickle Protocol 3) 4.6秒 6.7秒 500088832 byte

実は Python3.9でも、shelveのprotocolに4を指定すれば復元が速くなります というのが5つ目のTIPSということでよろしいでしょうか?

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。