2022年9月: より良い型ヒントの書き方(terapyon)

寺田 学です。9 月の「Python Monthly Topics」は、Python3.5 で導入され多くの場面で導入されている型ヒント(Type Hints)について、より良い型ヒントの書き方を紹介します。

Python の型ヒント

Python は動的型付け言語です。型を指定せずに変数宣言できますし、関数の引数や戻り値に型を宣言する必要はありません。

Python 3.5(2015 年 9 月リリース)で型ヒントの仕組みが入りました。型の指定が不要な Python ですが、型ヒントを付けることで、「コードの可読性向上」、「IDE コード補完の充実」、「静的型チェックの実行」といった静的型付け言語のようなメリットを得ることができます。

Python の型ヒントは以下のように記述します。

name: str = "氏名"  # 変数nameをstr型と宣言

def f(arg: int) -> str:  # 引数argはint型で受取り、戻り値はstr型
    return str(arg)

型チェック

Python 標準ライブラリでは現状型チェックを行う方法がありません。 デファクトスタンダードとなっているのは、 mypy というサードパーティー製パッケージです。

利用するには、 pip コマンドで mypy をインストールします。

$ pip install mypy

これにより、 mypy コマンドが利用できるようになります。 チェックするにはファイル名を引数に以下のように行います。

この例は mypy による指摘事項が無かった場合です。

$ mypy module_name.py
Success: no issues found in 1 source file

以下のように指摘がある状況を作り再度実行してみます。

def f(arg: int) -> str:  # 引数argはint型で受取り、戻り値はstr型
    return str(arg)


f("10")  # int型を受取る関数にstr型を渡す
$ mypy module_name.py
module_name.py:5: error: Argument 1 to "f" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)

さらに、VS Code や PyCharm などの IDE に mypy を設定して有効にすると以下のような表示でエラーを指摘してくれます。

VS Codeでmypyエラー

Python バージョンによる変化

コレクション型

Python3.9 で導入された、PEP 585(標準コレクション型の型ヒントにおける総称型) について説明します。

コレクションの変数を宣言する場合、Python3.8 以前は、typing.List のような大文字始まりのコレクション名の型をインポートして宣言していました。

Python3.9 以降では、標準コンストラクター関数を使って list のように宣言することができます。 現在では Python3.8 以前の記法である大文字始まりの List は、非推奨になっています。

Python3.9 以降のコード例

from typing import Union

users: list[str] = ["UserA", "UserB"]
size: tuple[int, int] = (120, 80)
info: dict[str, Union[str, int]] = {"name": "Sato", "age": 28}
permissions: set[str] = {"read", "write"}

さらに、Python3.7 及び Python3.8 では、 from __future__ import annotations 記述することで小文字始まりの list などで型宣言することが可能です。

Union

Python3.10 で導入された、PEP 604(Union をパイプ( | )で表記可能) について説明します。

先ほど、dict 型の宣言の値の宣言で用いた typing.Union ですが、Python3.10 以降ではパイプ( | )を用いて記述することができます。 Union は、複数のデータ型を宣言するためのものです。 Union[str, int] というのは、文字列型または整数型であることを宣言しています。

Python3.9 以前

from typing import Union
data: Union[str, int] = 20

Python3.10 以降

data: str | int = 20

また、 typing.Optonal で宣言していた、 None またはそれ以外の場合においてもパイプ( | )を用いることができます。 なお、Optonal は、None を許容する場合に用いられる Union の一つの型であると考えられます。

Python3.9 以前

from typing import Optional

data: Optional[int] = None

Python3.10 以降

data: int | None = None

この機能を Python3.7 から Python3.9 までで利用したい場合は、 from __future__ import annotations とすることで利用ができます。

より明確な型ヒントを付ける

ここからは、複雑なデータ構造を辞書やリストで扱う場合の方法について解説します。

支店とスタッフを示す、以下のようなデータ構造を考えます。

company_branches = {
    "東京": {
        "001": {
            "name": "佐藤",
            "is_leader": True,
            "leader_period": 3,
        },
        "005": {"name": "田中", "is_leader": False},
    },
    "福岡": {
        "003": {
            "name": "伊藤",
            "is_leader": True,
            "leader_period": 5,
        },
        "008": {"name": "山本", "is_leader": False},
        "011": {"name": "吉田", "is_leader": False},
    },
}

