EurekaMoments

新米エンジニアが一人前を目指す修行の日々を記していくブログです。

読書メモ: Effective Python

1. 背景・目的

Pythonコードのより良い書き方を学ぶための技術書として有名なEffective Pythonを友人から借りることが出来たので読んでみました。
今回の記事は、この書籍を読んで参考になった事のメモになります。

Effective Python ―Pythonプログラムを改良する59項目

Effective Python ―Pythonプログラムを改良する59項目

2. PEP8スタイルガイドに従う

  • 構文上意味を持つレベルのインデントには4個の空白を使う。
  • 各行は、長さが79文字かそれ以下とする。
  • 長い式を次の行に続ける時は、通常のインデントに4個の空白を追加してインデントする。
  • ファイルでは、クラスと関数は2行の空白行で分ける。
  • リストの添え字、関数呼び出し、キーワード引数代入では、前後に空白を置かない。
  • 変数代入の前後には、空白を一つ、必ず一つだけを置く。
  • プロテクテッド属性は、_leading_underscoreのように、下線を先頭に付ける。
  • プライベート属性は、__double_undersocoreのように、下線を2つ先頭に付ける。
  • 式全体を否定(if not a is b)するのではなく、否定判定演算子(if a is not b)を使う。
  • 長さを使って(if len(list) == 0)空値([]や''など)かどうかをチェックしない。if not listを使って、空値が暗黙的にFalseと評価されることを使う。

3. シーケンスをどのようにスライスするか知っておく

  • 冗長を避ける。添え字startに0を指定したり、endに列長を指定したりしない。
  • startやendの添え字と一緒にstrideを使わない。strideを使うときは、出来る限り正の値にして、startとendの添え字を省く。strideをstartやendと一緒に使わなければならない場合は、増分だけでの代入とスライスでの代入とに分けて使うように考える。
a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
b = a[::2]  # ['a', 'c', 'e', 'g']
c = b[1:-1] # ['c', 'e']

4. 大きな内包表記にはジェネレータ式を考える

リスト内包表記の問題点は、入力シーケンスの各値に対して一つの要素を含むまったく新しいリストを作りかねないことである。入力が大量だと、膨大な量のメモリを消費してプログラムのクラッシュを引き起こしかねない。

value = [len(x) for x in open('file.txt')]
print(value)

>>>
[100, 57, 15, 1, 12, 75, 5, 85, 89, 11]

この問題を解決するために、リスト内包表記とジェネレータを一般化したジェネレータ式を使う。ジェネレータ式は、リスト内包表記と同じ構文だが、周囲を括弧で括る構文である。

it = (len(x) for x in open('file.txt'))
print(it)

>>>
<generator object <genexpr> at 0x101b81480>

返されたイテレータは、必要に応じて(組み込み関数nextを用いて)ジェネレータ式から次の出力を生成するように、1ステップずつ進めることができる。

print(next(it))
print(next(it))

>>>
100
57

5. try/except/else/finallyの各ブロックを活用する

5.1. finallyブロック

例外を呼び出し元に伝えたいときにはtry/finallyを使う。その例が、下記のようにファイルハンドルを確実に閉じることである。

handle = open('hoge.txt') # IOErrorが起こるかも
try:
    data = handle.read() # UnicodeDecodeErrorが起こるかも
finally:
    handle.close()       # try:の後で必ず実行される

5.2. elseブロック

try/except/elseを使うと、どの例外が自分のコードで扱われ、どの例外が上に伝わるかが明らかになる。tryブロックが例外を起こさなければelseブロックが実行される。elseブロックによって、tryブロックでのコードが最小化できて可読性が向上する。

def load_json_key(data, key):
    try:
        result_dict = json.loads(data) # ValueErrorが起きるかも
    except ValueError as e:
        raise KeyError from e
    else:
        return result_dict['key']      # KeyErrorが起きるかも

6. Noneを返すよりは例外を選ぶ

ある数を別の数で割る関数を書くとする。ゼロ割が起こった場合は、結果が未定義なのでNoneを返すとする。

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

また、これを呼び出すコードでは、戻り値をそれに従って解釈する。

