try文とexceptを覚えて、エラーが起きてもプログラムが止まらないように書けるようになった。そんな段階まで来た人は多いと思います。
ところが自分で関数を書く側に回ると、今度はどの例外をraiseすればいいのか迷いませんか。とりあえずValueErrorやExceptionを投げておく、という書き方をしている人も少なくないはずです。
実はPythonでは、Exceptionを継承したクラスを書くだけで、自分専用の例外を作れます。これを独自例外、あるいはカスタム例外と呼びます。
今回は、独自例外の作り方からraise fromによる例外の置き換えまで、順番に見ていきます。実行結果は手元のPython 3.11で確かめたものです。
そもそも独自例外はなぜ必要なのか¶
まずは、組み込みの例外だけで書いたときに何が困るのかを考えてみましょう。ここがわかると、独自例外のありがたみが実感できます。
たとえば、注文を受け付ける関数を作ったとします。在庫が足りないときも、注文数がマイナスのときも、同じValueErrorをraiseしていたらどうなるでしょうか。
呼び出す側は、exceptでValueErrorを受け取ったあと、メッセージの文字列を見て原因を判断するしかありません。メッセージの文言を少し変えただけで、判定の処理が壊れてしまいます。
独自例外を作れば、エラーの種類をクラスで区別できるようになります。exceptに書くクラス名が、そのままエラーの意味を表してくれるわけです。
エラーの読み方そのものに自信がない人は、先にこちらで基本を押さえておくと、この先の話がスムーズに入ってきます。【関連記事】Pythonのエラー文はどこを読めばいい?初心者向けtraceback入門
独自例外のいちばんシンプルな作り方¶
それでは、実際に作ってみましょう。必要なのは、Exceptionを親にしたクラスを1つ書くことだけです。
次のコードは、中身が空の独自例外を定義して、raiseしてみる例です。
class InvalidQuantityError(Exception):
"""注文数がおかしいときのエラー"""
def check_quantity(quantity):
if quantity <= 0:
raise InvalidQuantityError(f"注文数は1以上にしてください: {quantity}")
return quantity
try:
check_quantity(0)
except InvalidQuantityError as e:
print("エラー:", e)
# エラー: 注文数は1以上にしてください: 0
クラスの中身はdocstringだけですが、raiseしてexceptで受け取るところまで、ふつうの例外と同じように動きます。
名前はErrorで終わらせる¶
独自例外の名前は、最後をErrorにするのが慣習です。Python公式のチュートリアルでも、標準の例外と同じようにErrorで終わる名前を付けることが多いと紹介されています。
BaseExceptionではなくExceptionを継承する¶
もうひとつ大事なのが、継承する親クラスです。Pythonの例外には、Exceptionのさらに上にBaseExceptionという大もとのクラスがあります。
BaseExceptionを直接継承すると、except Exception: で捕まえられない例外になってしまいます。手元で試したところ、BaseExceptionを継承したクラスはexcept Exceptionをすり抜けて、外側まで飛んでいきました。
BaseExceptionの直下には、Ctrl+Cで発生するKeyboardInterruptなど、プログラムを止めるための特別な例外が置かれています。ふつうのエラーはExceptionの下に作るのが原則です。
親クラスを作ってエラーをまとめる¶
独自例外を何種類か作るようになったら、ぜひ取り入れてほしい設計があります。自分のプログラム専用の親クラスを1つ作り、すべての独自例外をその子にする方法です。
次の表は、注文処理を例にした例外の親子関係です。
| クラス名 | 親クラス | 意味 |
|---|---|---|
| OrderError | Exception | 注文処理で起きるエラー全体 |
| OutOfStockError | OrderError | 在庫が足りない |
| InvalidQuantityError | OrderError | 注文数がおかしい |
| PaymentError | OrderError | 支払いに失敗した |
こうしておくと、呼び出す側は捕まえる範囲を自由に選べます。在庫不足だけを特別に扱いたいならOutOfStockErrorを、注文まわりのエラーをまとめて扱いたいならOrderErrorを書けばよいのです。
私は10年ほどエンジニアとして開発に関わってきましたが、外部ライブラリを選ぶときは、この例外の設計を必ず確認するようになりました。以前、例外が整理されていないライブラリでメッセージの文字列比較を書くはめになり、バージョンアップのたびに壊れて苦労したからです。
例外にエラーの情報を持たせる¶
独自例外はクラスなので、ふつうのクラスと同じように属性を持たせられます。メッセージの文字列だけでなく、エラーの原因になった値も一緒に渡せるのが強みです。
次のコードは、在庫不足のエラーに、商品名と注文数と在庫数を持たせた例です。親クラスのOrderErrorでまとめて受け取る書き方もあわせて載せています。
class OrderError(Exception):
"""注文処理で起きるエラーの親クラス"""
class OutOfStockError(OrderError):
"""在庫が足りないときのエラー"""
def __init__(self, item, requested, stock):
self.item = item
self.requested = requested
self.stock = stock
super().__init__(f"{item}の在庫が足りません(注文{requested}個 / 在庫{stock}個)")
class InvalidQuantityError(OrderError):
"""注文数がおかしいときのエラー"""
STOCK = {"ノート": 3, "ペン": 10}
def order(item, quantity):
if quantity <= 0:
raise InvalidQuantityError(f"注文数は1以上にしてください: {quantity}")
stock = STOCK.get(item, 0)
if quantity > stock:
raise OutOfStockError(item, quantity, stock)
STOCK[item] = stock - quantity
return f"{item}を{quantity}個注文しました"
for item, quantity in [("ペン", 2), ("ノート", 5), ("ペン", 0)]:
try:
print(order(item, quantity))
except OutOfStockError as e:
print("在庫不足:", e, "/ あと", e.stock, "個なら注文できます")
except OrderError as e:
print("注文エラー:", e)
# ペンを2個注文しました
# 在庫不足: ノートの在庫が足りません(注文5個 / 在庫3個) / あと 3 個なら注文できます
# 注文エラー: 注文数は1以上にしてください: 0
在庫不足のときは、exceptの中で e.stock を取り出して、あと何個なら注文できるかを案内しています。メッセージの文字列から数字を抜き出す必要はありません。
exceptを並べる順番にも注意してください。上から順に判定されるので、子クラスのOutOfStockErrorを先に、親クラスのOrderErrorを後に書きます。逆にすると、在庫不足もOrderErrorのほうで受け止められてしまいます。
super().__init__を忘れるとどうなるか¶
__init__を自分で書くときに、つい忘れがちなのが super().__init__の呼び出しです。これを書かないと、エラーを表示したときのメッセージがおかしくなります。
試しに、super().__init__を呼ばないOutOfStockErrorを作って表示してみました。すると、メッセージの代わりに ('ノート', 5, 3) という、渡した引数のタプルがそのまま出てきました。
super()の働きがまだあいまいな人は、こちらの記事で親クラスの呼び出し方を整理できます。【関連記事】Pythonのsuper()とは?親クラスを名前で書いてはいけない理由を初心者向けに解説
raise fromで元のエラーをつなげる¶
実務でよくあるのが、ライブラリから飛んできた例外を、自分のプログラムの例外に置き換えたい場面です。たとえば、設定を辞書から読むときに出たKeyErrorを、ConfigErrorという独自例外に変えたいとします。
このときに使うのが raise from という書き方です。次のコードを見てください。
class ConfigError(Exception):
"""設定の読み込みに失敗したときのエラー"""
def load_setting(settings, key):
try:
return settings[key]
except KeyError as e:
raise ConfigError(f"設定が見つかりません: {key}") from e
load_setting({}, "DB_URL")
これを実行すると、tracebackには2つの例外が表示されます。上に元のKeyError、下に新しいConfigErrorが並び、そのあいだに次の一文がはさまります。
The above exception was the direct cause of the following exception:
上の例外が、下の例外の直接の原因です、という意味です。元のエラーの情報が消えずに残るので、あとから調査するときに原因までたどれます。
KeyErrorそのものについては、こちらの記事でくわしく解説しています。【関連記事】PythonでKeyErrorが出る理由とは?辞書dictで初心者がハマる原因と対処法
fromを付けなかったときとの違い¶
では、fromを付けずに、exceptの中でただConfigErrorをraiseしたらどうなるでしょうか。実は、この場合も元の例外はtracebackに表示されます。
違うのは、2つの例外のあいだに出てくる一文です。fromを付けないと、次のように表示されました。
During handling of the above exception, another exception occurred:
exceptの中でうっかり別のエラーが出たときと同じ表示なので、意図した置き換えなのか事故なのかを区別できなくなります。置き換えるつもりなら、fromを付けて意図をはっきり示しましょう。
元の例外をあえて見せたくないときは、from None と書きます。手元で試すと、tracebackにはConfigErrorだけが表示され、KeyErrorの部分は消えました。
3つの書き方の違いを、表にまとめておきます。
| 書き方 | tracebackの表示 | 使いどころ |
|---|---|---|
| raise 新しい例外 from e | direct causeの一文で2つを表示 | 意図して例外を置き換えるとき |
| raise 新しい例外 | During handlingの一文で2つを表示 | 置き換えには使わない |
| raise 新しい例外 from None | 新しい例外だけを表示 | 元のエラーが利用者に不要なとき |
Python 3.11からのadd_noteで補足情報を足す¶
最後に、比較的新しい機能を紹介します。Python 3.11から、例外にあとからメモを書き足せる add_note というメソッドが使えるようになりました。
たとえば、CSVファイルを1行ずつ処理していて、ある行でValueErrorが起きたとします。エラーそのものは置き換えずに、何行目で起きたのかだけを付け足したい、という場面にぴったりです。
次のコードは、例外にメモを追加してから、もう一度raiseし直す例です。
rows = ["10", "20", "abc", "40"]
for line_no, value in enumerate(rows, start=1):
try:
number = int(value)
except ValueError as e:
e.add_note(f"users.csv の {line_no} 行目で発生しました")
raise
実行すると、tracebackの最後に、元のエラーメッセージに続けてメモの一文が表示されました。追加したメモは notes という属性にリストで保存されていて、プログラムから読み出すこともできます。
raise fromとの違いは、例外の種類を変えないところです。例外のクラスはそのままに、調査の手がかりだけを増やしたいときはadd_note、自分のプログラムの例外に置き換えたいときはraise from、と使い分けるとよいでしょう。
独自例外を作るときに気をつけたいこと¶
便利な独自例外ですが、作りすぎると、かえってコードが読みにくくなります。ここでは、実務で意識しておきたいポイントを見ておきましょう。
まず、組み込みの例外で十分に意味が伝わるなら、無理に作る必要はありません。引数の型が違うならTypeError、値の範囲がおかしいならValueErrorで、たいていの読み手には伝わります。
独自例外が力を発揮するのは、呼び出す側がそのエラーだけを区別して処理したいときです。exceptで個別に受け取る予定がないなら、新しいクラスを増やしても読む量が増えるだけになります。
例外をそもそも設計の段階でどう考えるかは、こちらの記事でも取り上げています。【関連記事】バグを出す前に例外を予見する!不測の事態に備えるプログラミングの考え方
私自身、若いころは機能ごとに細かく例外を作りすぎて、似たような名前のクラスが20個近く並んでしまったことがありました。結局ほとんどのexceptでは親クラスしか使っておらず、レビューで整理を求められたのを覚えています。
まとめ¶
ここまで、Pythonの独自例外について見てきました。最後に、今回の内容を振り返っておきましょう。
| やりたいこと | 書き方 |
|---|---|
| 独自例外を作る | class 名前Error(Exception) |
| エラーをまとめて捕まえる | 親クラスを作って子クラスに継承させる |
| エラーに値を持たせる | __init__で属性を設定し、super().__init__にメッセージを渡す |
| 別の例外に置き換える | raise 新しい例外 from e |
| 元のエラーを隠す | raise 新しい例外 from None |
| 補足情報を足す | e.add_note(メモ)(Python 3.11以降) |
独自例外は、クラスを1つ書くだけで作れる手軽な仕組みです。それでいて、エラーの意味をコードの上ではっきりさせ、呼び出す側の処理をずっと書きやすくしてくれます。
まずは、自分のプログラム用の親クラスを1つ作るところから始めてみてください。ValueErrorを投げていた場所を少しずつ置き換えていくと、エラー処理の見通しがよくなるのを実感できるはずです。
ここまでお読みいただきありがとうございました。