Daydreaming in Brookline, MA

Effective Python一人輪読会(Item 75 to 90)

Table of Contents

1 Chapter 9: テストとデバッグ

1.1 Item 75: デバッグ用表示にはreprを使え

print文を使ったデバッグは有用です。デバッグにおいてはintの5とstrの'5'の違いが重要になってくるので、reprを使って表示するべきです。

普通に作ったクラスを表示しても有用な情報は表示されないので、 __repr__ を用意するか __dict__ を表示します。本書には自分で作ったクラスなら前者をするように書いてありますが、 __repr__ 自体が信用できないケースもあるような気がするので、 __dict__ だけで十分なような。いや、__dict__は見づらいですね。

F-Stringsの {}f'...{..!r}...' のように !r サフィックスを付けるとrepr扱いになります。

1.2 Item 76: TestCaseのサブクラスを使って関連する挙動を検証せよ

ここではunittestを勧めていますが、pytestの方が良さそうです。

1.3 Item 77: setUp, tearDown, setUpModule, tearDownModuleを使って各テストを独立させよ

pytestにも同様の機能がありますが、よりパワフルなfixtureメカニズムが使えるかも知れません。

def setup_module(module):
def teardown_module(module):

@classmethod
def setup_clas(cls):
@classmethod
def teardown_class(cls):

def setup_method(self, method):
def teardown_method(self, method):

def setup_function(function):
def teardown_function(function):

1.4 Item 78: 複雑な依存関係を持つコードをテストするために Mocks を使え

pytestのmonkeypatch fixtureが相当するようです。

1.5 Item 79: Mockingとテストを容易にするため依存関係をカプセル化せよ

この節もunittestをベースに話が進むため、斜め読みです。

独立した関数をクラスメソッドにしたりすると、テストが容易になる場合があるそうです。テストを読みやすくするためにもリファクタリングするとよい、とのこと。

1.6 Item 80: pdbを使って対話的にデバッグせよ

pdbの使い方が衝撃的です。コードの止めたい部分に breakpoint() を埋め込みます。 以下の例では、 if err_2 >= 1: で止めたい条件を絞り込んでいます。

import math

def compute_rmse(observed, ideal):
    total_err_2 = 0
    count = 0
    for got, wanted in zip(observed, ideal):
        err_2 = (got - wanted) ** 2
        if err_2 >= 1:  # Injected for pdb
            breakpoint()  # Breakpoint will trigger pdb 
        total_err_2 += err_2
        count += 1
    mean_err = total_err_2 / count
    rmse = math.sqrt(mean_err)
    return rmse

result = compute_rmse(
    [1.8, 1.7, 3.2, 6],
    [2, 1.5, 3, 5])
print(result)

この状態で普通に実行すると、 breakpoint() の次の行(の直前)でpdbが起動します。変数を見るなり、ステップ実行するなりします。

pdbコマンドチートシート:

s - step; ステップ実行
n - next; 1行実行。関数だったら戻るまで実行。
c - continue; 次のブレークポイントまで実行再開
locals() - ローカル変数表示
help
q - quit

post-mortemデバッグは以下のように実行します。例外が出たらpdbが起動します。変数の値を見るくらいしかできませんが。。。

python -m pdb -c continue <filename>.py
あるいは、
>>> import my_module
>>> my_module.some_func()
Traceback ...<snip>
>>> import pdb; pdb.pm()

これまでprint文デバッグばかりでしたが、pdbも使うようにしたいと思います。

1.7 Item 81: メモリの利用状況とリークを調べるためtracemallocを使え

まずはメモリを浪費する準備です。

import os
class MyObject:
    def __init__(self):
        self.data = os.urandom(100)

def get_data():
    values = []
    for _ in range(100):
        obj = MyObject()
        values.append(obj)
    return values

def run():
    deep_values = []
    for _ in range(100):
        deep_values.append(get_data())
    return deep_values

tracemallocを使います。メモリ浪費関数(run())の前後でメモリのスナップショットを取り、差分を見ます。

import tracemalloc
tracemalloc.start(10)  # Set stack depth
time1 = tracemalloc.take_snapshot()
x = run()
time2 = tracemalloc.take_snapshot()
stats = time2.compare_to(time1, 'lineno')
for stat in stats[:3]:
    print(stat)
>>>
t.py:4: size=2314 KiB (+2314 KiB), count=29993 (+29993), average=79 B
t.py:9: size=469 KiB (+469 KiB), count=10001 (+10001), average=48 B
t.py:10: size=82.8 KiB (+82.8 KiB), count=100 (+100), average=848 B

2 Chapter 10: コラボレーション

2.1 Item 82: コミュニティーが作るモジュールをどこで見つけるかを知れ

Python Package Index (PyPI)はPythonパッケージのセントラルレポジトリです。PyPIからパッケージをインストールするには pip を使います。

