Pythonでゆるく始める静的型検査

TypeScriptを一年程楽しく書いていたのですが、１ヶ月程前から業務でPython3を触ることになりました。
Pythonは簡潔にかけて楽しいものの、他の人から引き継いだ箇所もあり、開発していて型がないのが辛くなってきました。

一番つらいのはコードリーディングしていて、この関数は何を返すのか？　とか、この変数は何が入ってるのか、とかひと目見て分からないこと。……ドキュメントとしての型が無いことです。
無いなら導入しようということで、型アノテーションとmypyを導入することにしました。

いきなりガチガチに型を導入しても逆に辛くなるので、型のない状態から無理せず型を導入していく方法を取り、結構うまく導入できたので、今回はその時の手順や得られた知見、Tipsを紹介しようと思います。

## 環境

今回はpipenvの環境でテストしていますが、mypyに付いてはpip等でも特に変わらないと思います。

python3.7.5
pipenv, version 2018.11.26

pipenvの導入はこちらを参考にしてください
https://pipenv-ja.readthedocs.io/ja/translate-ja/index.html



## インストール


プロジェクトディレクトリ下でmypyをインストールします。

```
cd ./myproject
pipenv install mypy -d
```

## 静的型検査の実行

グローバルのpipにmypyがインストールされていてsrcディレクトリ以下のコードを静的型検査をしたい場合は`mypy ./src`で型検査ができますが、pipenvにしかmypyが入っていない場合はエラーになります。

```
$ mypy ./src
Command 'mypy' not found, but can be installed with:

sudo apt install mypy
```

pipenvの仮想環境に入れば問題なく実行できます。

```
$ pipenv shell
(myproject) $ mypy ./src
Success: no issues found in 2 source files
```

毎回仮想環境に入るのは面倒なので、Pipfileにスクリプトを登録しておきましょう。

``` Pipfile
[scripts]
type-check = "mypy ./src"
```
参考　https://pipenv-ja.readthedocs.io/ja/translate-ja/advanced.html#custom-script-shortcuts

スクリプトで実行したコマンドはpipenvの環境下で実行されるので`pipenv shell`を行わなくてもmypyを実行できます。

```
$ pipenv run type-check
mypy.ini: No [mypy] section in config file
Success: no issues found in 1 source files
```

CIなどで型検査を実行するときはこのコマンドを使用します。

## Python3の組み込み型

殆どの人が既知だと思いますが、一応基本的な型について復習しておきます
公式のドキュメントにあるうち、普通型検査で使用するのはせいぜい以下の８種類くらいに限られると思います。
https://docs.python.org/ja/3.7/library/stdtypes.html

迷ったら組み込み関数のtype()に入れて結果を見ればいいので覚える必要すら無いです。

|型の種類|型名|例|
|:---------|---------:|---------:|
|真偽値型|bool|True
|整数型|int|10
|浮動小数点数型|float|1.2
|テキストシーケンス型（文字列型）|str|'hoge'
|リスト型|list|[1, 2, 3]
|タプル型|tuple|('a', 'b')
|辞書型（マッピング型）|dict|{ 'a': 'hoge', 'b': 'fuga'}
|集合型|set|{ 'j', 'k', 'l'}

## 型のない関数に型を付けてみる
まず型のない関数を作ってみます。
./src以下に`my_module.py`を作成します。

``` my_module.py
def get_greeting(time):
  if 4 <= time < 10:
    return 'Good morning!'
  elif 10 <= time < 14:
    return 'Hello!'
  elif 14 <= time < 24:
    return 'Goog afternoon.'
  elif 0 <= time < 4:
    return 'zzz..'
  else:
    return ''


if __name__ == "__main__":
    print(get_greeting('morning'))
```

0から24までの時間を受け取って挨拶を返してくれる関数にしてみました。
これで型検査を実行してみると...


```
$ pipenv run type-check
Success: no issues found in 1 source file
```

何もエラーになりません！
なぜかというと、型アノテーションを行っていないので関数の返り値や引数の方は基本Any型（何でもありの型）になってしまうためです。
（既存のコードベースが存在する場合に、型導入時にエラーが出まくって心が折れたりしないのである意味これでいいと思います）
そこで次に、型アノテーションをつけてみます。

