Python: Ctrl+c (KeyboardInterrupt)での中断と例外の基本

# Python: Ctrl+c (KeyboardInterrupt)での中断と例外の基本

## はじめに

簡単なプログラムの場合、`Ctrl+c`による`KeyboardInterrupt`によって中断させる事が良くあります。
最近、理解不足から中断しない理由がわからず苦労しました。
そのため、`Ctrl+c`で中断させるにはどうしたら良いか例外について勉強しなおしてみました。

C言語で実装された時間のかかる処理は、`Ctrl+c`を押してもその処理が終わるまで中断しないので注意してください。

## 環境

```:OS
エディション  Windows 11 Pro
バージョン 21H2
OS ビルド    22000.318
エクスペリエンス    Windows 機能エクスペリエンス パック 1000.22000.318.0
システムの種類   64 ビット オペレーティング システム、x64 ベース プロセッサ
```

```:python
Python 3.10.1 (tags/v3.10.1:2cd268a, Dec  6 2021, 19:10:37) [MSC v.1929 64 bit (AMD64)]
```

## 結論

通常は、`Ctrl+c`を押すと(`KeyboardInterrupt`が送られて)プログラムが終了します。
押しても実行を続ける場合は、どこかで`CTRL+c`のシグナルか`KeyboardInterrupt`が処理されていて例外の連鎖が終了しているのだと思います。

`KeyboardInterrupt`を処理してしまうものは、筆者の知るものとしては、

- `except KeyboardInterrupt:` : KeyboardInterruptを指定した`except`
- `except:` : 何も指定しない`except`。`bare-except`と呼ぶことにします。

があります。

`CTRL+c`のシグナルを処理するものは、

- `signal.signal(signal.SIGINT, handler)`

があります。

つまり、これらの処理を見直す必要があるという事です。

### `except KeyboardInterrupt:`

`except KeyboardInterrupt:`は、`KeyboardInterrupt`を捕らえて処理する事を意図して書かれます。
そのため、ここの処理で例外の連鎖を切ってしまうと`Ctrl+c`を押しても中断しないわけです。
連鎖が切れている場合は、`raise`を入れて連鎖するようにします。

```python:ptyhon
try:
  # 何らかの処理
except KeyboardInterrupt:
  # 何らかの処理
  raise
```

### `bare-except`

次に、`bare-except`の場合も、`KeyboardInterrupt`を捕らえてしまい例外の連鎖を切ってしまう事があります。
(あまり使われる事はないと思いますが、`except BaseException:`も`KeyboardInterrupt`を捕らえます)
修正する一番良い方法は、`bore-except`を使うのはやめて処理できる例外だけを対象にする事です。
(それが面倒だから`bare-except`を使ってしまうのだとは思いますが)
それが出来ない場合は、`except KeyboardInterrupt:`を`raise`するか、

```python:python
except KeyboardInterrupt:
  raise
except:
  #
```

または、`bore-except`を`raise`するかしかないかと思います。

```python:ptyhon
except:
  #
  raise
```


また、`bore-except`は、`sys.exit()`などによって出された`SystemExit`も捕らえてしまいます。
`sys.exit()`で終了しないのは、`bore-except`が原因だったりします。

また、`except Exception:`(`broad-except`と呼ぶことにします)の例外処理は、逆に`KeyboardInterrupt`を素通りしてしまうので意図通りにならない事があります。

`PyLint`が`broad-except`, `bare-except`と警告が出すのはこんなところが原因だったりします。

もっとも、`broad-except`, `bare-except`が問題視されるのは、意図せずに例外の連鎖を切ってしまう事があるからであり、上述しているように、例外の連鎖をするならば問題ないです。

```python:ptyhon
except Exception as e:
  print(type(e), e) # メッセージだけ表示する
  raise
except:
  print("unknown exception")
  raise
```

### `signal.signal(signal.SIGINT, signal.SIG_DFL)`

`signal.signal(signalnum, handler)`は、`signalnum`で登録されたシグナルが発行されると`handler`で登録された関数を呼び出すようにする関数です。
`Ctrl+c`シグナルによって`handler`を呼び出すようにするには、`signalnum`に`signal.SIGINT`を設定します。
`Ctrl+c`で終了しない場合は、`signal.signal(signal.SIGINT, handler)`がどこかで登録されている可能性があります。
この場合は、登録されている`handler`を修正する必要があります。
(新しく`handler`を登録すると古い`handler`が上書きされるので、既に`handler`が登録されているプログラムで安易に新しい`handler`を登録するのは危険です。)

