Python Monthly Topics

Python型ヒントの動向と新しい機能の紹介

鈴木たかのり@takanoryです。今月の「Python Monthly Topics」では、Pythonの型ヒントの最近の動き、比較的新しい型ヒントの機能について紹介します。

本連載でも過去にいくつも型ヒント関連の記事があります。このようによりよいPythonコードを書くための型ヒントが、Pythonバージョンの更新に伴って追加されています。

PEP 729 – Typing governance process⁠型ヒントのガバナンス

今回調べて知ったのですが、以下のドキュメント(PEP 729)で型ヒントのガバナンス(運営管理)方法について提案されており、2023年11月に採択されました。

PEP 729では型ヒントの保守、開発を行うPython型ヒント評議会Python Typing Councilを立ち上げることが提案されています。Pythonの言語仕様はPEP(Python Enahncement Proposal:Python拡張提案)というドキュメントで提案され、その提案が採択されることで決定されます。PEPを採択するかの判断はPython Steering Council(以下SC)が行っており、SCは5名の評議委員で構成されています。しかし、SCはPythonのすべてのPEPに対して検討・判断を行うため「型ヒントのPEPに対して適切な判断をすることが難しい」ということがPEP 729で主張されています。

最初に書いたとおりPEP 729はSCによって2023年11月に採択され、現在5名の初期メンバーがSCにより任命されています。5名のメンバーは、以下のように型ヒントや型関連のツールに習熟した開発者が選ばれています。

現在のメンバーは以下のリポジトリで記録されています。メンバー数は3~5名で、最長連続5年などの制限があります。

評議会はその取り組みの一部として、以下のことを行います。

  • 仕様に準拠したテストスイートの作成
  • 型システムの仕様の作成
  • 型システムのユーザー向けリファレンスの作成

なお、Python Steering Councilでの運営は、Guido van Rossum氏がBDFLから引退したことを受け、2019年から始まっています。SCについて詳細を知りたい方は以下の参考資料を確認してください。

型ヒントのドキュメント

上記の評議会の成果のひとつとして、型ヒント、型システムを使用するユーザー向けのリファレンスドキュメントが以下のサイト「Static Typing with Python」で公開されています。

Static Typing with Python
URL:https://typing.readthedocs.io/
Static Typing with Python

いままで型ヒントについてはPEPのみが仕様書で、PEPドキュメント自体は仕様を議論するためのドキュメントのため、ユーザー用のドキュメントとしては適切ではありません。そこで、型システムについてのユーザー向けドキュメントが作成されました。このドキュメントでは型システムのガイド、リファレンス、仕様が書かれています。また、型関連ツールが紹介されています。

仕様(Specification)のセクションには、各種型ヒントの仕様や使い方が記載されています。以降で説明する新しい型ヒントの機能についても、元となったPEPのドキュメントから、型ヒントのドキュメントへの参照が追加されています。

「Attention」の中で型ヒントのドキュメントを参照している
「Attention」の中で型ヒントのドキュメントを参照している

型ヒントの公式ドキュメントは以下のリポジトリで管理されています。

@override デコレーター

Python 3.12でtypingモジュールに@overrideというデコレーターが追加されました。

このデコレーターはクラスを継承したときに、サブクラスがスーパークラスの属性やメソッドをオーバーライドしていることを表します。もし、このデコレーターが付いている属性やメソッドが、実際にはなにもオーバーライドしていない場合は、型チェッカーはエラーを出力します。こうすることで「オーバーライドしたつもりだけど、実はオーバーライドしていない」というミスを防ぎます。

以下のサンプルコードでは、Petを継承したFerretサブクラスを作成しています。サブクラスでは2つのメソッドを定義して、両方に@overrideデコレーターを指定しています。

override_sample.py
from typing import override


class Pet:
    """ペットを表すクラス"""
    name: str

    def sleep(self) -> None:
        print(f"{self.name}-chan is sleeping.")

    def eat(self) -> None:
        print(f"{self.name}-chan is eating.")


