PythonのType Hintsとmypyでの型チェック

1. 概要

Pythonは動的型付けの言語です。つまり、事前にデータ型を宣言する必要がありません。これは柔軟ですが、プロジェクトが大きくなるにつれてランタイムエラーが発生しやすくなります。Type hints(PEP 484)を使用すると、実行時の動作を変えずに型情報をコードに記録でき、mypyはこれらの注釈を読み込んでコード記述時に型エラーを検出できます。

この記事を読み終わると、変数、関数、クラスにtype hintsを書く方法、Optional、Union、TypedDict、ジェネリクスの使い方、そしてmypyをコード検査プロセスに統合する方法を習得できます。

2. 基本的なType Hints

2.1. 変数と関数の型宣言

def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

customer_name: str = "Nguyễn Văn A"
total: float = calculate_total(price=15.5, quantity=3)
print(total)
  • price: float、quantity: intは入力パラメータの型です。
  • -> floatは戻り値の型です。
  • customer_name: strは変数のアノテーションで、IDEが提案を表示し、mypyが検査するのに役立ちます。
46.5

注意: type hintsは実行時に型を強制しません。calculate_total("10", 2)を渡した場合、Pythonはインタプリタがアノテーションを無視するため通常どおり実行されます。mypyのようなツールだけがエラーを報告します。

2.2. mypyのインストール

pip install mypy

ファイルをチェック:

mypy order_service.py

エラーがない場合、mypyは以下を出力します:

Success: no issues found in 1 source file

3. Optional と Union - Noneになる可能性のある値

多くの関数はデータが見つからない場合、Noneを返します。Optionalを使ってこれを明示的に表現します。

from typing import Optional

class Customer:
    def __init__(self, customer_id: int, email: str) -> None:
        self.customer_id = customer_id
        self.email = email

def find_customer(customer_id: int) -> Optional[Customer]:
    database = {1: Customer(1, "a@example.com")}
    return database.get(customer_id)

customer = find_customer(2)
if customer is not None:
    print(customer.email)
  • Optional[Customer]はUnion[Customer, None]と同等です。
  • 事前にNoneをチェックせずにcustomer.emailを呼び出すと、mypyはItem "None" of "Optional[Customer]" has no attribute "email"というエラーを報告します。
from typing import Union

def format_id(value: Union[int, str]) -> str:
    return str(value).zfill(6)

Union[int, str]はパラメータがintまたはstrであることを許可します。Python 3.10以降、Union[int, str]の代わりにint | strと書き、Optional[Customer]の代わりにCustomer | Noneと書くことができます。

4. コレクション型: list, dict, tuple

from typing import Dict, List, Tuple

def summarize_orders(order_totals: List[float]) -> Tuple[float, int]:
    return sum(order_totals), len(order_totals)

def build_price_table(items: Dict[str, float]) -> Dict[str, float]:
    return {name: price * 1.1 for name, price in items.items()}
  • List[float]は浮動小数点数のリストを意味します。
  • Tuple[float, int]は正確に2つの要素を持つタプルを意味します:最初がfloat、次がintです。
  • Dict[str, float]は、キーがstr型、値がfloat型の辞書です。

注意: Python 3.9以降、typingからインポートせずに直接list[float]、dict[str, float]を使用できます。typing.List/typing.Dictは主にPython 3.8以下との互換性のために使用します。

5. 構造化辞書データのためのTypedDict

JSONやスキーマが固定の辞書を操作する場合、TypedDictはmypyが各キーをチェックするのに役立ちます。

from typing import TypedDict

class OrderPayload(TypedDict):
    order_id: int
    customer_email: str
    total: float

def create_order(payload: OrderPayload) -> str:
    return f"Order #{payload['order_id']} - {payload['customer_email']} - {payload['total']}"

payload: OrderPayload = {
    "order_id": 101,
    "customer_email": "b@example.com",
    "total": 250.0,
}
print(create_order(payload))

totalキーがないか、"totl"と誤って入力した場合、mypyは報告します:

error: Missing key "total" for TypedDict "OrderPayload"

これはDict[str, Any]を使用するよりも大きな利点です。実行時に純粋なPython辞書を保ったまま、データ構造を制御できます。

6. TypeVarを使用したジェネリクス

複数のデータ型で共通して使用する関数またはクラスを書く場合、TypeVarを使用して入力型と出力型の関係を保持します。

from typing import TypeVar, List

T = TypeVar("T")

def first_item(items: List[T]) -> T:
    return items[0]

first_name: str = first_item(["Hùng", "Lan"])
first_number: int = first_item([10, 20, 30])
  • TypeVar("T")は型のプレースホルダを作成します。
  • first_item(["Hùng", "Lan"])を呼び出すと、mypyはT = strと推論し、戻り値はstrです。
  • List[int]で呼び出す場合、T = intです。そのため、mypyは各呼び出しの正確な戻り値の型を知ることができ、単なるAnyではなく明確な型情報が得られます。