```
def get_greeting(time: int) -> str:
  if 4 <= time < 10:
    return 'Good morning!'
  elif 10 <= time < 14:
    return 'Hello!'
  elif 14 <= time < 20:
    return 'Goog afternoon.'
  elif 0 <= time < 4:
    return 'zzz..'
  else:
    return None

if __name__ == "__main__":
    print(get_greeting('morning'))
```

１行目に「整数型を受け取って文字列型を返す」ことを表す型アノテーションを追加しました。
この状態で型検査を再び実行してみます。

```
$ pipenv run type-check
src/my_module.py:14: error: Argument 1 to "get_greeting" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)
```

今度はちゃんとエラーが出ました。
エラーメッセージを読むと14行目で関数get_greetingを呼び出す際に文字列を渡してしまっています。このまま実行すると実行時エラーが発生してしまっていました。
整数型を渡すようにコード変更して、再度型検査を実行するとエラーが出なくなります。

```
    print(get_greeting(10))
```
型アノテーションを付けることで、コードが理解しやすくなり、さらに実行時エラーを未然に防ぐことができました。

## 設定ファイルmypy.iniを使う

そうはいっても型アノテーションを強制させたいときもあると思います。
その場合は設定ファイルを作成します。

``` mypy.ini
[mypy]
python_version = 3.7
disallow_untyped_calls = True
disallow_untyped_defs = True
```

スクリプトも設定ファイルを指定するように修正します。

``` Pipfile
[scripts]
type-check = "mypy ./src --config-file ./mypy.ini"
```

こうしておけば型アノテーションを付け忘れた場合にエラーを返してくれるようになります。

```
$ pipenv run type-check
src/my_module.py:1: error: Function is missing a type annotation
src/my_module.py:14: error: Call to untyped function "get_greeting" in typed context
Found 2 errors in 1 file (checked 1 source file)
```

参考　https://mypy.readthedocs.io/en/latest/config_file.html

## ゆるく導入するために活用したいテクニック

Any許容で既存のコードベースに導入できるとはいえ、元のコードベースが大きいと導入の際に大量のエラーが発生するのは避けられません。
心が折れそうになる前にちょっとまってください。
次に紹介する２つを実行するだけでエラーの９割は消えるはずです。

参考 https://mypy.readthedocs.io/en/latest/existing_code.html#start-small

### 型のないモジュールのインポートを無視する

例えば以下のようなコードがあるとします。

```
import request
```
型検査を行うとエラーがなんと３行も返ってきます。

```
$ pipenv run type-check
src/my_module.py:1: error: Cannot find implementation or library stub for module named 'request'
src/my_module.py:1: note: See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports
Found 1 error in 1 file (checked 1 source file)
```

importしたモジュールの型定義ファイル(stub)が無いためです。

導入時に型定義ファイルを全部用意したりするのは大変なのでmypy.iniの設定で**無視**します。

``` mypy.ini
[mypy-request.*]
ignore_missing_imports = True
```

これで`request`からのインポートにstubが無いことを無視してくれます。

```
$ pipenv run type-check
Success: no issues found in 1 source file
```
これで平和が戻りました。

### その行だけ無視する

あまり推奨はできないのですが、testなどであえて間違った型を代入させたいときにはよく使用する方法です。
無視したいコードの行末尾に`#type: ignore`のコメントをつけます。

```
    print(get_greeting('hoge'))  #type: ignore
```

この行で発生するはずだった型エラーを抑制することができます。

## おまけ：stubを自動生成する

「いやstubを使いたいんだ」という場合もあると思います。しかしサードパーティのモジュールの開発者がstubを用意していてくれている保証はありません。
自分で作るのは面倒です。
そういうときは自動生成しましょう。

mypyをいれると使えるようになる、stubgenコマンドでファイルやディレクトリを指定してstubを自動生成することができます。

```
$ stubgen foo.py bar.py
```

インポートしたモジュールであれば`*.__path__`でモジュールのpathを確認することができるのでそのpathを直接指定してstubを作ることもできます。

```
>>> import request
>>> request.__path__
['/home/username/.local/share/virtualenvs/myproject-xxxxxxxx/lib/python3.7/site-packages/request']
>>>
``` 
pathがわかったら、stubgenを実行します。

```
(myproject) $ stubgen /home/username/.local/share/virtualenvs/myproject-xxxxxxxx/lib/python3.7/site-packages/request
Processed 1 modules
Generated out/request/__init__.pyi
```