2.2 Item 83: 隔離された、再現可能な依存関係のために仮想環境を使え

はい、venvなら使っています。

2.3 Item 84: 全ての関数、クラス、モジュールにdocstringsを書け

docstringsを書きましょう。ただし、type annotationsと重複する情報は不要です。

docstringsは __doc__ でアクセスできます。

print(repr(some_func.__doc__))

ビルトインの pydoc モジュールはWebサーバーを立ち上げ、自作のモジュールも含めてアクセス可能な全てのPythonドキュメントが見られるようになります。

$ python -m pydoc -p 1234
Server ready at http://localhost:1234/
Server commands: [b]rowser, [q]uit
server> b

2.4 Item 85: モジュールを管理し、安定したAPIを提供するためにパッケージを使え

ディレクトリに __init__.py を入れることでパッケージが定義されます。パッケージの第1の目的は、ネームスペースを分けることです。モジュールは一つのファイルです。

異なるパッケージやモジュールから同名のファイルを import する場合、 as で区別しないと後のimportで上書きされてしまいます。

from xxxx.xxxx import inspect as xxxx_inspect
from yyyy.yyyy import inspect as yyyy_inspect

公開するAPIを限定したい場合に __all__ のスペシャルアトリビュートが使えます。 __all__ の値がパブリックAPIとして公開する名前のリストになっています。かなりの規模にならない限り __all__ は不要です。

__init__.py__all__ をaggregateする例:

__all__ = []
from . models import *
__all__ += models.__all__
from . utils import *
__all__ += utils.__all__

2.5 Item 86: デプロイ環境をconfigureするためにモジュールスコープのコードを検討せよ

複数の種類のデプロイ環境が考えられます。OSの種類、開発環境、テスト環境、等。これらはモジュールスコープの普通のPythonステートメントにて区別します。例えば os.environ 環境変数を見るなど。複雑になってきたら、設定ファイルで区別するようにします。

2.6 Item 87: APIから呼び主をinsulateするためにルート例外を定義せよ

モジュールにおいてルート例外を定義するメリットは2つあります。

  1. APIの呼び側のエラーハンドリングミスがわかる
  2. API提供側の検討漏れがわかる

API側でルート例外を定義した例:

class RootError(Exception):
    pass
class InvalidValueError(RootError):
    pass
class InvalidLengthError(RootError):
    pass

def some_func(name, value):
    if len(name) >= 10:
        raise InvalidLengthError('name length must be 9 or less')
    if value < 0:
        raise InvalidValueError('value must be 0 or higher')
    return name + str(value)

上記において、API利用側が InvalidValueError のハンドリングしかしていない時に InvalidLengthError 例外が上がると、利用側のエラーハンドリングミスであることがわかります。

もしPython標準の Exception が上がったときには、API提供側のコードに検討漏れがあったことがわかります。

更に、中間の例外を定義すると例外のカテゴリー分けが可能です。実際に上げるエラーは中間例外を継承した具体的な例外です。

class RootError(Exception):  # ルート
    pass
class ValueError(RootError):  # 中間
    pass
class NameError(RootError):  # 中間
    pass
...

2.7 Item 88: 循環依存を断ち切る方法を知れ

循環依存(import)になったら、相互依存部分を別のモジュールに切り出して依存ツリーのbottomに置くようにリファクタリングすることが望ましいです。

そこまでやらずに済む手っ取り早い方法としては、関数等の中に問題のimportを持ってくるダイナミックインポートをすることが考えられます。実行速度が低下する、エラーが出るタイミングが遅くなるなどの副作用があります。

2.8 Item 89: リファクタリングと利用の移行促進のために warningを検討せよ

APIのI/Fに変更を入れた場合、warningを使って利用者に移行の促進をすることができます。 warnings.warn()stacklevel 引数により、どのレベルの呼び主にwarningを出力するかを指定できます。

import warnings
def require(name, value, default):
    if value is not None:
        return value
    warnings.warn(
        f'{name} will be required soon, update your code',
        DeprecationWarning,
        stacklevel=3)
    return default

def print_distance(speed, duration, *,
                   speed_units=None,
                   time_units=None,
                   distance_units=None):
    speed_units = require('speed_units', speed_units, 'mph')
    time_units = require('time_units', time_units, 'hours')
    distance_units = require('distance_units', distance_units, 'miles')

    norm_speed = convert(speed, speed_units)
    norm_duration = convert(duration, time_units)
    norm_distance = norm_speed * norm_duration
    distance = localize(norm_distance, distance_units)
    print(f'{distance} {distance_units}')

実行すると以下のようになりました。エラーと違って、コードの挙動には影響しません。

import contextlib
import io
fake_stderr = io.StringIO()
with contextlib.redirect_stderr(fake_stderr):
    print_distance(1000, 3,
                   speed_units='meters',
                   time_units='seconds')