途中経過の表示などの処理が必要なく`handler`の登録がない場合は、下記を実行すれば`except`に関係なく`Ctrl+c`シグナルで終了するようになります。
`Ctrl+c`シグナルをOS標準の`handler`で処理させるようになります。

```python:python
import signal

signal.signal(signal.SIGINT, signal.SIG_DFL)
```

### `signal.signal(signal.SIGINT, handler)`

終了前に何か処理を挟みたい場合は、`signal.signal(signal.SIGINT, handler)`で以下のような`handler`を登録します。

```python:python
def handler(signum, frame):
  # 何らかの処理
  sys.exit(0)
```

`Ctrl+c`シグナルを受け取ると`handler()`が実行されそこから`sys.exit()`を実行してプログラムを終了させます。
このような記述は、よく目にしますが少し注意が必要です。
`sys.exit()`はプログラムを終了するのではなく`SystemExit`を発生させる関数です。
上述したように、この`handler`と`bore-except`が両方存在する場合、`handler`から送出された`SystemExit`が`bore-except`で処理されてしまい意図しない結果になる事があります。

```python:python
import time
import signal
import sys

def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def handler(signum, frame):
  # 何らかの処理
  sys.exit(0)


signal.signal(signal.SIGFPE, handler)
for i in range(10):
  try:
    wait("main", 10)
  except:
    print("except")
```

`Ctrl+c`で終了したいならば、下記のような`handler`の方が良いかもしれません。
`Ctrl+c`(`KeyboardInterrupt`) を受け取って必要な処理をしたから、再度`KeyboardInterrupt`を送るので例外の連鎖と同じ挙動になります。
若干、再帰的な呼び出しになりそうで不安ですが、試してみたところ問題はないようです。

```python:python
def handler(signum, frame):
  # 何らかの処理
  raise KeyboardInterrupt
```


## 例外処理

ここからは、例外処理のおさらいをしていきたいと思います。

### 基本

例外を処理する場合は、`try`文を使います。
以下のように書くと、`func`で何らかの例外が発生すると`except`句が実行されます。

```python
def func(n):
  return 1/n

print("start")
try:
  func(1)
  print("success")
except:
  print("fail") # 例外が発生した場合に実行される
print("end")
```

必要に応じて、`else`や`finally`も指定できます。
`else`は、例外が発生しなかった場合に実行されます。
`finally`は、例外が発生してもしなくても最後に実行されます。

```python
print("start")
try:
  func(1)
  print("success")
except:
  print("fail")  # 例外が発生した場合に実行される
else:
  print("else")  # 例外が発生しなかった場合に実行される
finally:
  print("finish")  # 例外が発生してもしなくても最後に実行される
print("end")
```

### 特定の例外を処理する

`except:`と書いてしまうと`KeyboardInterrupt`を含めた全ての例外を捕らえてしまいます。
処理したい例外だけを捕らえたい場合は、

`except 例外名`

と記述します。
こうすると一致する例外とその派生先の例外が捕らえられます。

```python:python
try:
  func(0)
  print("success")
except ZeroDivisionError: 
  print("ZeroDivisionError") # "ZeroDivisionError"が発生した場合に実行される
except:
  print("fail") # "ZeroDivisionError"以外の例外が発生した場合に実行される
```

### 複数の種類の例外に対して同じ処理をする

いくつかの種類の例外を処理したい場合は、タプルで複数を指定できます。

```python:python
try:
  func(0)
  print("success")
except (ZeroDivisionError, TypeError): 
  print("ZeroDivisionError, TypeError") # "ZeroDivisionError もしくは TypeError"が発生した場合に実行される
except:
  print("fail") # 上記以外の例外が発生した場合に実行される
```

もしくは、上述したように例外の派生元を指定する事でも可能です。
ほぼ全ての組み込み例外は、`Exception`から派生しているので以下のようにすると大部分の例外を捕らえられます。


```python:python
try:
  func(0)
  print("success")
except Exception:
  print("Exception") # Exceptionから派生した例外が発生した場合に実行される
except:
  print("fail") # "Exception"で捕まえられなかった例外が発生した場合に実行される
```

