【学習メモ】Pythonの例外処理
例外処理ってむずかしいよね
例外処理とは
Pythonでは例外(エラー)が発生すると、例外オブジェクトを作成し、例外の情報と例外発生個所に関する情報を出力して処理を停止する。
当たり前だけど、エラーが起きるとプログラムが止まるということ。
# except_divide.py
def divide(a, b):
result = a / b
print("計算結果:", result)
divide(10, 0)
print("処理を終了します")
これを実行すると、
Traceback (most recent call last):
File "except_divide.py", line 5, in <module>
divide(10, 0)
~~~~~~^^^^^^^
File "except_divide.py", line 2, in divide
result = a / b
~~^~~
ZeroDivisionError: division by zero
ZeroDivisionError = ゼロで割ってますよ~ のエラーが出る。
エラーが出たところでプログラムが止まるので、print("処理を終了します")の行は実行されずに終わる。
例外処理を入れてエラーを捕捉することで、プログラムを停止させずに処理を続けることができるようになる。
例外処理の基本構文
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("エラー: 0 で割ることはできません。")
else:
print("計算結果:", result)
finally:
print("処理を終了します。")
print("divide(10, 2)を実行")
divide(10, 2) # 通常計算
print("divide(10, 0)を実行")
divide(10, 0) # ゼロ除算
↓ 実行結果
divide(10, 2)を実行
計算結果: 5.0
処理を終了します。
divide(10, 0)を実行
エラー: 0 で割ることはできません。
処理を終了します。
-
try:- 例外が発生する可能性がある処理
-
except 捕捉したい例外クラス:- 例外が発生した時の処理
-
else:-
try節で例外が発生しなかった場合のみ実行される処理 -
else節を設ける場合、すべてのexcept節より後ろに置く
-
-
finally:- 例外の発生の有無にかかわらず必ず実行される処理
複数の例外を捕捉する
except節では複数の例外を捕捉することもできる。
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("エラー: 0 で割ることはできません。")
except TypeError:
print("エラー: 文字列で計算することはできません。")
print("divide(10, '2')を実行")
divide(10, '2') # 文字列で計算
print("divide(10, 0)を実行")
divide(10, 0) # ゼロ除算
↓ 実行結果
divide(10, '2')を実行
エラー: 文字列で計算することはできません。
divide(10, 0)を実行
エラー: 0 で割ることはできません。
こんなこともできる。
def divide(a, b):
try:
result = a / b
print(f"計算結果は {result} です。")
except Exception as e:
print(f" {type(e)} の {e} が発生しました。")
divide(10, '2') # 文字列で計算
divide(10, 0) # ゼロ除算
↓ 実行結果
<class 'TypeError'> の unsupported operand type(s) for /: 'int' and 'str' が発生しました。
<class 'ZeroDivisionError'> の division by zero が発生しました。
except節のところで捕捉したい例外クラス(今回はexcept Exception as e:)を指定して、「as 一時変数名」(今回はas e)で例外オブジェクトを受け取って表示させる。
今回の処理だと文字列で計算した場合もゼロ除算した場合でも同じ処理が行われ、例外のタイプとメッセージが表示される。
「この処理で何のエラーが発生するかわからない!」という段階なら、上記のようにとりあえず Exception を指定してエラーを捕捉し、デバッグに使うのがよいかも。
except節で指定するクラスは(ZeroDivisionError, TypeError, OverflowError)のように、複数クラスをタプル形式で指定することもできる。
独自の例外クラスの作成
独自クラスを定義して、例外捕捉もできる。
Exceptionクラスを継承させて独自クラスを作ろう。
# 独自クラスの基底クラス
class InvalidAgeError(Exception):
"""年齢が不正な場合の例外"""
pass
def check_age(age):
if age < 0:
raise InvalidAgeError("年齢は0以上で入力してください")
print(f"{age}歳ですね!")
try:
age = int(input("年齢を入力してください: "))
check_age(age)
except InvalidAgeError as e:
print("エラー:", e)
except ValueError:
print("数値を入力してください")
↓ 実行結果
年齢を入力してください: 23
23歳ですね!
年齢を入力してください: -1
エラー: 年齢は0以上で入力してください
年齢を入力してください: a
数値を入力してください
独自クラスを使うメリットとしては以下。
上記くらいの短いコードだとあんまり意味を感じないけど、でかいサービスやアプリだと重要度が増しそう。
-
可読性
- InvalidAgeError……年齢制限に引っかかってそう、のように、発生する・しうる例外がクラス名から予測できる。
※クラス名をちゃんと定義しないとこのメリットは受けられない
- InvalidAgeError……年齢制限に引っかかってそう、のように、発生する・しうる例外がクラス名から予測できる。
-
分岐処理
- 不正な入力の時はこの処理、値がないときはこの処理、というように、エラーの種類で処理を分けられる
-
情報付加
- 標準例外は用途が一般的で、アプリ固有の情報(どのフィールドが不正か、どの値が閾値を超えたかなど)を表現するには不向き。
独自例外なら、必要な属性を自由に追加できる。
- 標準例外は用途が一般的で、アプリ固有の情報(どのフィールドが不正か、どの値が閾値を超えたかなど)を表現するには不向き。
-
設計の明確化
- 一般的なエラーと、アプリやシステム独自のエラー(発注上限を超えている・年齢が閾値以下・すでに予約済み など) を区別できる
raiseって何者?
「例外を発生させる」仕組み。
raise ValueError("不正な値です")
これが実行されると、
- 現在の関数の処理が即終了
- 呼び出し元へ例外が伝搬
- どこかの
try-exceptに到達するまで上へ上へと伝わる
という動きをする。
「この状況は正常ではないので、ここで処理を中断して例外処理に移ってください」という信号をPythonに送るもの。
if文との使い分け
わざわざraiseさせる必要なくね? ifで分岐させておけばよくね? と思ったりもしたが、エラー処理だったらやっぱりraiseするに越したことはない様子。
| 処理 | 使いどころ | 例 |
|---|---|---|
if分岐 |
想定内の分岐 | 偶数か奇数か、大きいか小さいか |
raise |
異常事態宣言 | 残高がない、ファイルがない |
if文ではなくraiseを使う理由を以下にまとめた。
異常を見逃さない
想定外のデータが入ってきたとき、if文だとそのまま進んでしまい、後々のバグ(データ破損など)につながる。
raiseで強制終了させ、開発者に異常を知らせる。
呼び出し元に判断を任せる
if文だと、自分のところの処理であらゆるパターンを想定しないといけない。
- 指定されたファイルがなかったら? → 画面に表示しよう
- 画面に表示できないときは? → ポップアップを出そう
- ポップアップブロックされていたら? → う~ん……
raiseは呼び出し元に対して「ファイルがなかったよ」と返すだけ。
それについて何を行うか(別ファイルを探す・無視・エラーメッセージの表示)は呼び出し元に任せる。
深い階層から一気に脱出する(例外のエスカレーション)
関数の中で関数を呼び出し → その中で関数を呼び出し → その中でも別の関数を呼び出し……みたいな感じで階層が深くなっている場合、すべての階層でif文によるチェックをしないといけない。
raiseを使えば、try-exceptを使用する関数のところまで一気に飛んでいける。
def C():
print("C: 開始")
raise ValueError("Cでエラーが発生しました")
print("C: 終了")
def B():
print("B: 開始")
C()
print("B: 終了")
def A():
print("A: 開始")
B()
print("A: 終了")
print("main: 開始")
try:
A()
except ValueError as e:
print("main: キャッチ →", e)
print("main: 終了")
↓実行結果
main: 開始
A: 開始
B: 開始
C: 開始
main: キャッチ → Cでエラーが発生しました
main: 終了
mainを実行するとA()が呼ばれて、A()の中でB()が呼ばれる。
更にB()の中でC()が呼ばれるが、C()でエラーが発生する。
このとき、C()の後続処理は実行されず、B()に戻る。
B()に戻るが、B()の後続処理も実行されず、A()に戻る。
A()に戻るが、A()の後続処理も実行されない。
except節まで戻り、C()で発生したエラーが捕捉される。
例外の再スロー
上で出てきたように、例外が発生したらtry-exceptのexcept節で捕捉される。
その前の途中の処理の中でも、例えばログを取って発生した例外を上位呼び出し元に投げなおしたり、追加情報をつけて投げなおしたりできる。
def C():
print("C: 開始")
raise ValueError("Cでエラーが発生しました")
print("C: 終了")
def B():
print("B: 開始")
try:
C()
except:
print("B:Cの呼び出しでエラー")
raise
print("B: 終了")
def A():
print("A: 開始")
try:
B()
except ValueError as e:
print("A: キャッチ →", e)
raise ValueError("Aからエラーを再スロー") from e
print("A: 終了")
print("main: 開始")
try:
A()
except ValueError as e:
print("main: キャッチ →", e)
print("main: 終了")
↓ 実行結果
main: 開始
A: 開始
B: 開始
C: 開始
B:Cの呼び出しでエラー
A: キャッチ → Cでエラーが発生しました
main: キャッチ → Aからエラーを再スロー
main: 終了
B()での処理は、例外が発生したときにログだけ記録して、raiseでエラーを呼び出し元にそのまま投げなおしている(エラー処理は呼び出し元に任せる)パターン。
A()での処理は、新しいエラーを再スローしているパターン。例外の種類を変えたい、表示メッセージを変えたい、例外に追加情報をつけたい、などの場合に有効。
| 書き方例 | 意味 |
|---|---|
raise ValueError("メッセージ") |
新しい例外を発生させる |
raise |
キャッチした例外をそのまま再スロー |
raise ValueError("変換失敗") from e |
新しい例外を投げつつ、元の例外eを紐づける |
例外処理の目的を再確認
プログラミングにおける例外処理とは、「想定外の事態が起きてもプログラムを安全に続行させるための仕組み」である。
目的としては主に以下。
-
プログラムの異常終了を防ぐ
- ネットワークが切れた、0で割った、ファイルがない……などのエラーが起きたとき、例外処理がなければプログラムは即終了する
- 例外処理を入れておけば「エラーは起きたけど、落ちずに次の処理へ進む」という動作が可能に
-
エラーの原因を特定しやすくする
- 例外処理を使うと、「どの種類のエラーが、どこで起きたのか」を明確にできる
-
復旧処理や後片付けを確実に行う
- 「ファイルを閉じる、DB接続を切る」などの処理を
finallyブロックに入れておけば、エラーが起きても確実に実行される
- 「ファイルを閉じる、DB接続を切る」などの処理を
-
正常系と異常系のコードを分離して読みやすくする
- 例外処理で「正常系」「異常系」のコードを分離して見やすくする
-
外部環境の不確実性に対応
- API呼び出し、ネットワーク通信などの「失敗しやすい処理」は自分だけでは制御できない
- 例外処理を使うと、外部環境の失敗をプログラムが落ちない形で扱えるようになる
例外処理つかいこなしたいね
Discussion