このデータに型ヒントを付けると以下のようになります。

company_branches: dict[str, dict[str, dict[str, str | bool | int]]]

ここで、支店名(東京や福岡)を指定して、スタッフの名前を出力する関数を考えてみます。

def get_staff_names(
    data: dict[str, dict[str, dict[str, str | bool | int]]], branch_name: str
) -> list[str | bool | int]:  # list[str]としたいが辞書の値がstrと特定できない
    staff_names = []
    for staff in data[branch_name].values():
        staff_names.append(staff["name"])
    return staff_names


def show_names(staff_names: list[str | bool | int]) -> str:
    return ", ".join(staff_names)  # リストの要素がstrとは限らないのでエラーとなる


target: str = "東京"
names = get_staff_names(company_branches, target)
print(show_names(names))

ここでは 2 つの問題があります。

まずは、関数 get_staff_names() の戻り値です。キーを指定して取得していて、 "name" は暗黙的に str となっていることを期待していますが、型ヒントの上では保証できません。

次に、関数 show_names() の引数です。ここはリストの中に str が入っていることを期待していますが、同様に型ヒントの上では保証できません。

TypeGuard を使う

ここでの解決策は、Python3.10 で導入された TypeGuard を使う方法があります。 これは、 PEP 647 (User-Defined Type Guards) で提案され実装されました。

TypeGuard を見る前に、 if 文を使って None をブロックする方法を確認します。

以下は、 floatint に変換する関数を示しています。この関数の引数に None が渡ってくる場合がありその時は 0 を返すという場合の関数です。

def to_int(data: float | None) -> int:
    if data is None:  # このif文でNoneの場合をブロック
        return 0
    return int(data)  # この段階でdataはfloatである

if data is None: を通過した場合、Python の型チェッカーでは None が除外されたということがわかります。 この方法は isinstance() を使っても同様になります。

ただ、今回のケースでは、リストの中の型を確認する必要があり、Python3.9 までは型チェック機構を使うことができませんでした。

それでは、先ほどのスタッフの名前を出力する関数を改造します。

型をチェックするための関数 is_all_str() を宣言します。 この戻り値は bool 型となります。チェックしたい対象の真偽を返します。 この時、戻り値の型を TypeGuard とし、そのデータの中がどのような型かを宣言します。

from typing import Any, Iterable
from typing import TypeGuard

def is_all_str(strings: Iterable[Any]) -> TypeGuard[Iterable[str]]:
    """引数で与えられたイテラブルの要素の全てが文字列型かを調べる"""
    if all(isinstance(s, str) for s in strings):
        return True
    return False

ここでは、 TypeGuard[Iterable[str]] としましたので、このチェック関数を通過したデータは、 Iterable[str] であることを保証すると宣言しています。

次に関数 show_names() を改造していきます。

from typing import NoReturn

def show_names(staff_names: list[str | bool | int]) -> str | NoReturn:
    """staffのnameをカンマ区切りで出力"""
    if not is_all_str(staff_names):
        raise ValueError("str以外のオブジェクトを発見")
    return ", ".join(staff_names)

この関数では、型が合わない場合には、 ValueError を返すようにしています。 例外の発生によりこの関数の戻り値がなくなりましたので、 NoReturn の場合があることも合わせて宣言しています。

TypeGuard を使うと、リストなどの要素内の型チェックができるようになります。

TypeAlias でより明確に型ヒントを定義

先ほどの、支店とスタッフを示す辞書に明確な型ヒントを定義します。

from typing import Literal
from typing import TypeAlias

BranchNames = Literal["東京", "福岡"]  # Literalを使って定義できる文字列を制限
Name = str  # 意味のある名前を付ける
IsLeader = bool
LeaderPeriod = int
Staff = dict[str, Name | IsLeader | LeaderPeriod]
Branch = dict[str, Staff]
CompanyBranches = dict[str, Branch]

ここで注目すべきは 2 つです。

1 つ目は Literal を使った文字列定義です。今回の場合、支店が 2 つと限定されている場合を想定しています。 よって、ここでは 東京, 福岡 と 2 つのみが支店名として有効であるということを示しています。

2 つ目は、 Name, IsLeader, LeaderPeriod という、スタッフの属性を表す 3 つの要素について、型に名前を付けてわかりやすく表現しています。

