見出し画像

Python基礎18:静的型チェック(mypy)

1.概要

 Pythonは動的型付け言語であり実行時に変数の型が決定されますが、コードの可読性/保守性が低下する原因にもなります。
 Python3.5以降では「型ヒント」の機能を追加することで変数や関数の引数、戻り値の型を明示的に示すことが可能になりました。

 mypyは型ヒントを利用してPythonコードの静的型チェックを行うツールです。開発者はmypyを用いてコードに存在する潜在的な型エラーを事前に検出できます。

2.基礎知識

2-1.環境構築

 MypyはPython3.7以降が必要であり、環境構築は"pip install mypy"で対応できます。

[Terminal]
pip install mypy

2-2.基礎用語

 基本用語は下記の通りです。

  1. 動的型付け言語:変数の型をプログラムが実行されるときに判断するプログラミング言語のことを指し,、Pythonはその一例です。

  2. 静的型チェック:プログラム実行前に”変数や関数の型が正しいかどうか”を確認する処理。これにより型エラーを早期に検出し、バグを予防することができる。

  3. 型ヒント/型注釈:Python 3.5から導入された機能で、変数や関数の引数、戻り値の型をコード内で明示的に示すことができます。これはPythonの動的型付けとは別に存在し、主にツールやライブラリで使用されます。

  4. データバリデーション:データが特定の条件や規則を満たしていることを確認するプロセスを指します。

  5. バリデータ:入力されたデータが正確であり、指定された形式や条件に従っているかを確認(= バリデーション)する機能を指します。つまりデータバリデーションのプロセスを行うためのものです。

  6. Linter(リンター)プログラミングにおける解析ツール。Linterの元となったのはLintと呼ばれるプログラムの静的解析ツールです。

  7. 型エイリアス(Type Aliases):既存の型に新しい名前をつけることで、コードの可読性を向上させ複雑な型を簡単に扱うための仕組みです。Pythonの型ヒントではtypingモジュールを用いて型エイリアスを定義できます。

2-3.mypyのメリット/デメリット

 mypy利用のメリット/デメリットは下記の通りです。

【メリット】

  • 早期のバグ検出:mypyはコードを実行する前に型エラーを検出するため、バグを早期に発見して修正することが可能になります。

  • コードの可読性と保守性の向上:型ヒントを使用することで、関数や変数がどのようなデータを扱うかを明確にすることができ、コードの可読性と保守性が向上します。

【デメリット】

  • 型ヒントの追加に時間がかかる:全てのコードに型ヒントを追加するには時間が必要です。ただしmypyは部分的な型チェックもサポートしており、必要に応じて段階的に型ヒントを導入することができます。

  • 学習コスト:型ヒントとmypyの使用するための学習コストがかかります。そもそも動的型付け言語であるPythonは「型を付けなくて良いから楽」という特徴があるため、型ヒントを付けるのはPythonの良さを消してしまう点もあります。

2-4.類似ライブラリ

 似たようなライブラリは下記の通りです。詳細な用途が違うため、必要に応じて各自で調べてください。

  • Pyright:Microsoftが開発したPythonの静的型チェッカーです。mypyと同様に型ヒントを利用してコードを解析しますが、mypyと比較して一部の機能が強化されています。またVS Codeの拡張機能で使用するPylanceの基盤としても機能します。

  • Pydantic:データの解析とバリデーションを行いPythonの型ヒントを利用してデータの構造を強く保証します。Pydanticは特にAPIのリクエスト/レスポンスモデルや設定管理などのデータのバリデーションと整形に威力を発揮します(例:FastAPI)。mypyとは異なりPydanticは実行時のデータのバリデーションに特化しています。

3.型ヒント

 mypyを学ぶ前に型ヒントについて紹介します。Python3.5以降から導入された型ヒントですが、version3.5以降も改良されており、一部では使用方法も変わっている点に注意が必要です。

3-1.型ヒントとは

 Python 3.5から導入された型ヒントは、コード内の変数や関数の期待する型を示す手段を提供し、コードの可読性が向上、特定のツール(例:型チェッカーなど)での型エラーを検出を補助します。

 注意点として型チェッカーが無い場合、型ヒントを付けて別の型の変数を入力したとしてもエラー・警告などは出力されません。あくまで確認用であり、チェック機能を付ける場合は別途型チェッカーが必要です。