class Ferret(Pet):
    """フェレットを表すクラス"""
    @override
    def sleep(self) -> None:  # OK
        print(f"Ferret, {self.name}-chan is sleeping.")

    @override
    def eet(self) -> None:  # NG
        print(f"Ferret, {self.name}-chan is eating.")

pyrightでこのコードをチェックするとeetメソッドにoverrideマークが付いているが、ベースメソッドが存在していない」というエラーが発生します。

% pyright override_sample.py
/.../override_sample.py
  /.../override_sample.py:22:9 - error: Method "eet" is marked as override, but no base method of same name is present (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations 

このエラーは、サブクラスで本来eatと書くべきところをeetと間違えているために発生しています。この間違いに気がつかないと、Ferret.eat()を呼び出した時に親クラスのメソッドが呼び出されてしまいます。@overrideを書くことによって、pyright、mypyなどの型チェッカーでチェックできるようになります。

TypedDictRequiredNotRequiredを使用する

Python 3.11でtypingモジュールにRequiredNotRequiredが追加されました。この2つの型ヒントは、TypedDictと組み合わせて使用します。TypedDictで辞書の各キーに対して型ヒントを定義し、各キーに対して必須、非必須を指定できます。

TypedDictの基本

まずはTypedDictの動作を確認します。以下のコードでは辞書の3つのキーnamefarmageに対して、型ヒントでstrintを指定しています。

typeddict_sample1.py:TypedDictで辞書を定義
from typing import TypedDict


class Ferret(TypedDict):
    """フェレットを表す辞書"""
    name: str
    farm: str
    age: int


guri1: Ferret = {"name": "guri", "farm": "Canadian", "age": 5}  # OK
guri2: Ferret = {"name": "guri", "farm": "Canadian", "age": "five"}  # NG
guri3: Ferret = {"name": "guri", "age": 5}  # NG

このコードをpyrightでチェックすると、2番目と3番目のパターンでエラーが発生します。12行目はageにはintを指定すべきところをstrを指定しているためにエラーになっています。13行目はfarmキーが指定されていないためにエラーとなっています。

% pyright typeddict_sample1.py 
/.../typeddict_sample1.py
  /.../typeddict_sample1.py:12:61 - error: Type "dict[str, str]" is not assignable to declared type "Ferret"
    "Literal['five']" is not assignable to "int" (reportAssignmentType)
  /.../typeddict_sample1.py:13:17 - error: Type "dict[str, str | int]" is not assignable to declared type "Ferret"
    "farm" is required in "Ferret" (reportAssignmentType)
2 errors, 0 warnings, 0 informations 

NotRequiredを使用

farmキーを非必須にするためにNotRequiredを使用します。型ヒントにNotRequired[str]と書くことで、このキーが非必須(オプション)となります。

typeddict_sample2.py:NotRequiredを使用
from typing import NotRequired, TypedDict


class Ferret(TypedDict):
    """フェレットを表す辞書"""
    name: str
    farm: NotRequired[str]  # farmを非必須にする
    age: int


gura1: Ferret = {"name": "gura", "farm": "Path Valley", "age": 6}  # OK
gura2: Ferret = {"name": "gura", "farm": "Path Valley", "age": "six"}  # NG
gura3: Ferret = {"name": "gura", "age": 6}  # OK

このコードをpyrightでチェックすると、farmキーを指定していない13行目がエラーではなくなります。

% pyright typeddict_sample2.py 
/.../typeddict_sample2.py
  /.../typeddict_sample2.py:12:64 - error: Type "dict[str, str]" is not assignable to declared type "Ferret"
    "Literal['six']" is not assignable to "int" (reportAssignmentType)
1 error, 0 warnings, 0 informations 

Requiredを使用

TypedDicttotal=Falseと指定するとすべてのキーが非必須となります。その場合、逆にRequiredを使用して必須に指定できます。以下のコード例ではnameageのみを必須としています。

typeddict_sample3.py:Requiredを使用
from typing import Required, TypedDict


class Ferret(TypedDict, total=False):
    """フェレットを表す辞書"""
    name: Required[str]
    farm: str
    color: str
    age: Required[int]


seven1: Ferret = {"name": "seven", "farm": "Far Farm", "age": 6}  # OK
seven2: Ferret = {"name": "seven", "color": "Black Self", "age": 6}  # OK
seven3: Ferret = {"name": "seven", "age": 6}  # OK
seven3: Ferret = {"name": "seven", "color": "Black Self"}  # NG

上記のコードをpyrightでチェックすると、最後のパターンで必須Requiredの要素ageが指定されていないためエラーとなります。

% pyright typeddict_sample3.py   
/.../typeddict_sample3.py
  /.../typeddict_sample3.py:15:18 - error: Type "dict[str, str]" is not assignable to declared type "Ferret"
    "age" is required in "Ferret" (reportAssignmentType)
1 error, 0 warnings, 0 informations 

任意の文字列リテラル型

Python 3.11でtypingモジュールにLiteralStringが追加されました。この型ヒントは文字列リテラルのみを表します。str型ではなく、文字列リテラルで作成した文字列のみで構成された文字列のみが使用できます。なお、LiteralStringは型チェックにのみに使用される特別な形式で、データ型としては存在しません。

literalstring_sample.py:LiteralStringを使用
from typing import LiteralString
from pathlib import Path


def eat(name: LiteralString) -> None:
    """LiteralString型のnameのみを受け取る"""
    print(f"{name}-chan is eating.")


eat("seven")  # OK
name = "seven"
eat(name)  # OK
eat(name.title())  # OK
eat("Ferret, " + name)  # OK
name = input()
eat(name)  # NG
eat(Path("name.txt").read_text())  # NG

上記のコードをpyrightでチェックすると、input()で入力を受け取る場合とファイルから文字列を取得する場合にLiteralStringではなくstrとなるためエラーが発生します。.title()で文字列を変換する場合や+演算子で文字列を連結していても、元となる文字列がLiteralString型の場合、変換された文字列もLiteralStringとなるためエラーとなりません。

% pyright literalstring_sample.py
/..;/literalstring_sample.py
  /.../literalstring_sample.py:16:5 - error: Argument of type "str" cannot be assigned to parameter "name" of type "LiteralString" in function "eat"
    "str" is not assignable to "LiteralString" (reportArgumentType)
  /.../literalstring_sample.py:17:5 - error: Argument of type "str" cannot be assigned to parameter "name" of type "LiteralString" in function "eat"
    "str" is not assignable to "LiteralString" (reportArgumentType)
2 errors, 0 warnings, 0 informations 

この機能は、たとえばプログラム中で実行するSQLやシェルのコマンドに、ユーザーが入力したデータが混入することを防ぐといった用途に使用できます。以下のコード例では、最後の2つのパターンはLiteralStringではないため、型チェックを実行するとエラーとなります。

literal_sql_sample.py:SQLを実行する関数でLiteralStringを使用
from typing import LiteralString


def run_query(sql: LiteralString) -> None:
    ...


def caller(arbitrary: str, literal: LiteralString) -> None:
    run_query("SELECT * FROM animals")  # OK
    run_query(literal)  # OK
    run_query("SELECT * FROM " + literal)  # OK
    run_query(f"SELECT * FROM {literal}")  # OK
    run_query(arbitrary)  # NG
    run_query(f"SELECT * FROM animals WHERE name = {arbitrary}")  # NG

まとめ

本記事では、最近の型ヒントの動きとして以下を紹介しました。

また、最近追加された以下の型ヒントを紹介しました。

  • オーバーライドしたメソッドに指定する@overrideデコレーター
  • TypedDictの必須、非必須のキーを指定するRequiredNotRequired
  • 文字列リテラルのみを含むLiteralString

評議会による運営により、今後も継続的に型システムに関する機能が追加・改善されていくと思われます。どのような機能が出てくるのか楽しみです。

おすすめ記事

記事・ニュース一覧