result = divide(x, y)
if result is None:
    print('Invalid input')

しかし、このif文がNoneだけではなくFalseと判定される値を見ていると間違いが起こる。

x, y = 0, 5
result = divide(x, y)
if not result:
    print('Invalid input')

これは、関数からNoneを返すことがエラーに繋がりやすいことを示している。このようなエラーの機会を減らすには2つの方法がある。

  1. 戻り値を2値のタプルにする
def divide(a, b):
    try:
        return True, a / b
    except ZeroDivisionError:
        return False, None
  1. Noneをそもそも返さない
    Noneを返さない代わりに例外を呼び出し元に上げて、その処理をやらせる。上記コードのZeroDivisionErrorをValueErrorに変えて、呼び出し元に対して入力値が適切でないことを示す。
def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError as e:
        raise ValueError('Invalid input') from e

7. リストを返さずにジェネレータを返すことを考える

ジェネレータとはyield式を使う関数のこと。ジェネレータ関数は、呼び出されると実際の作業をせずに直ちにイテレータを返す。appendメソッドを何度も呼び出して最後にリストを返すよりも遥かに読みやすく書ける。

def index_words_iter(text):
    if text:
        yield 0
    for index, letter in enumerate(text):
        if letter == '':
            yield index + 1

ジェネレータ呼び出しで返されるイテレータは、組み込み関数listに渡して、簡単にリストに変換できる。

result = list(index_words_iter(address))

appendメソッドを使う場合のもう一つの問題は、全ての結果を返す前にリストに格納する必要があることである。入力が大量の時には、このためプログラムがメモリを食いつぶしてクラッシュを引き起こしかねない。
次のように、ファイルから1行ずつ入力して1単語ずつyieldで出力するストリーム型のジェネレータを定義する。この関数に必要な作業メモリは、入力の中の行の最大長あればOK。

def index_file(handle):
    offset = 0
    for line in handle:
        if line:
            yield offset
        for letter in line:
            offset += 1
            if letter == '':
                yield offset

8. 可変長位置引数を使って、見た目をすっきりさせる

省略可能な位置引数を使うと、関数呼び出しがずっとすっきりして、見た目の雑音が減らせる。Pythonでは、最後の位置引数の名前に*をつけることで可能となる。

def log(message, *values):
    if not values:
        print(message)
    else:
        values_str = ', '.join(str(x) for x in values)
        print('%s: %s' % (message, values_str))

*valuesの位置引数に複数の引数を渡せるようになる。

log('My numbers are', 1, 2)
log('Hi there')

>>>My numbers are: 1, 2
Hi there

すでにリストがあって、logのような可変長引数関数を呼び出したなら、*演算子を使って呼び出せる。

favorites = [7, 33, 99]
log('Favorite colors', *favorites)

>>>
Favorite colors: 7, 33, 99

9. 辞書やタプルで記録管理するよりもヘルパークラスを使う

  • 値が他の辞書や長いタプルであるような辞書を作るのは止める。
  • 完全なクラスの柔軟性が必要となる前は、軽量で変更不能データのコンテナであるnamedtupleを使う。
  • 内部状態辞書が複雑になったら、記録管理コードを複数のヘルパークラスを使うように変更する。

10. プライベート属性よりはパブリック属性が望ましい

Pythonのクラスの属性の可視化は、パブリック(public)とプライベート(private)の2種類しかない。

class MyObject(object):
    def __init__(self):
        self.public_value = 5
        self.__private_value = 10
    
    def get_private_value(self):
        return self.__private_value

パブリック属性は、オブジェクトのドット演算子で誰もがアクセスできる。

foo = MyObject()
assert foo.public_value == 5

プライベートフィールドは、属性の名前の先頭に下線が2つ付くことで示される。含んでいるクラスのメソッドからは直にアクセスできる。

assert foo.get_private_value() == 10

サブクラスは、親クラスのプライベートフィールドにアクセスできない。例えば下記のようなコードを書くとAttributeErrorが起こる。

class MyParentObject(object):
    def __init__(self):
        self.__private_value = 71
    
class MyChildObject(MyParentObject):
    def get_private_value(self):
        return self.__private_value

