mypy駆動リファクタリング

## はじめに

Pythonは動的型付けのプログラミング言語です。そのため、実行時に型に関するチェックが実行されません。その結果、“動的型付けであるがゆえに問題がない処理”が存在してしまいます。それらは、ある時点で外部から観測される振る舞いに問題がなかったとしても、リリースを重ねていくうちにに不具合として顕在化する可能性があります。

そのような処理に対して、mypyによりコードにおけるリファクタリングをすると良い箇所を特定できます。mypyはPythonで静的型チェックを実行するツールです。

https://mypy.readthedocs.io/en/stable/

例えば、以下に該当するような、コードの検知が可能です。

- 型ヒントの不足
- 型の矛盾

つまり、mypyの出力に基づくリファクタリングでは以下の効果が期待されます。

- 処理の内容を型ヒントで補足することによる可読性の向上（潜在的な不具合を埋め込む可能性の解消）
- 型が矛盾している処理を修正することによる堅牢性の向上（潜在的な不具合の解消）

本記事では、このようなmypyの出力に基づくリファクタリングを「mypy駆動リファクタリング」と定義します。

本記事では、「mypy駆動リファクタリング」の流れと具体例を紹介します。

## mypy駆動リファクタリング

### 流れ

#### リファクタリング対象のプロジェクトの選定

以降、リファクタリング対象のプロジェクトおよびコードが存在している前提で解説します。

#### mypyのインストール

はじめにmypyをインストールします。

```sh
pip install mypy
```

以下のようなコマンドによりPythonで記述されているファイルに対してmypyの実行が可能であることを確認します。

```sh
mypy app.py 
```

#### 目標とオプションの選定

リファクタリングを進める前に、目標を設定します。例えば、プロジェクトの全体のリファクタリングを実施するのか、特定の部分からリファクタリングを実施するのかを決めます。そして、リファクタリングの内容を決めます。リファクタリングの内容に応じて、mypyを実行する時のオプションを指定します。以下よりオプションの確認が可能です。

https://mypy.readthedocs.io/en/stable/command_line.html

例えば、`--strict`を指定したいとします。

> --strict
> This flag mode enables all optional error checking flags. You can see the list of flags enabled by strict mode in the full mypy --help output.
> 引用：https://mypy.readthedocs.io/en/stable/command_line.html

この時、mypyを実行するコマンドは以下です。

```sh
mypy --strict app.py 
```

:::note warn
全てを一気に直すことが大変な場面では、リファクタリング対象やオプションを細かく設定し、修正内容を切り分けるという判断が大事です。
:::

#### mypyの実行

上述したコマンドを実行します。

```sh
mypy --strict app.py 
```

特に問題がなければ、以下のように出力されます。

```sh
$ mypy --strict app.py 
Success: no issues found in 1 source file
```

何か問題があれば以下のように出力されます。

```sh
$ mypy --strict app.py 
app.py:1: error: Function is missing a type annotation
Found 1 error in 1 file (checked 1 source file)
```

#### 出力の内容に従ってリファクタリング

mypyの出力に対して、リファクタリングを実施します。以下のように、該当する行と指摘の内容が出力されるので、これに従い修正します。

```sh
$ mypy --strict app.py 
app.py:1: error: Function is missing a type annotation
Found 1 error in 1 file (checked 1 source file)
```

上記は、`app.py`というファイルの1行目で定義している関数において、型ヒントの指定がされていないという指摘です。

:::note warn
mypyの指示に従いリファクタリングを進めていくと、以下のような判断が必要になる時があります。その時のリスクと対応について補足します。
- 現状の振る舞いをありのままに定義
    - 処理の修正に手を出す必要がないためリスクはない
    - 後に修正が必要であることの周知が必要
- 本来想定していた振る舞いに修正
    - 処理の修正に手を出す必要がありリスクが発生
    - 「該当箇所に関するテスト」、「実装者の処理に対する理解」、「処理の理解する他者によるレビュー」のいずれかが必要
:::

最終的に問題が見つからない状態を目指します。

```sh
$ mypy --strict app.py 
Success: no issues found in 1 source file
```

#### Continuous Integration（CI）での実行

キリがついた後は、修正した状態を維持するためにCIでmypyを実行することが望ましいです。

#### フローチャート

ここまで紹介したmypy駆動リファクタリングの主な流れは以下のフローチャートで表現されます。

```mermaid
graph TB
    start_flow([開始])-->prepare[リファクタリング対象のプロジェクトの選定]
    prepare-->install_mypy[mypyのインストール]
    install_mypy-->set_goal[目標とオプションの選定]
    set_goal-->run_mypy[mypyの実行]
    run_mypy-->refactor[出力の内容に従ってリファクタリング]
    refactor-->continue{継続?/終了?}
    continue--"継続"-->set_goal
    continue--"終了"-->end_flow([終了])
```

### 具体例

以降、具体的な「mypy駆動リファクタリング」の例を紹介します。

:::note info
追加していく予定です。
:::

#### Function is missing a type annotation

以下のようなファイル（`app.py`）が存在しているとします。

```python:app.py 
def process_int(value) -> int:
    return value
```

以下のコマンドによりmypyを実行させると、エラーが出力されます。

```sh
$ mypy --strict app.py 
app.py:1: error: Function is missing a type annotation
Found 1 error in 1 file (checked 1 source file)
```