注意点は、`KeyboardInterrupt`が`Exception`から派生していない事です。
`KeyboardInterrupt`は、`except Exception:`で捕らえられないようになっています。

以下は、pythonの公式ドキュメントからの抜粋です。

```
exception Exception
システム終了以外の全ての組み込み例外はこのクラスから派生しています。
全てのユーザ定義例外もこのクラスから派生させるべきです。

exception KeyboardInterrupt
ユーザが割り込みキー (通常は Control-C または Delete) を押した場合に送出されます。
実行中、割り込みは定期的に監視されます。
Exception を捕捉するコードに誤って捕捉されてインタプリタの終了が阻害されないように、この例外は BaseException を継承しています。
```

### 例外の情報を得る

複数の例外に同じ処理をした場合など、例外の情報を得たい時があります。
その場合、`except 例外名 as 変数名`などとすると変数に例外のインスタンスが格納されます。

```python:python
try:
  func(0)
  print("success")
except (ZeroDivisionError, TypeError) as e: # 例外の情報がeに格納される
  print("ZeroDivisionError, TypeError")
  print(e, type(e))
except Exception as e:
  print("Exception")
  print(e, type(e))
```


### 例外を発生させる

自分で任意に例外を発生させる事も出来ます。
`raise 例外名`で例外を発生させられます。

```python:python
def func(n):
  raise ValueError

try:
  func(1)
  print("success")
except ValueError: 
  print("ValueError")
```

#### ユーザ定義例外

組み込み例外以外に、ユーザ定義例外を発生させることも出来ます。
ユーザ定義例外は、`Exception`を継承したクラスならば何でも良いようです。
しかし、最低限必要な情報だけを持つように定義するのが良いようです。

```python:python
class Error(Exception):
  def __init__(self, message):
    self.message = message

def func(n):
  raise Error("on func")

try:
  func(1)
  print("success")
except Error as e: 
  print("Error", e.message)
```


### 例外処理

例外が起きた場合の挙動について詳しく見ていきたいと思います。

#### 例外なしのサンプル

下記のようなプログラムがあるとします。

```python:python
import time

def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def func():
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  for i in range(3):
    wait("main", 10)
    func()
  print("end")
```

実行結果は、以下のようになります。

```
start
main:   10/10
func:   10/10
main:   10/10
func:   10/10
main:   10/10
func:   10/10
end
```

#### 例外を起こす

故意に例外を起こすために以下のようにします。

```python:python
if __name__ == "__main__":
  print("start")
  for i in range(3):
    raise KeyboardInterrupt
    wait("main", 10)
    func()
  print("end")
```

例外に対する処理を行っていないので例外発生時の時点でプログラムが中断されます。

```python:pythono
start
Traceback (most recent call last):
  File "\test2.py", line 18, in <module>
    raise KeyboardInterrupt
KeyboardInterrupt
```

#### 例外を処理する(ループ外)

`try`文を使って例外を処理していきたいと思います。
まずは、`for`文の外に`try`文を書いてみます。

```python:python
if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      raise KeyboardInterrupt
      wait("main", 10)
      func()
  except KeyboardInterrupt:
    print("catch on main")
  print("end")
```

`for`文の中で発生した例外が`for`文の外の`except`で処理されたので処理は１回で終了します。

```
start
catch on main
end
```

#### 例外を処理する(ループ内)

今度は、`for`文の中に`try`文を書いてみます。

```python:python
if __name__ == "__main__":
  print("start")
  for i in range(3):
    try:
      raise KeyboardInterrupt
      wait("main", 10)
      func()
    except KeyboardInterrupt:
      print("catch on main")
  print("end")
```

`for`文の中の`except`で処理されるので同じ処理が3回繰り返されます。

```
start
catch on main
catch on main
catch on main
end
```

ここでは、明示的に`except KeyboardInterrupt:`を使っているので当たり前ですが、`except:`とした場合でも、`KeyboardInterrupt`が捕らえられてしまいます。
その結果、意図した結果にならない可能性もあります。

#### 関数内で例外を起こす

関数内で例外を起こします。

```python:python
def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  for i in range(3):
    wait("main", 10)
    func()
    print("after func")
  print("end")
```

実行結果は、以下のようになります。

