Last Updated: 2/6/2024, 5:44:57 AM

# いつ例外を投げるべき?

変数、属性に代入される
型またはクラスが
変わってしまうとき

もし関数の返り値の型が変わってしまう場合、 積極的に例外を投げた方が好ましそうな気配を感じます。

None を返すよりは例外を選ぶ
Effective Python (opens new window)

筆者は、プログラミングを学ぶときや、1 人で書いている小規模なプログラムであれば、 Python のようにすぐ例外を投げるほうが、 JavaScript のように undefined にするよりも良いと考えています。
西尾 泰和 - コーディングを支える技術 (opens new window)

同時に、エラーはこっそり握りつぶしてはならない。 (実装時には、これらの2つの項目により、例外を使用するという意志決定が自然に行われた)
Pythonの設計哲学 - The History of Python (opens new window)

エラーを静かに渡してはならない。
Errors should never pass silently.
PEP 20 - The Zen of Python (opens new window)

なんで None ではなくて例外なのでしょうか? まず、第一に気づけないからです。 以下の teratail の質問は None が返されてハマっていた方の質問です。 自分も同じ内容でよくハマっていました。 None が返ってきていることに気づけず、いろいろなことを試行錯誤しています。

また第二に、 気づいたあとも None が返されている箇所を探すのが面倒です。 たとえ None が入っていたことに気づけても、 どこで None が代入されたかを確認するために戻らないといけません。 探すのがとても手間になる時があります。

そして第三に、 開発時に気づければいいですが、 運用時になって気づく事態になると悲惨です。

Hello, world!

# 1. 具体例を見てみる。

関数の返り値が未定義であった場合、 None を return する方法と例外を raise する方法の2種類があります。

まず、例外が raise される組み込み型 dict と None が return される標準ライブラリ re の search 関数を それぞれ見てみたいと思います。

# 1.1. 例外を返すもの - dict 型

例えば dict 型について考えます。 添字表記では、キーを参照した時に値が存在しなかった場合、KeyError を投げます。 get メソッドでは、キーを参照した時に値が存在しなかった場合、None を返します。

capitals = {
    '日本': '東京',
    'アメリカ': 'ワシントンDC',
    'フランス': 'パリ',
    'ロシア': 'モスクワ',
    '中国': '北京',
}

# 添字表記 subscription
print(capitals['日本'])
print(capitals['アメリカ'])
print(capitals['ソマリア'])

# get メソッド
print(capitals.get('日本'))
print(capitals.get('アメリカ'))
print(capitals.get('ソマリア'))
>>> # 添字表記 subscription
... print(capitals['日本'])
東京
>>> print(capitals['アメリカ'])
ワシントンDC
>>> print(capitals['ソマリア'])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'ソマリア'
>>> 
>>> # get メソッド
... print(capitals.get('日本'))
東京
>>> print(capitals.get('アメリカ'))
ワシントンDC
>>> print(capitals.get('ソマリア'))
None
>>> 

なぜ dict はデフォルトでは KeyError を投げ返してくるのでしょうか? try 文なんか、あんまり使わないのに、 例外を投げ返されると、ちょっとギョッとします。 try 文を使わない、None を返してくれる方が自然ではないでしょうか。

# 1.2. None を返すもの - re.Match 型

標準ライブラリ re は正規表現を扱います。

例えば、標準ライブラリ re は、 パターンにマッチした文字列を返してくれる 関数 re.search を提供してくれます。

import re
result = re.search(文字列のパターン, 検索対象の文字列)

# ◯ とりあえずコピペで動かしてみる。

例えば、いま文字列 '1v2<4649>90a"abcd"1a' から < >" " で 囲まれた文字列を返すことを考えます。

re.search 関数は、検索対象も文字列が見つかった場合は Match クラスのオブジェクトが返されます。

そして group 関数でマッチした文字列を取得できます。 ( ) で囲まれたパターンの文字列が返されます。

#
# 対話モード >>> に
# コピペで動きます。
#
import re
文字列のパターン = '<(\d+)>.+"(\w+)"'
検索対象の文字列 = '1v2<4649>90a"abcd"1a'
result = re.search(文字列のパターン, 検索対象の文字列)
type(result) is re.Match
result.groups()
>>> type(result) is re.Match
True
>>> result.groups()
('4649', 'abcd')
>>> 

反対に re.search 関数は、検索対象の文字列が見つからなかった場合は None が返されます。