関数に型ヒントが指定されていない（`Function is missing a type annotation`）という指摘です。以下のように修正することで解決します。

```diff_python:app.py
- def process_int(value) -> int:
+ def process_int(value: int) -> int:
      return value
```

#### Function is missing a return type annotation

以下のようなファイル（`app.py`）が存在しているとします。

```python:app.py 
def process_int(value: int):
    # 何らかの処理（省略）
    return value
```

以下のコマンドによりmypyを実行させると、エラーが出力されます。

```sh
$ mypy --strict app.py 
app.py:1: error: Function is missing a return type annotation
Found 1 error in 1 file (checked 1 source file)
```

関数の返り値に型ヒントが指定されていない（`Function is missing a return type annotation`）という指摘です。以下のように修正することで解決します。

```diff_python:app.py
- def process_int(value: int):
+ def process_int(value: int) -> int:
      # 何らかの処理（省略）
      return value
```

#### Incompatible types in assignment

以下のようなファイル（`app.py`）が存在しているとします。

```python:app.py
value = 1
# 何らかの処理（省略）
value = "2"
```

以下のコマンドによりmypyを実行させると、エラーが出力されます。

```sh
$ mypy app.py 
app.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int")
Found 1 error in 1 file (checked 1 source file)
```

型に矛盾がある（`Incompatible types in assignment`）、具体的には int型の変数にstr型の値の代入を試みている（`(expression has type "str", variable has type "int")`）という指摘です。変数を使いまわしていることが原因です。変数の使い回しは潜在的な不具合になりかねません。例えば、以下のように修正することで解決します。

```diff_python:app.py 
  value = 1
  # 何らかの処理（省略）
- value = "2"
+ value = 2 # int("2")
```

```diff_python:app.py 
+ from typing import Union


- value = 1
+ value: Union[int, str] = 1
  # 何らかの処理（省略）
  value = "2"
```

```diff_python:app.py 
  value = 1 # value: int = 1
  # 何らかの処理（省略）
- value = "2"
+ another_value = "2" # value: str = 1
```

#### Missing type parameters for generic type {type}

以下のようなファイル（`app.py`）が存在しているとします。

```python:app.py
from typing import List


def process_list(sample_list: List) -> List:
    # 何らかの処理（省略）
    return sample_list


sample_list = [0, 1, 2, 3, 4, 5]
process_list(sample_list)
```

以下のコマンドによりmypyを実行させると、エラーが出力されます。

```sh
$ mypy --strict app.py 
app.py:4: error: Missing type parameters for generic type "List"
Found 1 error in 1 file (checked 1 source file)
```

`List`型の要素が不明であるという指摘です。以下のように修正することで解決します。

```diff_python:app.py
  from typing import List


- def process_list(sample_list: List) -> List:
+ def process_list(sample_list: List[int]) -> List[int]:
      # 何らかの処理（省略）
      return sample_list

  sample_list = [0, 1, 2, 3, 4, 5]
  process_list(sample_list)
```

### Item {type} has no attribute {method}

以下のようなファイル（`app.py`）が存在しているとします。

```python:app.py
from typing import Set, List, Union


def process_list_or_set(
    list_or_set: Union[List[int], Set[int]]
) -> Union[List[int], Set[int]]:
    # 何らかの処理（省略）
    list_or_set.sort()
    return list_or_set


sample_list = [0, 1, 2, 3, 4, 5]
process_list_or_set(sample_list)
```

以下のコマンドによりmypyを実行させると、エラーが出力されます。

```sh
$ mypy --strict app.py 
app.py:8: error: Item "Set[int]" of "Union[List[int], Set[int]]" has no attribute "sort"
Found 1 error in 1 file (checked 1 source file)
```

`Set[int]`型には`sort`という属性（`attribute`）がないという指摘です。ファイル内では`process_list_or_set`に`Set`型の値を入力しておらず、問題なく実行できていました。試しに`process_list_or_set`へ`Set`型の値を入力する処理を追加し実行します。

```python:app.py
from typing import Set, List, Union


def process_list_or_set(
    list_or_set: Union[List[int], Set[int]]
) -> Union[List[int], Set[int]]:
    # 何らかの処理（省略）
    list_or_set.sort()
    return list_or_set

sample_set = {0,1,2,3,4,5}
process_list_or_set(sample_set)
```

すると、エラー（`AttributeError`）が発生します。つまり、潜在的な不具合を含んでいたことが明らかになりました。

```sh
$ python app.py 
Traceback (most recent call last):
  File "app.py", line 12, in <module>
    process_list_or_set(sample_set)
  File "app.py", line 8, in process_list_or_set
    list_or_set.sort()
AttributeError: 'set' object has no attribute 'sort'
```

以下のように修正することで解決します。

```diff_python:app.py
  from typing import Set, List, Union


  def process_list_or_set(
      list_or_set: Union[List[int], Set[int]]
  ) -> Union[List[int], Set[int]]:
      # 何らかの処理（省略）
+     if isinstance(list_or_set, List):
          list_or_set.sort()
      return list_or_set

  sample_list = [0, 1, 2, 3, 4, 5]
  process_list_or_set(sample_list)
```

## まとめ

本記事では「mypy駆動リファクタリング」の流れと具体例を紹介しました。是非とも実践してください！何かございましたら、コメントや編集リクエストをお待ちしております。