Python 3.8.2 の組み込み例外の木構造を見てみる

以下の記事の続き的立ち位置です

[Pythonの組み込み例外の木構造を見てみる](https://qiita.com/amedama/items/aa840a0a98f720cfc4ca)

# 使用するスクリプト、環境、使用例

```py:class_tree.py
#!/usr/bin/env python

import platform


def show_class_tree(cls, depth=0):
    prefix = "" if depth == 0 else "." * (depth * 3) + " "
    if cls.__name__.lower() == "error":
        print("{}{} ({})".format(prefix, cls.__name__, cls))
    else:
        print("{}{}".format(prefix, cls.__name__))
    for subcls in sorted(cls.__subclasses__(), key=lambda c: c.__name__):
        show_class_tree(subcls, depth+1)


if __name__ == "__main__":
    print("Python Version: {}".format(platform.python_version()))
    print()
    show_class_tree(BaseException)
```

本質的には前回と変わりませんが、古いPython3対策とPython2すべてをサポート対象から外しました。
あと、blackがそうするという理由で最近ダブルクオート使っているのでそうしています。

比較対象として `3.7.7` を採用します。前回の記事では最後が `3.7.1` でしたが例外ツリーに差はありませんでしたので省略

手元の環境は MacOS 10.15.4 ですがPythonレイヤで差は出ないはず。

実行方法は大まかに以下のような感じになります

```shell-session
$ python -V
Python 3.7.7
$ python class_tree.py | tee $(python -c 'import platform; print("".join(platform.python_version_tuple()) + ".txt")')
Python Version: 3.7.7

...

$ cat 377.txt
(上記と同じ内容がテキストに保存される)
```

# 3.7.7 での実行結果（全文）

```
Python Version: 3.7.7

BaseException
... Exception
...... ArithmeticError
......... FloatingPointError
......... OverflowError
......... ZeroDivisionError
...... AssertionError
...... AttributeError
...... BufferError
...... EOFError
...... Error (<class 'locale.Error'>)
...... ImportError
......... ModuleNotFoundError
......... ZipImportError
...... LookupError
......... CodecRegistryError
......... IndexError
......... KeyError
...... MemoryError
...... NameError
......... UnboundLocalError
...... OSError
......... BlockingIOError
......... ChildProcessError
......... ConnectionError
............ BrokenPipeError
............ ConnectionAbortedError
............ ConnectionRefusedError
............ ConnectionResetError
......... FileExistsError
......... FileNotFoundError
......... InterruptedError
......... IsADirectoryError
......... ItimerError
......... NotADirectoryError
......... PermissionError
......... ProcessLookupError
......... TimeoutError
......... UnsupportedOperation
...... ReferenceError
...... RuntimeError
......... BrokenBarrierError
......... NotImplementedError
......... RecursionError
......... _DeadlockError
...... StopAsyncIteration
...... StopIteration
...... StopTokenizing
...... SubprocessError
......... CalledProcessError
......... TimeoutExpired
...... SyntaxError
......... IndentationError
............ TabError
...... SystemError
......... CodecRegistryError
...... TokenError
...... TypeError
...... ValueError
......... UnicodeError
............ UnicodeDecodeError
............ UnicodeEncodeError
............ UnicodeTranslateError
......... UnsupportedOperation
...... Verbose
...... Warning
......... BytesWarning
......... DeprecationWarning
......... FutureWarning
......... ImportWarning
......... PendingDeprecationWarning
......... ResourceWarning
......... RuntimeWarning
......... SyntaxWarning
......... UnicodeWarning
......... UserWarning
...... _OptionError
...... error (<class 're.error'>)
... GeneratorExit
... KeyboardInterrupt
... SystemExit
```

これは比較元なので特にコメントしません。 3.7.1 と結果に差はないようでした。

# 3.8.2 での実行結果（比較）

```
$ diff 377.txt 382.txt
1c1
< Python Version: 3.7.7
---
> Python Version: 3.8.2
44d43
< ......... BrokenBarrierError
50,53d48
< ...... StopTokenizing
< ...... SubprocessError
< ......... CalledProcessError
< ......... TimeoutExpired
59d53
< ...... TokenError
79d72
< ...... _OptionError
```

結構変わっています。前回 3.8-dev と同様で一方的に「減っている」のが気になりますが、これはこのスクリプトの実装のせいじゃないかな。

What's New 見てても気づいたんですが、そもそも全部の例外は捕捉できてないです、このスクリプト。だめな子！

> asyncio.CancelledError 

これはそもそも結果にありません。

ふるーい記事をメンテしているだけなのでこれ以上は掘り下げないですが、網羅的だとは思わないほうがよいですね、まる。

＃ しかしなぜ捕捉できていないか、は気になります。ご存じの方いればぜひ（いやーなんかまぁちょっと予想つくけどさ)

以前から感じることとして、個人的な感覚としてこの例外ツリーの枝の方は変わる前提で挑むほうが良いのだとは思います。 

まずは開発上のノウハウとして `Exception` から自前例外を派生させて自分のコードが明示的に出した例外はランタイムの例外とは明確に `except` で分けられるにしておく、みたいなところは基本として抑えておく。もし必要があって、例えば `OSError` より細かい粒度で例外のクラスを見るのなら、バージョンごとにそれなりに変化する可能性をちゃんと認識してユニットテスト書いておく、みたいなことですかね。ユニットテストで例外を捕捉するテストを書いておけば、ランタイムを変えたときに例外の木構造が変わってもユニットテストが落ちてすぐ気づくので、安心感は上がると思います。

# といいつつ調べた

そもそもOOPの理屈だと親が全部の子クラスをはじめからしっているはずはないのですよねー、というのを前提にして

https://stackoverflow.com/questions/3862310/how-to-find-all-the-subclasses-of-a-class-given-its-name

> Note that if the class definition of a subclass hasn't been executed yet - for example, if the subclass's module hasn't been imported yet - then that subclass doesn't exist yet, and __subclasses__ won't find it.

しまった基本的なミスだこれ。何年間気づいてなかった私（＾＾；

スクリプトを以下のように書き換えてみます

```py:class_tree_mod.py
#!/usr/bin/env python

import platform

from threading import BrokenBarrierError
from tokenize import StopTokenizing
from warnings import _OptionError
from subprocess import SubprocessError


def show_class_tree(cls, depth=0):
    prefix = "" if depth == 0 else "." * (depth * 3) + " "
    if cls.__name__.lower() == "error":
        print("{}{} ({})".format(prefix, cls.__name__, cls))
    else:
        print("{}{}".format(prefix, cls.__name__))
    for subcls in sorted(cls.__subclasses__(), key=lambda c: c.__name__):
        show_class_tree(subcls, depth+1)


if __name__ == "__main__":
    print("Python Version: {}".format(platform.python_version()))
    print()
    show_class_tree(BaseException)
```

diffを取ります

```
$ diff 377.txt 382.txt
1c1
< Python Version: 3.7.7
---
> Python Version: 3.8.2
```

変化なし。

ということは 3.8.2 では例外は少なくとも減ったわけではなく、ランタイムが起動したときに不必要にインポートされないようになった、というのが正しそうです。

明示的に import した際には当然それらの例外はランタイムに読み込まれ、親クラスの `__subclasses__()` から見つけられるようになったと。

これは単純な改善ですね。