#
# 対話モード >>> に
# コピペで動きます。
#
import re
文字列のパターン = '<(\d+)>.+"(\w+)"'
検索対象の文字列 = '1v2 4649 90a abcd 1a'
result = re.search(文字列のパターン, 検索対象の文字列)
type(result) is type(None)
# result.groups()  result は None なので実行できない。
>>> type(result) is type(None)
True
>>> # result.groups()  result は None なので実行できない。
... 
>>> 

# ◯ 文字列のパターン

次に 文字列のパターン は何を意味していたのでしょうか。 簡単に見ていきます。

文字列のパターン = '<(\d+)>.+"(\w+)"'

\d は任意の英数字、 + は1回以上の繰り返し、 \d+ で任意の英数字の繰り返し。 \w は任意の英字、 + は1回以上の繰り返し、 \w+ で任意の英字の繰り返し。

UX MILK が一番綺麗にまとまっている気がします。 なんで Google の検索結果から消えてるんだろう...

# 2. 例外? それとも None ?

例外が望ましいと感じます。

例外と None のメリットとデメリット
項目 影響 例外 None
デバッグのしやすさ O X
リスト内包表記が使える X O
速度 X O

# 2.1. デバッグのしやすさ - 開発のしやすさ

まず、開発時に誤った操作をした時にすぐに気づくことができます。 None を返されてしまうと突き抜けていきます。 そしていざデバッグしようと思った時に、 一体誰が None を返してきたのか探すのが面倒です。

例外ではなく undefined という値を返して来る JavaScript を触っていて本当にそう思いました。

また、副次的ですが、コードを書いている途中で None を返す関数でしたってことがわかると面倒です。 ドキュメントを読め、あるいは型アノテーションで...という話もあるとは思うのですが、 基本的に1つの関数は1つの型だけを返すようにしておくのが無難な気がします。

# 2.2. ジェネレータ式/リスト内包表記

この考え方は Effective Java (opens new window) から知りました。

例外を投げ返されると try 文で包まないといけなくなります。 ジェネレータ式やリスト内包表記が使えなくなります。 None を返してくれれば、逃げようもあるのですが。

countries = {
    '日本': '東京',
    'アメリカ': 'ワシントンDC',
    'フランス': 'パリ',
    'ロシア': 'モスクワ',
    '中国': '北京',
    # 'モナコ公国': None
}
country_names = [
    '日本',
    'アメリカ',
    'フランス',
    'ロシア',
    '中国',
    'モナコ公国'
]

# こっちは動くけど...
[
    countries.get(country_name)
    for country_name in country_names
]

# こっちは例外が投げられる。
[
    countries[country_name] 
    for country_name in country_names
]
>>> # こっちは例外が投げられる。
... [
...     countries[country_name] 
...     for country_name in country_names
... ]
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
  File "<stdin>", line 4, in <listcomp>
KeyError: 'モナコ公国'
>>> 

# 2.3. 速度

内部で C 言語で書かれた標準ライブラリが例外を投げてくるときは速いのですが、 Python で書いたコードから例外を投げると遅くなります。 それでも、全体から見れば、ごく若干で影響は大きくないかなと思っています。

# 2.4. まとめ

うまく文章にできていないのですが、基本的に例外を返した方が良さそうだなという気がします。 自分も最初は例外がよくわからなかったので None の方がわかりやすくていいんじゃないのかなと思っていたのですが、 やはり1つの関数に2つ以上の型がはいる可能性が生じてしまうのは、色々と面倒なことの方が多いような気がします。

# 3. 例外を投げる。

raise 文の書式は以下のようなものです。

# raise 例外クラスオブジェクト
raise ValueError

# または

# raise 例外クラスのインスタンスオブジェクト
raise ValueError('引数が不正です。')

raise できる組み込み例外の一覧は、こちらにあります。

例えば ValueError は引数が間違っていた時に投げ返す例外です。

raise ValueError
>>> raise ValueError
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError
>>> 

ValueError (opens new window)
演算子や関数が、正しい型だが適切でない値を持つ引数を受け取ったときや、 IndexError のようなより詳細な例外では記述できない状況で送出されます。

# 3.1. 詳細を追加する。

例外クラスを raise するときは、括弧 ( ) を使い、 詳細な内容を付与することができます。

raise ValueError('そんな', '引数は', '無理です。')
>>> raise ValueError('そんな', '引数は', '無理です。')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: ('そんな', '引数は', '無理です。')
>>> 

ちなみに ValueError はクラスオブジェクトです。 ( ) をつけることでインスタンスオブジェクトになります。

