Python Monthly Topics

Python最新バージョン対応!より良い型ヒントの書き方

寺田 学です。9月の「Python Monthly Topics」は、Python 3.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バージョンによる変化

コレクション型

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

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

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

Python 3.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"}

また、Python 3.7およびPython 3.8では、from __future__ import annotationsを記述することで、小文字始まりのlistなどで型宣言することが可能です。

Union

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

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

Python 3.9以前
from typing import Union
data: Union[str, int] = 20
Python 3.10以降
data: str | int = 20

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

Python 3.9以前
from typing import Optional

data: Optional[int] = None
Python 3.10以降
data: int | None = None

この機能をPython 3.7からPython 3.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を使う

ここでの解決策として、Python 3.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()を使っても同様になります。

ただ、今回のケースではリストの中の型を確認する必要があり、Python 3.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氏がお勧めすると力説していました。キーノートの動画はYouTubeで公開されています。興味のある方はご覧になってください。

URL:https://www.youtube.com/watch?v=wbohVjhqg7c

ここからは、Python 3.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]

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

なお、Python 3.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度の機能アップを伴うマイナーリリースが行われています。リリースごとに型ヒントの機能もアップしており、より型安全なコードが書けるようになっています。

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

おすすめ記事

記事・ニュース一覧