3-2.型ヒントのつけ方

 型ヒントのつけ方は変数や引数にコロンを挟んで"var: <型>"と記載します。また戻り値には"-> <型>"と記載します。
 基本的な型としてint, float, str, boolがあります。

[IN]
def func1(a:int)->int:
    return a
    
def func2(b:float)->float:
    return b
    
def func3(c:str)->str:
    return c
    
def func4(d:bool)->bool:
    return d

a: int = 1
b: float = 2.0
c: str = '3'
d: bool = True

print(func1(a), func2(b), func3(c), func4(d))
[OUT]
print(func1(a), func2(b), func3(c), func4(d))

 型ヒントは指定した型と別の変数を渡してもエラーは生じません。

[IN]
def double_num(num: int):
    return num * 2

print(double_num(2))
print(double_num(3.0))
print(double_num('4'))
[OUT]
print(double_num(2))
print(double_num(3.0))
print(double_num('4'))

3-3.型ヒント用ライブラリ:typing

 基本的な型(int, float, str, bool)以外の型ヒントを付ける場合はtypingモジュールを使用します。

 なおPython3.9(PEP585)以降ではtyping モジュールからいくつかの型ヒント(リストや辞書など)をインポートする必要がなくなりました。

 3-3-1.イテラブル:Dict, List, Tuple

 イテラブル(Iterable)はある種のオブジェクトが繰り返し可能(要素を一度に1つずつ返すことができる)であることを示す型ヒントであり、リスト、タプル、セット、辞書などがあります。
 各イテラブルの型ヒントを付ける場合はtypingから該当するクラスをインポートします。

[IN]
from typing import List, Tuple, Dict

def func5(data: List[int]):
    return data

def func6(data: Tuple[int, float]):
    return data

def func7(data: Dict[str, int]):
    return data

x_list: List[int] = [1, 2, 3]
x_tuple: Tuple[int, float] = (1, 2.0)
x_dict: Dict[str, int] = {'a': 1, 'b': 2}

print(func5(x_list), func6(x_tuple), func7(x_dict))
[OUT]
[1, 2, 3] (1, 2.0) {'a': 1, 'b': 2}

【Python3.9以降】
 Python3.9以降ではtypingモジュールをインポートしなくても、基本的な型と同様の形で型ヒントを付けることが出来ます。

[IN]
def func5(data: list):
    return data

def func6(data: tuple):
    return data

def func7(data: dict):
    return data

x_list: list = [1, 2, 3]
x_tuple: tuple = (1, 2.0, '3')
x_dict: dict = {'a': 1, 'b': 2}

print(func5(x_list), func6(x_tuple), func7(x_dict))
[OUT]
[1, 2, 3] (1, 2.0, '3') {'a': 1, 'b': 2}

 3-3-2.任意のイテラブル:Iterable

 任意のイテラブルを渡したい場合はIterableをインポートします。

[IN]
from typing import Iterable

def func8(data: Iterable[int]):
    return data

x_list = [1, 2, 3]
x_tuple = (1, 2, 3)
x_dict = {'a': 1, 'b': 2, 'c': 3}
print(func8(x_list), func8(x_tuple), func8(x_dict))
[OUT]
[1, 2, 3] (1, 2, 3) {'a': 1, 'b': 2, 'c': 3}

【Python3.9以降】
 Python3.9以降ではIterableに型引数を付けるためには上記のほかに、collections.abcからインポートできます。
 Python3.9未満でcollections.abcを使用すると”TypeError: 'ABCMeta' object is not subscriptable”が発生しますので注意が必要です。

[IN]
from collections.abc import Iterable

def func9(data: Iterable[int]):
    return data

print(func9(x_list), func9(x_tuple), func9(x_dict))
[OUT]
[1, 2, 3] (1, 2, 3) {'a': 1, 'b': 2, 'c': 3}

 3-3-3.任意の型:Union/Optional

 渡す変数の型が不明または複数ある場合はUnionまたはOptionを使用します。Optional は Union[X, None]の意味でありUnionより幅広い意味を持っています。

[IN]
from typing import Union, Optional

def func_u(n: Union[int, str]) -> Union[int, str]:
    if isinstance(n, str): # nがstr型の場合
        return n.upper()
    else:
        return n * 2