このように、グローバル変数に代入した場合、型エイリアスとなります。

これらの宣言により、スタッフを表す辞書 Staff の値は 3 つの型エイリアスで宣言することができます。 さらには、支店のを表す Branch の値には Staff と明確にし、データ構造の辞書自体である CompanyBranches がどのようなデータ構造であるかが明確になりました。

この方法は、Python US 2022 のキーノートでコアデベロッパーの Łukasz Langa 氏がお勧めすると力説していました。

ここからは、Python3.10 の PEP 613 (Explicit Type Aliases) で導入された、 TypeAlias でより明確にする方法を説明します。

具体的には以下のようになります。

from typing import TypeAlias

Name: TypeAlias = str  # TypeAliasとして明確にする
IsLeader: TypeAlias = bool
LeaderPeriod: TypeAlias = int

先ほどの例では、 Name = str とグローバル変数として定義していましたが、これが型エイリアスなのかどうかは使う側が決めるということになります。 Name: TypeAlias = str とすることで、明確に型エイリアスであることを示すことができます。

これは、文字列で宣言できる前方参照の時に有効な手段となります。

MyType: TypeAlias = "ClassName"

def foo() -> MyType: pass

class ClassName: pass

型は文字列で dummy: "str" = "1" のように宣言することが可能で、クラス定義の前に型ヒントを書くことができます。 ただ、型エイリアスの代入では文字列を渡すことができませんでした。

TypeAlias を用いることで、上記のように型エイリアスを宣言することができるようになります。 グローバル変数宣言時には、より分かりやすく明確に書くことができます。

TypedDict の活用

TypedDict は、キーを固定した辞書(dict)を型を宣言できる型ヒントの機能です。 TypedDict を継承したクラスに対して、クラス属性を宣言することで辞書のキーと値のデータ型を明確にしていきます。

ここまで使っている、支店とスタッフの辞書を TypedDict で宣言したいとおもいます。

しかし、ここまで使ってきた辞書は、キー自体に暗黙的に意味を持たせたデータ構造になっています。 例えば「支店名」を見てみると、この辞書の第 1 階層キーで表しています。このままの構造では TypedDict で明確な宣言ができません。

まずは、辞書を変更し、キーに意味を持たせる構造に変更していきます。

定義済みの辞書を再掲載します。

company_branches = {
    "東京": {
        "001": {
            "name": "佐藤",
            "is_leader": True,
            "leader_period": 3,
        },
        "005": {"name": "田中", "is_leader": False},
    },
    "福岡": {
        "003": {
            "name": "伊藤",
            "is_leader": True,
            "leader_period": 5,
        },
        "008": {"name": "山本", "is_leader": False},
        "011": {"name": "吉田", "is_leader": False},
    },
}

変更後の辞書は以下のようになります。

company_branches = [
    {
        "branch_name": "東京",
        "staff": [
            {
                "number": "001",
                "name": "佐藤",
                "is_leader": True,
                "leader_period": 3,
            },
            {"number": "005", "name": "田中", "is_leader": False},
        ],
    },
    {
        "branch_name": "福岡",
        "staff": [
            {
                "number": "003",
                "name": "伊藤",
                "is_leader": True,
                "leader_period": 5,
            },
            {"number": "008", "name": "山本", "is_leader": False},
            {"number": "011", "name": "吉田", "is_leader": False},
        ],
    },
]

ここでは、データ全体をリストとして、要素を辞書で表すようにしています。 いままでは、支店名が辞書のキーでしたが、 branch_name キーの値で示すようにしています。さらに、スタッフ番号も同様に変更しています。

参考までに、変更されたデータに対して型定義を掲載しておきます。

BranchNames = Literal["東京", "福岡"]
Number: TypeAlias = str
Name: TypeAlias = str
LeaderPeriod: TypeAlias = int
IsLeader: TypeAlias = bool
Staff = dict[str, Number | Name | IsLeader | LeaderPeriod]
Branch = dict[str, str | list[Staff]]
CompanyBranches = list[Branch]

具体的な TypedDict の使い方

データ構造が変わり、 TypedDict で明確なデータ構造を示せるようになったので、クラスを宣言し型ヒントを宣言します。

from typing import Literal
from typing import TypedDict


class Staff(TypedDict):  # TypedDictの継承し、スタッフを表すクラスを宣言
    number: str
    name: str
    is_leader: bool


