30
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-16

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を作成します。

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.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.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を使用しているのですが、もっと良いのがあれば乗り換えたいです。

それでは良い年末を!

30
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?