はじめに
型ヒントの話は各記事でちょくちょく書いてきたが、まとめて整理したことがなかった。
PHPでもPHP 7以降は引数・戻り値の型宣言が書けるし、PHPStanやPsalmで静的解析もできる。Pythonの型ヒントはそれと似た立ち位置だが、書き方の幅がかなり広い。typingモジュールの色々な型をどう使うか、mypyをどこまで厳しくするか、実際に試しながら整理した。
型ヒントの基本
関数の引数と戻り値への型ヒントはすでに書いてきた通り。
def greet(name: str) -> str:
return f"こんにちは、{name}さん"
def add(x: int, y: int) -> int:
return x + y
def show(value: float) -> None: # 戻り値なし
print(value)
変数にも書ける。
name: str = "田中"
age: int = 28
score: float = 95.5
flag: bool = True
型ヒントはあくまでヒントであって、実行時に強制されるわけではない。型が違う値を渡してもPythonは普通に動く。静的解析ツール(mypy)を使って初めてチェックされる。
typingモジュール
Python 3.9以前はtypingモジュールのクラスを使う必要があった。Python 3.9以降は組み込み型を直接使えるようになった。
# Python 3.8以前
from typing import List, Dict, Tuple, Optional
def process(items: List[str]) -> Dict[str, int]:
...
# Python 3.9以降(組み込み型で書ける)
def process(items: list[str]) -> dict[str, int]:
...
現場のコードには古い書き方が残っている場合もあるので両方読めるようにしておく。新しく書くならlist[str]の形式を使う。
よく使う型の書き方
Optional — Noneになりうる型
<?php
function find_user(int $id): ?User
{
// nullを返す可能性がある
}
# Python 3.9以前
from typing import Optional
def find_user(user_id: int) -> Optional[User]:
...
# Python 3.10以降(| Noneで書ける)
def find_user(user_id: int) -> User | None:
...
PHPの?Userに相当するのがUser | None。Python 3.10以降は|演算子が使えるのでOptionalを使わなくて済む。
Union — 複数の型のどれか
# Python 3.9以前
from typing import Union
def parse(value: Union[str, int]) -> str:
return str(value)
# Python 3.10以降
def parse(value: str | int) -> str:
return str(value)
list / dict / tuple / set
def process_names(names: list[str]) -> dict[str, int]:
return {name: len(name) for name in names}
def get_range(data: list[float]) -> tuple[float, float]:
return min(data), max(data)
def unique_tags(tags: list[str]) -> set[str]:
return set(tags)
Callable — 関数を引数に取る
from typing import Callable
def apply(func: Callable[[int], int], value: int) -> int:
return func(value)
apply(lambda x: x * 2, 5) # 10
Callable[[引数の型], 戻り値の型]の形式で書く。
TypedDict — 辞書の構造を定義する
from typing import TypedDict
class UserDict(TypedDict):
name: str
age: int
email: str
def create_user(data: UserDict) -> None:
print(data["name"])
# キーと型が合わないとmypyが警告する
create_user({"name": "田中", "age": 28, "email": "tanaka@example.com"})
PHPの連想配列の型宣言に相当するものが長らくなかったが、TypedDictを使うと辞書の構造を型で表現できる。データ処理で辞書をよく使う案件では重宝しそう。
dataclassとの使い分け
構造を持ったデータはTypedDictよりdataclassを使うことが多い。
# TypedDict → 辞書として扱いたい場合
# dataclass → オブジェクトとして扱いたい場合
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int
email: str
JSONを返すAPIのレスポンスをそのまま辞書で扱うならTypedDict、アプリ内部でオブジェクトとして扱うならdataclassという使い分けをしている。
ジェネリクス
型をパラメータとして受け取る仕組み。
# Python 3.12以前
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
first([1, 2, 3]) # int型が返る
first(["a", "b"]) # str型が返る
# Python 3.12以降(より直感的な書き方)
def first[T](items: list[T]) -> T:
return items[0]
PHPにも@templateアノテーション(PHPStan)があるので概念は同じ。Python 3.12からの書き方はより自然になった。
mypyの導入
pip install mypy
# 型チェックを実行
mypy main.py
# プロジェクト全体
mypy src/
設定ファイル
mypy.iniかpyproject.tomlに設定を書く。
# mypy.ini
[mypy]
python_version = 3.12
strict = false
# 段階的に厳しくする
disallow_untyped_defs = true # 型ヒントのない関数を禁止
disallow_any_generics = true # list(型引数なし)を禁止
warn_return_any = true # Any型を返す関数に警告
warn_unused_ignores = true # 不要な# type: ignoreに警告
check_untyped_defs = true # 型ヒントがない関数も内部チェック
# pyproject.toml
[tool.mypy]
python_version = "3.12"
disallow_untyped_defs = true
warn_return_any = true
mypyの段階的な導入
既存コードに一気にstrict = trueを適用すると大量のエラーが出る。段階的に厳しくしていくのが現実的。
# まず型ヒントなしでも動くコードを確認
# mypy --ignore-missing-imports src/
# 新しく書くコードだけ厳しくする
# type: ignore でファイル単位で無視
# 特定の行だけ型チェックを無視
result = some_untyped_function() # type: ignore[no-untyped-call]
# 理由も書くとレビューで説明が楽
result = legacy_function() # type: ignore[return-value] # レガシーコード、修正予定
PHPStanもレベル0〜9で段階的に厳しくできるが、同じような感覚で使える。
Any型
型がわからない、あるいは型チェックをスキップしたい場合。
from typing import Any
def process(data: Any) -> Any:
return data
Any型はすべての型と互換性がある。mypyの型チェックをその変数に対してスキップする効果がある。多用するとせっかくの型ヒントが無意味になるので最小限に留める。
外部ライブラリが型ヒントを持っていない場合にやむを得ず使う、という位置づけ。
Final — 定数の表現
変数の記事でも触れたが改めて。
from typing import Final
MAX_RETRY: Final = 3
API_URL: Final = "https://api.example.com"
MAX_RETRY = 5 # mypyがエラーを出す
PHPのconstに相当する。言語レベルでの強制はないが、mypyを使えば再代入を検出できる。
Literal — 特定の値だけ許可する
from typing import Literal
def set_direction(direction: Literal["left", "right", "up", "down"]) -> None:
print(f"方向: {direction}")
set_direction("left") # OK
set_direction("diagonal") # mypyがエラーを出す
PHPのenumや@param "left"|"right"アノテーションに相当する。Python 3.11以降はenumが使いやすくなったが、単純な文字列の制限ならLiteralが手軽。
Python 3.11以降のEnum
from enum import StrEnum
class Direction(StrEnum):
LEFT = "left"
RIGHT = "right"
UP = "up"
DOWN = "down"
def set_direction(direction: Direction) -> None:
print(f"方向: {direction}")
set_direction(Direction.LEFT)
PHPのEnumとほぼ同じ感覚で使える。
どこまで型を書くか
実際に書いていて感じた判断基準をまとめた。
書くべき場所
- 関数・メソッドの引数と戻り値(最優先)
-
Noneを返す可能性がある関数の戻り値 - 複数の型を受け付ける引数
- クラスのインスタンス変数
書かなくてもいい場合
- 型が自明なローカル変数(
count = 0など) - 内包表記の変数(
[x for x in items]のx) - テストコードの一部(過剰になりやすい)
# 自明なので型ヒント不要
count = 0
name = "田中"
items = []
# 型ヒントがあると意味が明確になる
def process(
records: list[dict[str, Any]],
max_count: int = 100,
) -> tuple[list[str], int]:
...
PHPStanとmypyの比較
| 項目 | PHPStan | mypy |
|---|---|---|
| 言語 | PHP | Python |
| 設定の厳しさ | レベル0〜9 | オプションで細かく制御 |
| 型なし関数 | レベルによって警告 |
disallow_untyped_defsで制御 |
Any相当 |
mixed |
Any |
| ジェネリクス | @template |
TypeVar / Python 3.12〜 |
| 定数 | const |
Final |
| Union型 | string|int |
str | int |
PHPStanを使ってきた人にはmypyの考え方はほぼ同じ感覚で入れると思う。
まとめ
- 型ヒントは書かなくても動くが、書くとエディタ補完と静的解析が効く
- Python 3.10以降は
|でUnion・Optionalが書けてすっきりする - mypyは最初から
strictにせず段階的に厳しくしていく - 関数の引数と戻り値だけでも書いておくと効果が大きい
PHPStanを使っていた経験があれば、mypyの必要性と使い方はすんなり理解できた。型ヒントの文法が少し違うだけで、「静的解析で早めにバグを見つける」という目的は同じ。