baz = MyChildObject()
baz.get_private_value()

このように、サブクラスや外部からアクセスされるべきでない内部APIであることを示すためにプライベートフィールドを使う事は間違いである。プライベート属性を選んだことで、サブクラスがオーバーライドして、面倒で一時的な拡張を行うようにしているだけである。

一般に、保護(プロテックテッド)属性を用いてサブクラスに多くのことをやらせて失敗する方がまだましである。保護フィールドそれぞれに文書化して、サブクラスで使える内部APIがどれであり、どれを使うべきでないかを説明しておくこと。

class MyClass(object):
    def __init__(self, value):
        # This stores the user-supplied value for the object.
        # It should be coercible to a string. Once assigned for
        # the object it should be treated as immutable.
        self._value = value

プライベート属性を使う事を真面目に考えるべきなのは、サブクラスとの名前の衝突を心配しなければならないときだけである。この問題は、親クラスで既に定義されている名前を、知らずに属性を定義したときに生じる。

11. ローカルタイムクロックにはtimeではなくdatetimeを使う

11.1. timeモジュールについて

あるタイムゾーンのローカル時間を別のタイムゾーンの時間に変換する場合、time, lacaltime, strptime関数の戻り値を直接扱ってタイムゾーンの変換をするのはまずい考え方である。例えば、太平洋夏時間のサンフランシスコの出発時間をパースするなら、

time_format = '%Y-%m-%d %H:%M:%S'
parse_time = '%Y-%m-%d %H:%M:%S %Z'
depart_sfo = '2014-05-01 15:45:16 PDT'
time_tuple = strptime(depart_sfo, parse_format)
time_str = strftime(time_format, time_tuple)
print(time_str)

>>>
2014-05-01 15:45:16

しかしながら、strptime関数は、東部夏時間(ニューヨークのタイムゾーン)を見ると例外を起こす。

arrival_nyc = '2014-05-01 23:33:24 EDT'
time_tuple = strptime(arrival_nyc, time_format)

>>>
ValueError: unconverted data remains: EDT

timeモジュールは、複数のローカルタイムに対して適切に働くことができない。この目的には、datetimeモジュールを使うべきである。

11.2. datetimeモジュールについて

datetimeは現在時刻をUTCからローカル時間に変換する事ができる。下記はサンプルコード。

from datetime import datetime, timezone

now = datetime(2014, 8, 10, 18, 18, 30)
now_utc = now.replace(tzinfo=timezone.utc)
now_local = now_utc.astimezone()
print(now_local)

>>>
2014-08-10 11:18:30-07:00

pytzモジュールと組み合わせることで、異なるタイムゾーン間の変換を行える。まずはUTC時間に変換しておき、それからローカル時間に変換する。例えば、NYCのローカル時間をUTC時間に変換すると、

arrival_nyc = '2014-05-01 23:33:24'
nyc_dt_naive = datetime.strptime(arrival_nyc, time_format)
eastern = pytz.timezone('US/Eatern')
nyc_dt = eastern.localize(nyc_dt_naive)
utc_dt = pytz.utc.normalize(nyc_dt.astimezone(pytz.utc))
print(utc_dt)

>>>
2014-05-02 03:33:24+00:00

そして、上記のUTC時間をサンフランシスコのローカル時間に変換するなら下記のようなコードになる。

pacific = pytz.timezone('US/Pacific')
sf_dt = pacific.normalize(utc_dt.astimezone(pacific))
print(sf_dt)

>>>
2014-05-01 20:33:24-07:00

12. 全ての関数、クラス、モジュールについてドキュメンテーション文字列を書く

def文の直後にドキュメンテーション文字列を記述すると、関数にドキュメンテーションを追加することができる。

def palindrome(word):
    """Return True if the given word is a palindrome."""
    return word == word[::-1]

関数の特別な属性docにアクセスすることでPythonプログラムそのものの中からドキュメンテーション文字列を取り出せる。

print(repr(palindrome.__doc__))

>>>
'Return True if the given word is a palindrome.'

12.1. モジュールのドキュメンテーション