```
start
main:   10/10
Traceback (most recent call last):
  File "\test5.py", line 20, in <module>
    func()
  File "\test5.py", line 12, in func
    raise KeyboardInterrupt
KeyboardInterrupt
```


#### 関数内の例外を関数内で処理する

```python:python
import time


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")


if __name__ == "__main__":
  print("start")
  for i in range(3):
    wait("main", 10)
    func()
    print("after func")
  print("end")
```

関数内で起こった例外を関数内で処理すれば、外のスコープの処理は正常に続きます。

```
start
main:   10/10
catch on func
after func
main:   10/10
catch on func
after func
main:   10/10
catch on func
after func
end
```


#### 関数内の例外を関数外で処理する

以下のように、関数外でかつループ外で処理すると

```python:python
def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except KeyboardInterrupt:
    print("catch on main")
  print("end")
```

結果は、以下のようになります。

```
start
main:   10/10
catch on main
end
```

一方で、関数外でかつループ内で処理すると

```python:python
def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  for i in range(3):
    try:
      wait("main", 10)
      func()
      print("after func")
    except KeyboardInterrupt:
      print("catch on main")
  print("end")
```

以下のようになります。
こちらは、処理が飛ばされているために"after func"が表示されません。

```
start
main:   10/10
catch on main
main:   10/10
catch on main
main:   10/10
catch on main
end
```

#### 例外を連鎖させる

例外が発生したら処理をするけれども、そのままプログラムを終了したい時など例外を連鎖させる必要があります。
(`KeyboardInterrupt`を検出したら途中結果を表示して終了するなど)
その場合は、以下のように`except`句に`raise`だけを記述します。
こうするとその`except`が捕らえた例外をそのまま外部に投げることになります。

```python:python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except KeyboardInterrupt:
    print("catch on main")
    raise
  print("end")
```

`func()`で発生した例外を`func()`で処理し、それをそのまま`main`に渡します。
`main`でも処理をしますが、ここでもまたそのまま例外を投げるので以下のようになります。

```
start
main:   10/10
catch on func
catch on main
Traceback (most recent call last):
  File "\test9.py", line 25, in <module>
    func()
  File "\test9.py", line 13, in func
    raise KeyboardInterrupt
KeyboardInterrupt
```

#### 例外の種類を変えて例外を連鎖させる

単純に連鎖させるのではなく、付属情報を付けたり例外の種類を変えたりして連鎖させたいこともあります。
その場合は、`raise 例外名`で実現できます。
下記のように、`Exception`にメッセージを設定したものを渡しても良いですし、組み込み例外や値を設定したユーザ定義例外なども使用できます。

```python:python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise Exception("Exception on func")


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except Exception as e:
    print("catch on main")
    print(type(e), e)
    raise
  print("end")
```

`func`で発生した`KeyboardInterrupt`が、`main`に渡ったら`Exception`になっているのが分かるかと思います。

```
start
main:   10/10
catch on func
catch on main
<class 'Exception'> Exception on func
Traceback (most recent call last):
  File "\test10.py", line 13, in func
    raise KeyboardInterrupt
KeyboardInterrupt

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "\test10.py", line 25, in <module>
    func()
  File "\test10.py", line 17, in func
    raise Exception("Exception on func")
Exception: Exception on func
```

ここで注意していただきたいのが、以下のメッセージです。
例外処理中に意図しない例外が発生したとも読み取れます。
意図通りに例外の種類を変えたのでそれを明示したいと思います。

```
During handling of the above exception, another exception occurred:
```

#### 例外の種類を変えて例外を連鎖させる(連鎖の意図を明確にする): raise from

明示的に例外を連鎖させたいと思います。
その場合は、`raise 連鎖させる例外名 from 元の例外名`を使います。
下記の場合は、`except KeyboardInterrupt:`で捕らえた`KeyboardInterrupt`から、`Exception("Exception on func")`を連鎖させる事になります。

```python:python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise Exception("Exception on func") from KeyboardInterrupt
```

結果は、以下になります。

```
start
main:   10/10
catch on func
catch on main
<class 'Exception'> Exception on func
KeyboardInterrupt

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

Traceback (most recent call last):
  File "\test11.py", line 25, in <module>
    func()
  File "\test11.py", line 17, in func
    raise Exception("Exception on func") from KeyboardInterrupt
Exception: Exception on func
```