class LeaderStaff(Staff, total=False):  # スタッフを表す辞書にオプショナルキーがあるので継承して別クラスを宣言
    leader_period: int


class Branch(TypedDict):
    branch_name: str
    staff: list[LeaderStaff]  # leader_periodが存在する可能性があるので継承されたスタッフ


BranchNames = Literal["東京", "福岡"]
CompanyBranches = list[Branch]

クラス Staff には、3 つの必須キーと、1 つのオプションキーがあります。そのため、2 つのクラスを作っています。 最初に宣言している Staff を見てみると、クラス属性に辞書のキーを宣言し、値となるデータ型を宣言します。 クラス LeaderStaff には、 total=False としています。これはここで宣言されたクラス属性は必須ではなく、オプションのキーとなります。

支店を表す構造をクラス Branch としています。ここには 2 つのクラス属性を宣言しています。 staff には、リストでスタッフを持つようにしています。リストには leader_period があってもなくてもいいように LeaderStaff としています。

この様に辞書に対して明確に型ヒントを付けるには、データ構造から見直す必要があります。 ただ、より明確に型ヒントを付けることで、以前の構造ではできなかった、キーを取得した時点でデータ型は定まるという恩恵を受けることができます。

変更した構造に合わせた関数 get_staff_names() を見ていきましょう。

def get_staff_names(data: CompanyBranches, branch_name: BranchNames) -> list[str]:
    """company_branchesから、branch_nameに所属するstaffのnameをリストで出力"""
    staff_names = []
    for branch in data:
        if branch["branch_name"] == branch_name:
            for staff in branch["staff"]:
                staff_names.append(staff["name"])
    return staff_names

データ構造が変わりましたので、少しコードを変更しています。

この関数の戻り値のデータ型を list[str] とすることができるようになりました。 これにより、使う側で TypeGuard を使った判定を行う必要がなくなります。

さらに、 TypedDict では、必須の辞書のキーを決めていますので、辞書を宣言する際のキー設定忘れを型チェックで確認することが可能になります。

オプションのキーをより明確に宣言

2022 年 10 月にリリース予定の Python 3.11 には、 NotRequired という新たな仕組みが入ります。 これは、 PEP 655 (Marking individual TypedDict items as required or potentially-missing) で提案されて正式に取り込まれることが決まりました。

先程の TypedDict の宣言を変更すると以下のようになります。

from typing import TypedDict, NotRequired

class Staff(TypedDict):
    number: str
    name: str
    is_leader: bool
    leader_period: NotRequired[int]


class Branch(TypedDict):
    branch_name: BranchNames
    staff: list[Staff]

一つの TypedDict を継承したクラスの中に、オプションのキーを宣言することができるようになります。

なお、Python3.10 以前でこの機能を使いたい場合は、サードパーティー製ライブラリ typing_extensions を導入し、以下のようにインポートすると利用できるようになります。

from typing_extensions import TypedDict  # TypedDictもtyping_extensionsからインポートする
from typing_extensions import NotRequired

JSON を取り込む

ここまでは、Python の辞書やリストを直接定義してきました。実際にはこのようなデータは JSON で渡ってくることが多かと思います。 その場合は JSON を Python のオブジェクトに変換し、型ヒントを宣言することができます。

ここで注意があります。 TypedDict は型ヒントとしてしか機能しません。これは JSON がどのようなものかをチェックする機構が無いということです。 JSON で渡ってくるデータ構造は、別の仕組みでチェックをする必要があります。これは、Python のサードパーティー製ライブラリである、 jsonschema のようなもので JSON を受け取る段階でチェックを行う必要があるということです。

他の方法として、 dataclass を用いて独自のオブジェクトを作り、より厳密で型安全なコードを書くことができます。 その際には、 dataclass__post_init__() メソッドを用いて受け取ったデータが正しいかをチェックしてオブジェクト化する方法もあります。

まとめ

今回は、「より良い型ヒントの書き方」の説明を、最新の Python の機能とあわせて解説しました。

Python は年に 1 度の機能アップを伴うマイナーリリースが行われています。リリースごとに型ヒントの機能もアップしており、より型安全なコードが書けるようになっています。

みなさんも徐々に型ヒントを明確に付けるということに挑戦をしていただければと思います。