TypeScriptを一年程楽しく書いていたのですが、1ヶ月程前から業務で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にスクリプトを登録しておきましょう。
[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の組み込み型
殆どの人が既知だと思いますが、一応基本的な型について復習しておきます
公式のドキュメントにあるうち、普通型検査で使用するのはせいぜい以下の8種類くらいに限られると思います。
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
を作成します。
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'))
1行目に「整数型を受け取って文字列型を返す」ことを表す型アノテーションを追加しました。
この状態で型検査を再び実行してみます。
$ 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]
python_version = 3.7
disallow_untyped_calls = True
disallow_untyped_defs = True
スクリプトも設定ファイルを指定するように修正します。
[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許容で既存のコードベースに導入できるとはいえ、元のコードベースが大きいと導入の際に大量のエラーが発生するのは避けられません。
心が折れそうになる前にちょっとまってください。
次に紹介する2つを実行するだけでエラーの9割は消えるはずです。
参考 https://mypy.readthedocs.io/en/latest/existing_code.html#start-small
型のないモジュールのインポートを無視する
例えば以下のようなコードがあるとします。
import request
型検査を行うとエラーがなんと3行も返ってきます。
$ 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-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]
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を使用しているのですが、もっと良いのがあれば乗り換えたいです。
それでは良い年末を!