Edited at

Pythonで例外を投げるときのベストプラクティス

More than 1 year has passed since last update.


目的

ライブラリ開発やデータ分析ツールの作成の際に適切に例外処理を行うことで、頑健かつバグの発見をしやすいシステムを作れるようになる。Pythonは他言語と比べて例外処理のオーバーヘッドが軽いので積極的に利用することで、高速かつ安全なコードを書くことができます。


推奨行為

例外処理を定義する際に従うべき項目についてまとめました。


投げる例外は適切に文書化する

最も重要なことです。定義した例外がどういうものなのかを適切にドキュメント化しましょう。毎度毎度書くのは面倒なので、名前だけで伝わるような命名にする、__str__ に詳細な説明を書く、もしくはSphinxなどのドキュメント自動生成ツールを使うのがオススメです。ドキュメント生成の自動化については、マスターブランチのマージと同時にドキュメントをS3に公開する。が参考になります。


ライブラリ共通の例外を作成し、全てそれを継承させる

これをすることで、そのライブラリが投げた全ての例外を簡単に受け取ることができます。

class VirtualCoinError(Exception):

"仮想通貨を扱うライブラリが投げる例外の基底クラス"

class CannotBuyError(VirtualCoinError):
"仮想通貨が買えなかったときに投げるエラー"

class BrandNotFoundError(VirtualCoinError):
"指定された銘柄がなかったときに投げるエラー"

こうしておくことで、

try:

# 仮想通貨に関する何らかの処理
except VirtualCoinError:
# 仮想通貨ライブラリがエラー吐いたときの共通処理

のように書くことができます。特に開発中・デバッグ中にこの恩恵を得ることができます。仮想通貨の例を出したのには実は理由がありまして、以前Slack + GASで社内仮想通貨取引所を実装 Part1という記事を書いたので、それを引きずっているだけです :smiley:


例外は適切なレイヤーで処理する

エラーを内部で握りつぶす処理は避けるべきです。IndexErrorなどの期待していない入力に基づくエラー関係はユーザーに委ねましょう。


Duck typingを活用する

そもそもDuck Typingってとなった人はWikiへどうぞ。例外処理やValidationにはLBYL(Look Before You Leap)とEAFP(Easier to Ask for Forgiveness than Permission)という概念があり、要は前者は「石橋を叩いて渡る」方式で、後者は「当たって砕けろ」方式です。以下はobjectをprintする関数の実装の仕方です。コードはLBYL vs EAFPから引用しました。

def print_object_lbyl(some_object):

# Check if the object is printable...
if isinstance(some_object, str):
print(some_object)
elif isinstance(some_object, dict):
print(some_object)
elif isinstance(some_object, list):
print(some_object)
# 97 elifs later...
else:
print("unprintable object")

def print_object_eafp(some_object):
# Check if the object is printable...
try:
printable = str(some_object)
except TypeError:
print("unprintable object")
else:
print(printable)

LBYL版では新しいオブジェクトを作る度にif文を追加する羽目になるのでバグを生みかねません。一方、EAFP版では対象のobjectに__str__が実装されていれば実行できるので、コードも短く可読性が増していることが分かります。


禁則行為

こちらでは逆に例外処理を行う上でやってはいけないことをまとめてあります。


フローコントロールに利用

Goto 的な感覚で利用するのは禁忌です。例えば、リストから該当する文字列を検索するプログラムを作る際、見つからなかった場合に例外を投げるのは誤りです。Indexを超えた場合など、起こるべきではないケースで例外を返しましょう。

def string_finder_wrong(l: Sequence[str], s: str, end: int) -> str:

result = None
for i in range(end):
if (l[i] == s):
result = s

if result is None:
raise NotFoundError # 見つからなかったときに例外処理でコントロールするのは良くない

def string_finder_good(l: Sequence[str], s: str, end: int) -> Optional[str]:
if (len(l) <= end):
raise IndexError # OutOfBoundsなので例外を投げる

result = None
for i in range(end):
if (l[i] == s):
result = s

return result # 該当しなかった場合はNoneを返す


内部実装が漏れる例外

カプセル化が保たれるように例外を設計しましょう。例えば、URLからHTMLを返すような実装をするときに、キャッシュを使いたいとします。その際に、キャッシュのファイルが開けないときに IOError を投げるのは誤りです。というのも、内部実装は変更されることがあり、今後はS3にキャッシュを保存し、それをAthenaでアクセスしたくなるかもしれません。内部実装によって例外が変わる場合、実装を変えるたびこの機能のユーザーは例外処理を書きなおさなければならなくなります。

def url2html_wrong(url: str) -> str:

try:
with open(CACH_FILE):
# ファイルの中身を操作する何かしらの操作
except IOError:
raise IOError # 内部でファイルIOを利用していることがバレる。

def url2html_good(url: str) -> str:
try:
with open(CACH_FILE):
# ファイルの中身を操作する何かしらの操作
except IOError:
raise CashNotFoundError # キャッシュ周りでエラー吐いたことが伝わる


まとめ

さまざまな観点でPythonでの例外処理についてまとめました。記事中の誤りや「他にもxxしておくと良いコードになるよ」などの情報があればコメントください:bow:


参考