def func_o(n: Optional[int]) -> Optional[int]:
    if n is not None: # nがNoneでない場合
        return n * 2
    else:
        return None

print(func_u(2))      # 4
print(func_u('test')) # TEST
print(func_o(2))      # 4
print(func_o(None))   # None
[OUT]
4
TEST
4
None

【Python3.10以降】
 Python3.10以降ではUnionを使わずに”|”を使うことで同様の意味を表すことができ、型ヒントがさらに直感的になります。
 Python3.9以下を使用すると"TypeError: unsupported operand type(s) for |: 'type' and 'type'"とエラーが出ます。

[IN]
def func_u(n: int | str) -> int | str:
    if isinstance(n, str):
        return n.upper()
    else:
        return n * 2

def func_o(n: int | None) -> int | None:
    if n is not None:
        return n * 2
    else:
        return None
    
print(func_u(2))      # 4
print(func_u('test')) # TEST
print(func_o(2))      # 4
print(func_o(None))   # None
[OUT]
4
TEST
4
None

3-4.ライブラリの型を指定

 ライブラリが提供する型を型ヒントとして使用することも可能です。例としてnumpy配列を引数として受け取る関数の型ヒントを設定しました。

[IN]
import numpy as np

def array_sum(arr: np.ndarray) -> np.ndarray:
    return np.sum(arr)

a = np.array([1, 2, 3])
print(array_sum(a))  # 6
[OUT]
6

3-5.より汎用的な型ヒント:collections.abc

 collections.abc モジュールはより抽象的な型ヒントを提供します。これにより、特定のインターフェースを実装するオブジェクトに型ヒントを提供することが可能になります。

 collections.abc モジュールは多くの抽象基底クラス(SequenceSetCallable など)を提供しており、必要に応じて型ヒントとして利用することができます。

 3-5-1.イテラブル:collections.abc.Iterable

注意点は下記の通りです。

  • Python3.9未満だと"TypeError: 'ABCMeta' object is not subscriptable"が発生する

  • 型ヒントは型チェックをしないため別の変数を入れてもエラーは出ないが、関数に記載する場合はインポートしないと"NameError: name 'List' is not defined"が発生する

[IN]
from collections.abc import Iterable
from typing import List

def flatten(nested_list: Iterable[Iterable[int]]) -> List[int]:
    return [x for sublist in nested_list for x in sublist]

print(flatten([[1, 2, 3], [4, 5, 6]]))  # [1, 2, 3, 4, 5, 6]
[OUT]
[1, 2, 3, 4, 5, 6]

 3-5-2.マッピング型:collections.abc.Mapping

 Mapping クラスは__getitem____iter__ メソッドを実装したオブジェクト(e.g.:dict)の型ヒントを実装します。

[IN]
from collections.abc import Mapping

def show_keys(mapping: Mapping) -> None:
    for key in mapping:
        print(key)

data = {'a': 1, 'b': 2, 'c': 3}
show_keys(data)  
[OUT]
a
b
c

3-6.型エイリアス

 型エイリアス(Type Aliases)とは、既存の型に新しい名前をつけることで、コードの可読性を向上させ複雑な型を簡単に扱うための仕組みです。Pythonの型ヒントではtypingモジュールを用いて型エイリアスを定義できます。
 型エイリアスは特に複雑な型として、辞書のリストやタプルの辞書などを何度も書く必要がある場合に便利です。

[IN]
from typing import List, Tuple, Dict, TypeVar

# 複雑な型(タプルのリスト)に名前を付ける
TupleList = List[Tuple[int, int]]

# 辞書のリストに名前を付ける
DictList = List[Dict[str, int]]

def process_coordinates(coordinates: TupleList) -> DictList:
    results = [{'x': x, 'y': y} for x, y in coordinates]
    return results

data: TupleList = [(1, 2), (3, 4), (5, 6)]

process_coordinates(data)
[OUT]
[{'x': 1, 'y': 2}, {'x': 3, 'y': 4}, {'x': 5, 'y': 6}]

4.mypyの基本操作

 公式Docsの”Getting started”から基本操作を学習します。

【VS CodeのExtention】
 VS Codeの拡張機能としてMypy Type Checkerがあります。興味のある方はご参考までに。