モジュールは、トップレベルでドキュメンテーション文字列を記載する。これは、モジュールにある重要なクラスや関数を見つける出発点になる。

# words.py
"""Library for testing words for various linguistic patterns.

Testing how words relate to each other can be tricky sometimes.
This module provaides easy ways to determine when words you've found have special properties.

Available functions:
- palindrome: Determine if a word is a palindrome.
- check_anagram: Determine if two words are anagrams.
...
"""

12.2. クラスのドキュメンテーション

どのクラスもクラスレベルのドキュメンテーション文字列を記載する。第1行は、クラスの目的という1文。続く段落は、クラスの演算の重要な詳細を論じる。
クラスの重要なパブリック属性とメソッドをクラスレベルのドキュメンテーション文字列で記載する。

class Player(object):
    """Represents a player of the game.

    Subclasses may override the 'tick' method to provide
    custom animations for the player's movement depending
    on their power level, etc.

    Public attributes:
    - power: Unused power-ups (float between 0 and 1).
    - coins: Coins found during the level (integer).
    """

12.3. 関数のドキュメンテーション

第1行は、関数が何をするかを記述する。続く段落は、振る舞いと引数について述べる。戻り値があれば述べる。呼び出し元が関数のインターフェースの一部として扱う例外は説明する。

def find_anagrams(word, dictionary):
    """Find all anagrams for a word.

    This function only runs as fast as the test for
    membership in the 'dictionary' container. It will
    be slow if the dictionary is a list and fast if
    it's a set.

    Args:
        word: String of the target word.
        dictionary: Container with all strings that
            are known to be actual words.

    Returns:
        List of anagrams that were found. Empty if
        none were found.
    """

関数のドキュメンテーション文字列を書く際は次のような注意事項がある。

  • 関数が引数を持たず単純な戻り値を返すなら、1文の記述で十分。
  • 関数が何も返さないなら、戻り値について何も書かないのがよい。
  • 関数が通常の演算で例外を起こさないはずなら、それについては何も述べない。
  • 関数が可変長引数やキーワード引数を取るなら、引数リストの中で*argsと**kwardsをその目的とともに述べる。
  • 関数がデフォルト値のある引数を取るなら、そのデフォルト値について述べる。
  • 関数がジェネレータなら、ドキュメンテーション文字列でジェネレータが何をyieldするか述べる。
  • 関数がコルーチンなら、ドキュメンテーション文字列でコルーチンが何をyieldし、yield式から何を受け取ると期待し、いつイテレーションを停めるかを記述する。

13. 最適化の前にプロファイル

プログラムを最適化する前には、その性能を直接測っておく。Pythonは、組み込みのプロファイラ(profiler)を提供していて、プログラムのどの部分が性能に影響しているか確認できる。
Pythonは、2つの組み込みプロファイルを提供している。1つはPythonのみで書かれたモジュール(profile)で、もう1つはC提供モジュール(cProfile)である。cProfile組み込みモジュールの方が、プロファイルを取っているときに、プログラムの性能への影響が最小なので、適している。
cProfileモジュールからProfileオブジェクトをインスタンス化して、runcallメソッドを使ってテスト関数を実行する。

from cProfile import Profile

profiler = Profile()
profiler.runcall(test)

テスト関数の実行が終わったら、pstats組み込みモジュールとそのStatsクラスを使って性能についての統計情報を取り出せる。

stats = Stats(profiler)
stats.strip_dirs()
stats.sort_stats('cumulative')
stats.print_stats()

プロファイラの統計カラムの意味は下記の通り。

  • ncalls: プロファイル期間に関数が呼ばれた回数
  • tottime: 他の関数呼び出しに費やした時間を除いた関数実行に費やした秒数
  • tottime percall: 他の関数呼び出しに費やした時間を除いた、関数が1回あたり呼び出されて実行に要した平均秒数。tottimeをncallで割ったもの。
  • cumtime: それが呼び出している関数の実行に費やした時間も含めた関数実行に要した累積秒数。
  • cumtime percall: 呼び出している関数の実行に費やした時間も含めた関数が1回あたりに呼び出されて実行に要した平均秒数。