isinstance(ValueError('無理です'), ValueError)
# True

# 3.2. 詳細を受け取る

属性 args に詳細が詰め込まれます。 try 文の as を使い詳細な内容を受け取ることができます。

try:
    raise ValueError('そんな', '引数は', '無理です。')
except ValueError as value_error:
    value_error.args
... 
('そんな', '引数は', '無理です。')
>>> 

# 3.3. 括弧の有無の違い

raise 例外クラス として、例外クラスを直接 raise している場合は、

8.4. 例外を送出する - Python チュートリアル (opens new window)

raise の唯一の引数は送出される例外を指し示します。 これは例外インスタンスか例外クラス (Exception を継承したクラス) でなければなりません。 例外クラスが渡された場合は、引数無しのコンストラクタが呼び出され、 暗黙的にインスタンス化されます:

raise ValueError  # shorthand for 'raise ValueError()'

# 3.4. raise ... from ...

raise ... from ... を例外が連鎖していることを明示できます。 エラー文の表示に違いがあり、機能的には、あまり大きな違いは見受けられません。

from 節は例外を連鎖させるのに使います。
The from clause is used for exception chaining:
7.8. The raise statement - The Python Language Reference (opens new window)

例えばシーケンスのイテレータを自作したとします。 シーケンスの IndexError を利用して StopIteration に書き換えて、イテレーションの終了を通知しましょう。

class Iterator:
    def __init__(self, sequence):
        self._sequence = sequence
        self._index = -1
    
    def __iter__(self):
        return self
    
    def __next__(self):
        self._index += 1
        try:
            return self._sequence[self._index]
        except IndexError:
            raise StopIteration

iterator = Iterator([0, 1, 2])

# イテレータを空にする。
[element for element in iterator]

# 例外を起こす
next(iterator)
>>> next(iterator)
IndexError

The above exception was
the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 14, in __next__
StopIteration
>>> 

from を追加するとエラー文が変わります。

class Iterator:
    def __init__(self, sequence):
        self._sequence = sequence
        self._index = -1
    
    def __iter__(self):
        return self
    
    def __next__(self):
        self._index += 1
        try:
            return self._sequence[self._index]
        except IndexError:
            # raise StopIteration
            raise StopIteration from IndexError

iterator = Iterator([0, 1, 2])

# イテレータを空にする。
[element for element in iterator]

# 例外を起こす
next(iterator)
>>> next(iterator)
Traceback (most recent call last):
  File "<stdin>", line 12, in __next__
IndexError: list index out of range

During handling of the above exception,
another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 15, in __next__
StopIteration
>>> 

from None にすると IndexError の方の表示は消えます。

class Iterator:
    def __init__(self, sequence):
        self._sequence = sequence
        self._index = -1
    
    def __iter__(self):
        return self
    
    def __next__(self):
        self._index += 1
        try:
            return self._sequence[self._index]
        except IndexError:
            # raise StopIteration
            # raise StopIteration from IndexError
            raise StopIteration from None

iterator = Iterator([0, 1, 2])

# イテレータを空にする。
[element for element in iterator]

# 例外を起こす
next(iterator)
>>> next(iterator)
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 15, in __next__
StopIteration
>>> 

上記のツイートは Python のコアデベロッパである Raymond Hettinger 氏です。

# 4. 例外を定義する。

書籍 Effective Python の項目51 「APIからの呼び出し元を隔離するために、ルート例外を定義する」では、 作成したライブラリごとに自身で例外を定義することを勧めています。 詳細は書籍をご確認ください。

例外クラスを自分で定義することもできます。 このとき raise する例外クラスは BaseException を継承していなければなりません。 もし自作した例外を raise したい場合は、継承していないと TypeError という別の例外を投げ返されます。

class MyException:
    pass

raise MyException
>>> raise MyException
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: exceptions must derive from BaseException
^^^^^^^^^ 定義した例外クラスでない例外クラスが
          投げ返されました。
>>> 

正しくは以下のようにします。

class MyException(BaseException):
    pass

raise MyException
>>> raise MyException
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
__main__.MyException
         ^^^^^^^^^^^ 無事に定義した例外クラスが
                     投げ返されました。
>>>

# 5. おわりに

ここまで以下のような流れで見てきました。

例外を投げるか None を返すか、どちらでもいい場合、あるいは迷った場合は、 とりあえず例外を返しておけば安牌かなと個人的に感じています。 以上になります。ありがとうございました。