stubgenを実行するとプロジェクトrootにoutディレクトリが作成されるのでこのpathをmypyが見るようにmypy.iniに指定します。

``` mypy.ini
[mypy]
python_version = 3.7
mypy_path = ./out

```

型検査が通るようになりました。

```
$ pipenv run type-check
Success: no issues found in 1 source file
```

stubgenで生成される型は完全なものではありません。
大体Any型になってしまうので本格的に使用したければstubファイルを自分で修正する必要があります。

参考　https://github.com/python/mypy/blob/master/docs/source/stubgen.rst

## よく使う発展的な型

組み込み型以外にもよく使う型が存在するので紹介しておきます。
mypyでは組み込み型以外は`typing`モジュールや`typing_extensions`モジュールからそれらの型のクラスを呼び出して使用します。
typescriptとはちょっと使い勝手が違いますが、これらのモジュールにジェネリック型含めて殆どの型が網羅されているのでガチガチに型プログラミングしたいという人も満足できそうです。

参考　https://mypy.readthedocs.io/en/latest/

### Optional

通常は整数を返し、間違った値を受け取った場合などにはNoneを返すなどの関数はよくあります。
その場合の返り値はintもしくはNoneですが、これを表現できるのがOptionalです。

```
from typing import Optional

def sample(time: int) -> Optional[int]:
  if 24 < time:
    return None
  else:
    return time
```

### List, Dict
整数のリストや文字列のリストなどをListで表現できます。

```
from typing import List

# 整数のリスト
intList: List[int] = [1, 2, 3, 4] 

# 文字列のリスト
strList: List[str] = ['a', 'b', 'c']
```

同様にDictを使えば辞書型でも「keyが文字でvalueが整数」のような表現ができます。

```
from typing import Dict

# keyが文字でvalueが整数の辞書型
strIntDict: Dict[str, int] = {'a': 1, 'b': 2, 'c': 3, 'd': 4}

```

### Union

複数の方を組み合わせたユニオン型を作成できます。

```
from typing import Union

strOrInt: Union[str, int] = 1  # OK
strOrInt = 'hoge'  # OK
strOrInt = None # error: Incompatible types in assignment (expression has type "None", variable has type "Union[str, int]")
```

### Any

型を指定したくない場合はAny型ももちろん可能です

```
from typing import Any

string: str = 'hoge' 

any: Any = string
any = 10  # OK

notAny = string
notAny = 10  # error: Incompatible types in assignment (expression has type "int", variable has type "str")
```

### Callable

Callableで関数の型を表現できます。

```
from typing import Callable

# 整数型の引数を一つ受け取って文字列型を返す関数型定義
func: Callable[[int], str]

def sample(num: int) -> str:
  return str(num)

func = sample
```


### TypedDict

マップでkeyの値を指定してそのkeyは何の型のvalueを持っているかを指定したい場合があると思います。typescriptだとinterfaceで表現されるものです。

例えばmovieと言う辞書型の値があったとします。

```
movie = {'name': 'Blade Runner', 'year': 1982}
```

moveはnameとyearというkeyを持ちますが、上書きするときに間違ってnameに整数を入れてしまったり、yearに文字列を入れてしまったら困りますよね。
TypedDictを使うと簡単に型として表現できます。

```
from typing_extensions import TypedDict

Movie = TypedDict('Movie', {'name': str, 'year': int})

movie1: Movie = {'name': 'Blade Runner', 'year': 1982} # OK

movie2: Movie = {'name': 'Blade Runner', 'year': '1982'} # error: Incompatible types (expression has type "str", TypedDict item "year" has type "int")
```

クラスの形でも表現できます。個人的にはTSのinterfaceっぽくかけるのでこちらの方が好みです。

```
from typing_extensions import TypedDict

class Movie(TypedDict):
    name: str
    year: int
```

詳細は公式ドキュメントをご確認ください。
https://mypy.readthedocs.io/en/latest/more_types.html#typeddict

## まとめ

いかがでしたでしょうか？
Pythonで型を始めるのは意外とハードルが低いんだなと感じていただけたら幸いです。

Pythonで型がなくて辛いという方は今すぐ導入しましょう！　思ったより大体揃っているので幸せになれます。

不満点としてはエディタの支援があんまりしっかりしてないということでしょうか。
VS-Codeの拡張機能でPyrightを使用しているのですが、もっと良いのがあれば乗り換えたいです。

それでは良い年末を！