4-1.使用方法:mypyのコマンドライン

 mypyの使用方法として、通常pythonスクリプトを実行する場合に"python <pythonスクリプト>"とするところを"mypy"に置き換えます。

[Terminal]
mypy <pythonスクリプト>

 4-1-1.シンプル編1:動作確認

 簡単なコードで実践します。Pythonの型ヒントだけだと型の違いでエラーが起きませんでしたが、mypyで実行することで型の違いをエラーで検出することが出来ました。

[a.py]
def greet(name: str) -> str:
    return 'Hello ' + name

greet(123)  # 型ヒントはstr型、入力はint型なのでエラーになる
[Terminal]
mypy a.py
[OUT]
a.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

 なお入力値を文字列に変えると下記のように"Success"が表示されます。

[OUT]
Success: no issues found in 1 source file

 4-1-2.シンプル編2:Local type inferenceとは

 ”Local type inference”とは、mypyが関数につけた型ヒントから自動的に変数や式の型を推論してくれる機能です。この機能によりすべての変数に型ヒントを手動で付ける必要がなくなります。

【事例1】
 下記は引数が文字列でありreturnは"str"+"str"となっているため出力は文字列であることが予想できます。下記ケースでは戻り値の型ヒントを付けなくてもエラーは発生しません。

[a.py]
def greet(name: str):
    return 'Hello ' + name

greet('KIYO')  
[Terminal]
mypy a.py
[OUT]
Success: no issues found in 1 source file

【事例2】
 本ケースでは関数に型ヒントをつけておりません。しかしn* 2という操作で使用されているためnが数値であることをmypyが"推論"できます。よって、型ヒントをつけなくても問題なく動作します。

[a.py]
def double(n):
    return n * 2

double(2)
[Terminal]
mypy a.py
[OUT]
Success: no issues found in 1 source file

 4-1-3.複数の引数

 複数の引数に型ヒントを付けてチェックすることも可能です。

[a.py]
def greet(name: str, country: str) -> str:
    greet_dict = {'EN': 'Hello', 'FR': 'Bonjour', 'DE': 'Hallo', 'ES': 'Hola', 'JP': 'こんにちは'}
    return f'{greet_dict[country]} {name}!'

print(greet('KIYO', 'JP'))
[Terminal]
mypy a.py
[OUT]
Success: no issues found in 1 source file

 通常通りpythonスクリプトとしても扱うことが出来ます。

[Terminal]
python a.py
[OUT]
こんにちは KIYO!

4-2.一部の引数にだけ型ヒント/型チェックを適用

 Pythonではすべての引数に型ヒントを適用する必要はなく、一部の引数にだけ型ヒントを付けることも可能です。型ヒントが付いていない引数は、mypyが型を推測するまでどのような型でも受け入れられます。

 下記例では型ヒントをname引数に付けage引数は外しました。よって、ageは任意の型の値を渡すことができます(str, int, floatなど渡してもエラーとして検出されません)。

[a.py]
def greeting(name: str, age):
    return 'Hello, ' + name + '. You are ' + str(age) + ' years old.'

greeting('Alice', 25) 
[Terminal]
mypy a.py
[OUT]
Success: no issues found in 1 source file

 ただし一般的に年齢は整数のため、型が確定している変数には正しい型ヒントをつけるのがベストです。

5.mypyのコマンドラインオプション

 mypyではコマンドラインオプションを指定することで様々な動作が出来ます。次章のmypy.iniと併用しながら使いやすい方を選択するのがベターと思います。

5-1.オプション引数

 詳細の確認には下記引数を使用します。

  • -h, --help:mypyのコマンドライン一覧表示

  • -v, --verbose:verboseメッセージ表示

  • -V, --version:プログラムのVersion

[Terminal]
mypy -h
[OUT]
省略
[Terminal]
mypy -V
[OUT]
mypy 1.4.1 (compiled: yes)

5-2.strictフラグ

 型チェックの厳格さを調整するオプションです。様々なコマンドラインオプションがありますが"--strict"のみ紹介します。

 下記では"Local type inference "を使用したコードでありmypyが型を推論してくれるため引数に型ヒントを付けなくてもエラーは発生しません。

[a.py]
def double(n):
    return n * 2