7. インターフェースの代わりにClassとProtocol

Pythonはインターフェースを持っていませんが、Protocol(PEP 544)は構造的型付けを定義できます。つまり、継承を強制する代わりに「正しいメソッドがあるか」をチェックします。

from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, amount: float) -> bool:
        ...

class VnPayGateway:
    def charge(self, amount: float) -> bool:
        print(f"{amount}をVNPayで請求中")
        return True

def process_payment(gateway: PaymentGateway, amount: float) -> bool:
    return gateway.charge(amount)

process_payment(VnPayGateway(), 199000.0)

VnPayGatewayはPaymentGatewayを継承する必要がありません。charge(self, amount: float) -> boolメソッドを持つだけで、mypyはそれがProtocolを満たしていることを認めます。これは非常に「Pythonic」な方法で、ダックタイピングを保ちながら静的チェックも行われます。

8. プロジェクトのmypy設定

requirements-dev.txtに依存関係を追加します:

mypy==1.11.2

プロジェクトのルートディレクトリにmypy.ini設定ファイルを作成します:

[mypy]
python_version = 3.12
disallow_untyped_defs = True
warn_return_any = True
warn_unused_ignores = True
ignore_missing_imports = True
  • disallow_untyped_defs = Trueは、すべての関数にtype hintsがあることを強制し、漏れを防ぎます。
  • warn_return_any = Trueは、関数が暗黙的にAnyを返すときに警告します。
  • ignore_missing_imports = Trueは、型スタブがないサードパーティライブラリを使用する場合に便利です。

プロジェクト全体を検査します:

mypy src/

9. Type Hintsのあるテストの作成

Type hintsはテストの代替にはなりませんが、入出力の型データについてテストをより明確にするのに役立ちます。pytestを使用します:

<!-- requirements-dev.txt に同等のもの、依存関係の構造を説明するためだけ -->
pytest==8.3.2
from order_service import calculate_total

def test_calculate_total_returns_correct_sum() -> None:
    result = calculate_total(price=20.0, quantity=5)

    assert result == 100.0

def test_calculate_total_with_zero_quantity() -> None:
    result = calculate_total(price=50.0, quantity=0)

    assert result == 0.0

テストを実行します:

pytest -v
test_order_service.py::test_calculate_total_returns_correct_sum PASSED
test_order_service.py::test_calculate_total_with_zero_quantity PASSED

CIでは両方のステップを組み合わせることができます:最初にmypyを実行して型エラーをキャッチし、その後pytestを実行してビジネスロジックをチェックします。2つのツールは相互に補完します。mypyは「型が間違っている」というエラーをキャッチし、pytestは「ロジックが間違っている」というエラーをキャッチします。

10. 一般的なエラーと、mypyが不要な場合

  • ロジック変更時にtype hintの更新を忘れる: 例えば、関数がOptional[Customer]を返すから常にCustomerを返すように変更されたが、アノテーションの更新を忘れた場合、コードが以前のより広い型に一致している場合、mypyはエラーを報告しません。これにより、アノテーション情報が不正確になります。
  • Anyを乱用する: def process(data: Any) -> Anyと宣言すると、mypyはチェック機能を失い、その場所での静的型付けが事実上無効になります。
  • 型スタブがないサードパーティライブラリのアノテーション不一致: 多くの古いパッケージは型スタブを公開していません。mypyはerror: Cannot find implementation or library stubを報告します。ignore_missing_importsを使用するか、利用可能な場合はtypes-<package>をインストールして対処できます。
  • mypyを厳格に使用すべきでない場合: 小さいスクリプト、使い捨てスクリプト(throwaway script)、またはアイデアをテストするための高速プロトタイプの場合、完全なtype hintsを強制することは、相応の価値をもたらさずに記述速度を低下させることがあります。

注意: mypyはソースコードのみに対する静的分析(static analysis)を行い、プログラムを実行しません。設定ファイルやAPI呼び出しから読み込んだ値が宣言された型と一致しているかどうかなど、ランタイムデータに依存するエラーはキャッチできません。

11. まとめ

Type hintsはPythonコードを読みやすく、保守しやすくし、型の間違いによるエラーを減らすのに役立ちます。一方、mypyはこれらの注釈を単なるドキュメントではなく、実際のチェックツールに変えます。公開関数と重要なモジュールから段階的に適用を開始し、disallow_untyped_defs設定を使用してプロジェクト全体に拡大することをお勧めします。CIでmypyを統合し、pytestを使用したテストと組み合わせることが、Pythonの柔軟性を保ちながら静的型付け言語に近い安全性を実現する最も現実的な方法です。

pythontype-hintsmypystatic-typing