print(fake_stderr.getvalue())
>>>
1.8641182099494205 miles
t.py:46: DeprecationWarning: distance_units will be required soon, update your code
  print_distance(1000, 3,

warningはエラーにすることもできます。 -W error を指定します。指定した場合には、実行が途中で終わって最後のdoneメッセージが表示されないことがわかります。

import warnings
print('begin')
warnings.warn('This usage is deprecated',
              DeprecationWarning)
print('done')
>>>
(blg) ~/Documents/AW/py/blg % python t.py  # 普通に起動
begin
t.py:3: DeprecationWarning: This usage is deprecated
  warnings.warn('This usage is deprecated',
done
(blg) ~/Documents/AW/py/blg % python -W error t.py  # エラーを指定
begin
Traceback (most recent call last):
  File "t.py", line 3, in <module>
    warnings.warn('This usage is deprecated',
DeprecationWarning: This usage is deprecated

warningを抑止することもできます。

warnings.simplefilter('ignore')
warnings.warn('How about this?')

よりよいアプローチは、ログにリダイレクトすることです。logging.captureWarningsを呼んで、対応するpy.warningsロガーを設定します。

import io
import logging
import warnings
fake_stderr = io.StringIO()
handler = logging.StreamHandler(fake_stderr)
formatter = logging.Formatter(
    '%(asctime)-15s WARNING] %(message)s')
handler.setFormatter(formatter)

logging.captureWarnings(True)
logger = logging.getLogger('py.warnings')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)

warnings.resetwarnings()
warnings.simplefilter('default')
warnings.warn('This will go to the logs output')

print(fake_stderr.getvalue())
>>>
2020-09-01 08:00:24,490 WARNING] t.py:17: UserWarning: This will go to the logs output
  warnings.warn('This will go to the logs output')

あれ、どこのファイルに出力されているのでしょう??? /var/log/system.logには無さそうですが。。。(macOSです)

2.9 Item 90: バグを取り除くためにtypingを使った静的解析を検討せよ

type annotationsはコードの実行にはほとんど関係しませんが、静的解析でタイプエラーを見つけるのに役に立ちます。これまで何度か出てきていますが、こういうやつです:

def subtract(a: int, b: int) -> int:
    return a - b

mypyのようなツールで実行前の静的解析が可能です。

python -m mypy --strict t.py

type annotationsには性能的な副作用もあるので、全てに付ける必要はありません。公開しているAPIや最も重要な部分だけに絞った方が良さそうです。

3 終わりに

Effective Pythonはなかなか難しい本でしたが、なんとか読み終えることができました。Effective Javaで挫折した経験があるので、少しほっとしています。2冊を比べると、もしかしてEffective Javaの方が難しいのでしょうか。

3.1 感想

3.1.1 内容全般

評判に違わず良書でした。位置づけとしては初中級くらいの人をターゲットにしていると思われますが、それにしては要求される前提知識のレベルが高めだと感じました。本書は、これをしろ、これをするな、という書き方が多いのですが、その理由を実際のコード例で示してくれるため、納得のいく内容になっています。

3.1.2 この本の読み方

この本はわからないところが出てきたときに、自分でどれらけ調べられるかで、読み進められるかどうかが決まってくると思います。その際にReal Pythonのサイトにはとてもお世話になりました。フレンドリーな見かけによらず意外と中上級向けの解説記事があって助かりました。

この本のコードは半分くらい写経して、実際に動かしてみました。実際に動かしてみると(主にTYPOですが)エラーがたくさん出ます。これらのエラーを直すのに改めてコードを読み直したりして、結構勉強になりました。

3.1.3 第2版について

第2版を読んだのは正解でした。この版ではPython 3.8までの機能を普通に使っています。asyncio周りは動きが速く、最新の情報を元にしないとすぐに陳腐化してしまいますし、特にWalrus operator(:=)はエレガントなコードを書くために必須と思いました。

3.1.4 前後参照の多さ

この本は前後の項目への参照が非常に多く、最初は読みづらかったのですが、後半になってくると以前に読んだ内容が多くなり、逆に読みやすくなりました。ただ、後で説明する内容を前の項目で使うことが多く、この構成はどうにならないものかと感じます。

3.1.5 Kindle版

何度も書きましたが、Kindle版はコードのインデントが崩れており、非常に読みづらいです。そのためにビットマップイメージへのリンクがある筈ですが、ビットマップでもインデントがずれている箇所がたくさんあり、辟易しました。Kindle版の技術書は2度と買いません。

また、ビットマップのリンクを見たり、前に出てきたコードを見返したりを多様するためか、Kindleデバイスの電池の減りがとても速かったです。

繰り返しますが、Kindle版は勧めません。紙かPDF版にしておけばよかったです。

3.2 その次

fluent pythonに行くか、python cookbookに行くかでまだ悩んでいます。。。