double(2)
[Terminal]
mypy a.py
[OUT]
Success: no issues found in 1 source file

 このコードに"--strict"を付けると下記の通りエラーが発生します。これを取り除くには引数と戻り値の型ヒントを付ける必要があります。

[Terminal]
mypy --strict a.py 
[OUT]
a.py:1: error: Function is missing a type annotation  [no-untyped-def]
a.py:4: error: Call to untyped function "double" in typed context  [no-untyped-call]
Found 2 errors in 1 file (checked 1 source file)

6.mypyの設定:mypy.ini

6-1.設定方法

 mypyでは詳細な型チェックの設定が可能です。型チェックの設定にはmypyの設定ファイル(mypy.ini)を使用し、mypy.iniファイル内にmypyの動作をカスタマイズするための設定を書きます。

6-2.Strict mode:strict = True

 mypyの厳密な型チェックオプション(strict mode)を有効にすると、以下のような厳密な型チェックが行われます。

  • 全ての関数の引数と戻り値に型ヒントが付けられていること

  • 型ヒントが付けられていない変数がないこと

  • 継承関係やオーバーライドの規則が守られていること

  • その他、厳密な型チェックに関する多数のルール

 設定方法はmypy.initに下記を記載します。

[mypy.ini]
[mypy]
strict = True

 例として前章で動作が確認できたコードでもStrict modeで実行するとエラーが検出されました。

[b.py]
def greeting(name: str, age):
    return 'Hello, ' + name + '. You are ' + str(age) + ' years old.'

name = 'KIYO'
age = 25
greeting(name, age) 
[Terminal]
mypy b.py
[OUT]
b.py:1: error: Function is missing a return type annotation  [no-untyped-def]
b.py:1: error: Function is missing a type annotation for one or more arguments  [no-untyped-def]
Found 2 errors in 1 file (checked 1 source file)

 問題は下記2点であり、それを修正するとエラーなく実行できます。

  • 引数ageに型ヒントがない

  • 戻り値の型ヒントが無い

[b.py]
def greeting(name: str, age:int) -> str:
    return 'Hello, ' + name + '. You are ' + str(age) + ' years old.'

name = 'KIYO'
age = 25
greeting(name, age) 
[Terminal]
mypy b.py
[OUT]
Success: no issues found in 1 source file

 所感として、これを設定するとすべての引数に型ヒントを付けないとエラーが出るため、開発初期や専門家でない限りは使うタイミングは無いと思います。

7.型ヒント/型チェッカーの設計思想

 型ヒントの活用方法に関して検討してみました。なお私はプログラムの専門家ではないため、あくまで参考用となります。

7-1.任意の型ヒントはなるべく避ける

 型ヒントはできるだけ具体的にするほうが望ましいです。静的型チェックのメリットとして「期待されない型の入力を避ける」ことがあるため、Any/Union/Optionを多用すると意味がなくなります。
 例えばリストにしても、そのリストが整数を受け取るなら"List"ではなく"List[int]"と記載することで想定するデータの形状が明確になります。

7-2.戻り値:Noneの関数はOptional

 Pythonの関数では戻り値が特定の型、何も返さない、Noneを返すパターンがあります。何も返さない場合はNoneを返すパターンと区別するために"Optional[型]"を使用します。

[a.py]
def greet(name: str) -> str:
    return 'Hello ' + name
[b.py]
def greet(name: str) -> Optional[str]:
    print('Hello ' + name)
[c.py]
def returnNone() -> None:
    return None

7-3.型チェッカーの利用

 Pythonの型ヒントは型が正しいかをチェックはしてくれません。そのため、型チェッカー(mypy)を使うことでコードの静的解析を行い型ヒントに従っているかどうかをチェックします。
 これにより、実行前のコードのチェックが可能になり想定外の型での操作を行うバグを事前に防ぐことができます。



参考記事

あとがき

 先出し。追加する場合は下記予定。

  • より複雑な引数(入れ子構造)での型ヒント

  • strictモード以外のコマンドラインオプションと設定

 自主開発+Jupyter Notebookばっかりだとあまり使わないかもしれないけど、型ヒントくらいはつけておかないと後で見直した時にわからないので注意したい。


この記事が気に入ったらサポートをしてみませんか?