下記に注目すると、`KeyboardInterrupt`から`Exception`が発生したのが分かるかと思います。

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

#### 例外の種類を変えて例外を連鎖させる(連鎖元の情報を隠す): raise from None

例外を連鎖させたいが連鎖元の情報が要らない場合は、`raise 連鎖させる例外名 from None`を使う事で実現できます。
厳密に言えば、スタックトレースあたりの挙動を変えるようですが細かいところまではわかりません。


```python:python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise Exception("Exception on func") from None

```

`Exception("Exception on func") from None`と表示されているので連鎖したことはわかりますが、その前の例外が何だったのか標準のメッセージからは分からなくなっています。

```
start
main:   10/10
catch on func
catch on main
<class 'Exception'> Exception on func
Traceback (most recent call last):
  File "\test12.py", line 25, in <module>
    func()
  File "\test12.py", line 17, in func
    raise Exception("Exception on func") from None
Exception: Exception on func
```

#### 関連: スタックトレースを表示する

例外を処理しているとスタックトレースの情報が知りたくなる事があります。
その様な場合に使えるのが`traceback`モジュールです。
このモジュールには色々ありますが、正直違いがよくわからなかったり簡易表記なだけだったりで、基本的に使うのは`traceback.format_exception()`か`traceback.format_exc()`だけで良いと思います。

```python:python
impoart traceback
type, value, tb, = sys.exc_info()
print(traceback.format_exception(type, value, tb))

print(traceback.format_exc())
```

下記のように、`main`文でのみ例外処理をしていると何が原因で発生した例外なのか分からなくなります。
そこで`main`の`except`に`print(traceback.format_exc())`を入れてみます。

```python:python
import time
import traceback


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except:
    print(traceback.format_exc())
  print("end")
```

そうすると、見慣れたトレースバックが表示されます。

```
start
main:   10/10
Traceback (most recent call last):
  File "\test15.py", line 23, in <module>
    func()
  File "\test15.py", line 14, in func
    raise KeyboardInterrupt
KeyboardInterrupt

end
```

## `signal.signal()`によるシグナルの処理

上述したように`signal`モジュールを使うと`Ctrl+c`シグナル(`KeyboardInterrupt`に相当)を処理する事が出来ます。
例えば以下のようなプログラムでは、`bore-except`が`for`文の中にあるために1回の`Ctrl+c`シグナル(`KeyboardInterrupt`)でプログラムが終了しません。

```python:python
import time
import signal


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


print("start")
for i in range(10):
  try:
    wait("main", 10)
  except:
    print("\nexcept")
print("end")
```

そんな場合に、`signal.signal(signal.SIGINT, signal.SIG_DFL)`を追加すると1回の`Ctrl+c`シグナルでプログラムが終了することになります。

`signal.signal(signal.SIGINT, signal.SIG_DFL)`は

- `signal.SIGINT`: `Ctrl+c`のシグナル。`KeyboardInterrupt`に相当する
- `signal.SIG_DFL`: シグナルに対する標準の関数。Windowsの場合は、終了コード3で終了します。

を意味します。
ただ、これの欠点は、プログラムが即座に終了してしまい、追加の処理が行えない事です。

```python:python
import time
import signal


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


signal.signal(signal.SIGINT, signal.SIG_DFL)

print("start")
for i in range(10):
  try:
    wait("main", 10)
  except:
    print("\nexcept")
print("end")
```

そこで、追加の処理が必要な場合は、独自の`handler`を登録する必要があります。
例えば、以下のようにします。
こちらの欠点は、`KeyboardInterrupt`や`SystemExit`が`bore-except`で処理される恐れがある事です。

```python:python
import time
import signal
import functools


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def handler(signum, frame, exc):
  print(f"\n{frame.f_locals}")
  raise exc


handler = functools.partial(handler, exc=KeyboardInterrupt)
signal.signal(signal.SIGINT, handler)

print("start")
for i in range(10):
  wait("main", 10)
print("end")
```


---

## 参考

[Microsoft Documentation: Technical documentation: Signal](https://docs.microsoft.com/en-us/cpp/c-runtime-library/reference/signal?view=msvc-170)

```
By default, signal terminates the calling program with exit code 3, regardless of the value of sig.
```