🐍

Python 3.8以降の型ヒント革命:DataclassとPydanticの徹底比較

2023/12/01に公開

はじめに 📘

この記事は ラクスパートナーズ Advent Calendar 2023 の1日目の記事になります!!
本社の ラクス Advent Calendar 2023 の7日目にも参加予定なのでそちらもよろしくお願い致します🥳

長い間 Python3.7 環境のプロジェクトに携わっていましたが、この度 Python3.10~ 環境のプロジェクトに携わることになりました。
そこでこの機会に python3.8 以降の最新の型ヒントやコード品質向上のテクニックについて、改めて情報をキャッチアップしながらまとめていきたいと思います。

この記事の対象者 🎯

  • Python の型ヒントについて学び直したい方
  • Python3.8 以降の型ヒントについて理解を深めたい方
  • python のドメインモデルクラスについて理解を深めたい方
  • 型ヒントを使用したことがないが、興味がある方

なぜ型ヒントを使うのか? 🤔

一般的に、型ヒントは以下のようなメリットがあるとされています

  • ✅ コードの可動性向上
  • ✅ IDEにおけるコード補完の充実
  • ✅ 静的型チェックの実行
  • ✅ コードの堅牢性向上

個人的には、これに加えて、クラス設計力やアーキテクチャ設計力がより向上しやすくなるというメリットもあると考えています。
Python は動的型付け言語ですが、型を全く意識せずに開発することが推奨されているわけではありません

Pythonにもインターフェースのような機能が実装可能であり、型を意識する文化が根付いています。
型をしっかりと意識し、インターフェースを実装することで、より可読性が高く、堅牢なコードを作成することが可能です。
そして、これがクラス設計やアーキテクチャ設計の上達に繋がると考えています。

第1部:Python 3.8以降の型ヒントの進化 👆

Python3.8 で追加された型

Literal

Literal は、オブジェクトが提供されたいずれかの値と等価であることを示すために使用されます。

from typing import Literal

def open_helper(file: str, mode: Literal['r', 'rb', 'w', 'wb']) -> str:
    ...

open_helper(file='/some/path', mode='r')      # Passes type check
open_helper(file='/other/path', mode='typo')  # Error in type checker
🚨Pylance
型 "Literal['typo']" の引数を、関数 "open_helper" の型 "Literal['r', 'rb', 'w', 'wb']" のパラメーター "mode" に割り当てることはできません
  型 "Literal['typo']" を型 "Literal['r', 'rb', 'w', 'wb']" に割り当てることはできません
    "Literal['typo']" を型 "Literal['r']" に割り当てることはできません
    "Literal['typo']" を型 "Literal['rb']" に割り当てることはできません
    "Literal['typo']" を型 "Literal['w']" に割り当てることはできません
    "Literal['typo']" を型 "Literal['wb']" に割り当てることはできません

Final

Final で宣言された名前は、どのスコープでも再割り当てできません。クラススコープで宣言された Final のキーは、サブクラスでのオーバーライドもできません。

MAX_SIZE: Final = 9000
MAX_SIZE += 1  # Error reported by type checker

class Connection:
    TIMEOUT: Final[int] = 10

class FastConnector(Connection):
    TIMEOUT = 1  # Error reported by type checker
🚨Pylance
"MAX_SIZE" は定数であり (大文字であるため)、再定義できません

親クラス "Connection" が Final として宣言しているため、"TIMEOUT" を再宣言できません

Protocol

主に duck-typing[1] を認識する静的型チェッカーとして使用します。

ダックタイピングは type()isinstance() による判定を避けます。その代わり hasattr() 判定や EAFP プログラミングを利用します。

例えば、下記のように Dog2Duck と同じ quack関数が定義されておりインターフェイスが同じなため、型ヒントによるエラーが表示されません。

from typing import Protocol

class Duck(Protocol):
    """アヒルのように鳴くアヒル"""
    def quack(self) -> str:
        ...

class Dog1:
    "犬のように鳴く犬"
    def bowwow(self) -> str:
        ...

class Dog2:
    """アヒルのように鳴く犬"""
    def quack(self) -> str:
        ...

def func(x: Duck) -> str:
    return x.quack()

func(x=Dog1())  # Error reported by type checker
func(x=Dog2())  # Passes static type check
🚨Pylance
型 "Dog1" の引数を、関数 "func" の型 "Duck" のパラメーター "x" に割り当てることはできません
  "Dog1" はプロトコル "Duck" と互換性がありません
    "quack" が存在しません

runtime_checkable

protocol class を runtime protocol としてマークできます。マークされたクラスは isinstance()issubclass()とともに使用することが可能となり、構造的チェックを可能にします。

from typing import Protocol, runtime_checkable


class DuckWithOutRuntimeCheckable(Protocol):
    """アヒル"""
    def quack(self) -> str:
        return "quack quack"


@runtime_checkable
class Duck(Protocol):
    """アヒル"""
    def quack(self) -> str:
        return "quack quack"

class Dog1:
    "犬のように鳴く犬"
    def bowwow(self) -> str:
        return "bowwow bowwow"

class Dog2:
    """アヒルのように鳴く犬"""
    def quack(self) -> str:
        return "quack quack"

def func(x: Duck) -> str:
    return x.quack()

assert isinstance(Dog1, Duck) # cause error
assert isinstance(Dog2, Duck) # pass assert
assert isinstance(Dog2, DuckWithOutRUntimeCheckable) # cause error
# > TypeError: Instance and class checks can only be used with @runtime_checkable protocols

TypedDict

TypedDict は、その全てのインスタンスにおいてキーの集合が固定されていて、各キーに対応する値が全てのインスタンスで同じ型を持つことが期待される辞書型を宣言します。 型は実行時にはチェックされず、型チェッカーでのみ強制されます。

from typing import TypedDict


class Point2D(TypedDict):
    x: int
    y: int
    label: str

a: Point2D = {'x': 1, 'y': 2, 'label': 'good'}  # OK
b: Point2D = {'z': 3, 'label': 'bad'}           # Fails type check
assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') # passed
🚨Pylance
型 "dict[str, int | str]" の式を宣言された型 "Point2D" に割り当てることはできません
  "z" は型 "Point2D" の未定義のフィールドです

keyword であったりハイフンを含む名前はエラーとなりますが、functional syntax を使えば実装可能。

# raises SyntaxError
class Point2D(TypedDict):
    in: int  # 'in' is a keyword
    x-y: int  # name with hyphens

# OK, functional syntax
Point2D = TypedDict('Point2D', {'in': int, 'x-y': int})

TypedDict では、デフォルトでは全てのキーが必須ですが、NotRequired[2] を使って任意のキーを Optional なキーとして扱うことが可能です。

class Point2D(TypedDict):
    x: int
    y: int
    label: NotRequired[str]

# Alternative syntax
Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': NotRequired[str]})

必要なkeyのみを Required[3] を用いて逆に指定することも可能です。
キー数が多くほとんどが Optional のキーとなる場合などはこちらの方が楽そうです。

class Point2D(TypedDict, total=False):
    x: Required[int]
    y: Required[int]
    label: str

# Alternative syntax
Point2D = TypedDict('Point2D', {
    'x': Required[int],
    'y': Required[int],
    'label': str
}, total=False)

generic type にも対応してます。

T = TypeVar("T")

class Group(TypedDict, Generic[T]):
    key: T
    group: list[T]

final

@finalデコレータは、メソッドやクラスが最終的であり、サブクラスでのオーバーライドや継承を禁止することを示します。メソッドに@finalを適用すると、そのメソッドはサブクラスでオーバーライドすることができなくなります。クラスに@finalを適用すると、そのクラスは継承できなくなります。

from typing import final


class Base:
    @final
    def done(self) -> None:
        ...


class Sub(Base):
    def done(self) -> None:  # Error reported by type checker
        ...


@final
class Leaf:
    ...


class Other(Leaf):  # Error reported by type checker
    ...
🚨Pylance
メソッド "done" は、クラス "Base" で定義されている最終的なメソッドをオーバーライドできません

基底クラス "Leaf" は final とマークされており、サブクラス化できません

Python3.9 で追加された型

Annotated

簡単に言うと、型に追加情報を記載することが可能となります。
また追加された情報は型そのものには影響を与えません。

例えば、
下記のコードはBMIを計算するための関数ですが weighthight の単位がわかりません.
単位の情報に関してはコメントにも残せますが,typing.Annotated を使用することで,より簡潔にまた規則的に情報を残すことが可能です。

from typing import Annotated


def calculate_bmi(weight: float, hight: float) -> float:
    return weight / (hight*hight)

# Annotatedを使用して単位の情報を追加することが可能
def calculate_bmi_with_info(weight: Annotated[float, "kg"], hight: Annotated[float, "m"]) -> float:
    return weight / (hight*hight)

Python3.10 で追加された型

「|」演算子を使用したユニオン型の指定

以前は Union 型を表現するために下記のように定義する必要がありました。

from typing import Union

def just_return(var: Union[str, int]) -> Union[str, int]:
    return var

python3.10 以降は 演算子を用いて簡潔に記載できるようになります。
これは便利!!

def just_return(var: str | int) -> str | int:
    return var

TypedGuard

TypedGuard が登場する以前と比較して考えます。
下記のように func1 内部で引数が str のリストか int のリストかによって処理を分けたいケースが存在するとします。
この場合、is_str_list という関数を作り、その返値として boolean を返すことで条件分岐をすることを考えます。

しかし、型ヒントではこれを保証することができず Pylance に怒られます。。。コード上では必ずlist(str)なんですけどね。。。

def is_str_list(val: list[str | int]) -> bool:
    '''Determines whether all objects in the list are strings'''
    return all(isinstance(x, str) for x in val)

def func1(val: List[str | int]):
    if is_str_list(val):
        # Type of ``val`` is narrowed to ``list[str]``.
        # しかし、型ヒントにおいては list[str] とは保証されない
        print(" ".join(val))
    else:
        # Type of ``val`` remains as ``list[object]``.
        print("Not a list of strings!")
🚨Pylance
型 "list[object]" の引数を、関数 "join" の型 "Iterable[str]" のパラメーター "__iterable" に割り当てることはできません
  "list[object]" は "Iterable[str]" と互換性がありません
    型パラメーター "_T_co@Iterable" は共変ですが、"object" は "str" のサブタイプではありません
      "object" は "str" と互換性がありません

しかし TypedGuard を使用することで、型ヒントにおいても型を保証することが可能となります。

def is_str_list(val: List[str | int]) -> TypeGuard[List[str]]:
    '''Determines whether all objects in the list are strings'''
    return all(isinstance(x, str) for x in val)

def func1(val: List[str | int]):
    if is_str_list(val):
        # 型ヒントにおいてもこの時点で list(str) が保証される。
        print(" ".join(val))
    else:
        # Type of ``val`` remains as ``list[object]``.
        print("Not a list of strings!")

TypeAlias

TypeAlias is particularly useful on older Python versions for annotating aliases that make use of forward references, as it can be hard for type checkers to distinguish these from normal variable assignments:

公式ドキュメントにあるように、古いpythonバージョンにおいて前方参照を行うときに役立ちます。

python3.12 以前のバージョンにおいて
Python の型ヒントでは、未定義の型を参照するときには、文字列で型名を指定する必要がありました。

class Box():
    @classmethod
    def of(cls) -> "Box":
        ...

しかし、型エイリアスには、文字列を使った前方参照を指定できません。次のように型エイリアスを文字列で指定しても、エラーとなります。

MYTYPE = "Box"

class Box():
    @classmethod
    def of(cls) -> MYTYPE:
        ...

def sample_func(c: MYTYPE) -> Any:
    ...

ここで使用するのが TypeAlias です。

TypeAlias を使って明示的に型エイリアスを定義すると
型チェッカーは MYTYPE が型エイリアスであることを理解して、型名として利用できるようになります。

from typing import Any, TypeAlias

MYTYPE: TypeAlias = "Box"

class Box():
    @classmethod
    def of(cls) -> MYTYPE:
        ...

def sample_func(c: MYTYPE) -> Any:
    ...

Concatenate, ParamSpec

この型は少し複雑なので順を追って話していきます。
まずは、以下のような with_lock デコレーター関数を考えてみます。
このデコレーター関数は関数を引数として受け取りそのまま返しているだけなので、
何の意味も持たないです。

from typing import List
from threading import Lock

def with_lock(f):
    def inner(*args, **kwargs):
        return f(*args, **kwargs)
    return inner

@with_lock
def sum_threadsafe(lock: Lock, numbers: List[float]) -> float:
    '''Add a list of numbers together in a thread-safe manner.'''
    with lock:
        return sum(numbers)

Python の型ヒントでは関数のような呼び出し可能なものは Callable[[引数の型のリスト], 戻り値の型] のように記述できます。
with_lock は関数を受け取り、関数を返送するので、コードに型ヒントを追記すると次のようになります。
ちなみに sum_threadsafe をこのデコレーターでラップすると R には sum_threadsafe の戻り値である float がキャプチャーされます。

R = TypeVar("R")

def with_lock(f: Callable[..., R])-> Callable[..., R]:
    def inner(*args, **kwargs) -> R:
        return f(*args, **kwargs)
    return inner

@with_lock
def sum_threadsafe(lock: Lock, numbers: list[float]) -> float:
    '''Add a list of numbers together in a thread-safe manner.'''
    with lock:
        return sum(numbers)

次に *args, *kwargs の型ヒントを定義していきます。
python3.10 以前では下記のように Any を使用する必要がありました。

しかし、Any を使用することで実質的に型チェックは行なわれないことになります。
これでは型ヒントを書いている意味合いが薄れてしまいます。

from typing import Any

def with_lock(f: Callable[..., R])-> Callable[..., R]:
    def inner(*args: Any, **kwargs: Any) -> R:
        return f(*args, **kwargs)
    return inner

@with_lock
def sum_threadsafe(lock: Lock, numbers: list[float]) -> float:
    '''Add a list of numbers together in a thread-safe manner.'''
    with lock:
        return sum(numbers)

sum_threadsafe(lock=Lock(), numbers=[1.1, 2.2, 3.3]) # passes type checker
sum_threadsafe(lock=Lock(), numbers=["1.1", 2.2, "3.3"]) # passes type checker but it would fail

この問題を解決するために出てくるのが ParamSpec 演算子です。
P = ParamSpec('P') とすることで、パラメーター仕様(関数のパラメーターの型)を表す変数が得られます。

P = ParamSpec('P')

def with_lock(f: Callable[P, R])-> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return f(*args, **kwargs)
    return inner

@with_lock
def sum_threadsafe(lock: Lock, numbers: list[float]) -> float:
    '''Add a list of numbers together in a thread-safe manner.'''
    with lock:
        return sum(numbers)

sum_threadsafe(lock=Lock(), numbers=[1.1, 2.2, 3.3]) # passes type checker
sum_threadsafe(lock=Lock(), numbers=["1.1, 2.2, 3.3"]) # Failed type checker
🚨Pylance
型 "list[str]" の引数を型 "list[float]" のパラメーター "numbers" に割り当てることはできません
  "Literal['1.1, 2.2, 3.3']" は "float" と互換性がありません

次に lock instance を引数で渡さずに特定のインスタンスを常に参照したいケースを考えます。
Concatenate を用いて以下のように実装することで実現可能となります。
with_lock 内の inner 関数によって lock 引数が渡されるため、sum_threadsafe の引数として lock 引数を渡す必要がなくなります。


# Use this lock to ensure that only one thread is executing a function
# at any time.
my_lock = Lock()
P = ParamSpec('P')

def with_lock(f: Callable[Concatenate[Lock, P], R])-> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return f(my_lock, *args, **kwargs)
    return inner

@with_lock
def sum_threadsafe(lock: Lock, numbers: list[float]) -> float:
    '''Add a list of numbers together in a thread-safe manner.'''
    with lock:
        return sum(numbers)

sum_threadsafe(numbers=[1.1, 2.2, 3.3]) # passes type checker

上記の例では、結果的に関数に渡す引数を減らしましたが、逆に増やすことも可能です。

my_lock = Lock()
P = ParamSpec('P')

def with_lock(f: Callable[Concatenate[Lock, P], R])-> Callable[Concatenate[int, P], R]:
    def inner(a: int, *args: P.args, **kwargs: P.kwargs) -> R:
        print(a)
        return f(my_lock, *args, **kwargs)
    return inner

@with_lock
def sum_threadsafe(lock: Lock, numbers: list[float]) -> float:
    '''Add a list of numbers together in a thread-safe manner.'''
    with lock:
        return sum(numbers)

# Failed type checker
result = sum_threadsafe(numbers=[1.1, 2.2, 3.3])

Python3.11 で追加された型

LiteralString

文字列定数及び他の LiteralString 型と適用する型になります。
文字列定数とはプログラム中に直接書き込まれた文字列のことを指します。

以下、公式のサンプルを見て見ましょう。

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

def caller(arbitrary_string: str, literal_string: LiteralString) -> None:
    run_query("SELECT * FROM students")  # 文字列定数なのでOK
    run_query(literal_string)  # LiteralString 型なのでOK
    run_query("SELECT * FROM " + literal_string)  # 文字列定数および LiteralString なのでOK
    run_query(arbitrary_string)  # 文字列定数以外なのでError
    run_query(  # type checker error
        f"SELECT * FROM students WHERE name = {arbitrary_string}" # 文字列定数以外を使用している為Error
    )

SQLインジェクションなど外部入力によってバグや想定外の挙動が起こされるケースを防止するのに役立ちます。

LiteralString is useful for sensitive APIs where arbitrary user-generated strings could generate problems. For example, the two cases above that generate type checker errors could be vulnerable to an SQL injection attack.

Self

内包されているクラス自身を表すタイプになります。
日本語が難しいので、例を見ていきましょう

from typing import Self, reveal_type

class Foo:
    def return_self(self) -> Self:
        ...
        return self

class SubclassOfFoo(Foo): pass

reveal_type(Foo().return_self())  # Revealed type is "Foo"
reveal_type(SubclassOfFoo().return_self())  # Revealed type is "SubclassOfFoo"

いや、ちょっと待てと、
文字列でも自身のクラス名をタイプとしてアノテーションすれば Self を使わなくてもいけるでしょ?
と思う方もいると思いますが、その場合サブクラスにおいて問題が生じます。

下記のコードの場合、 SubclassOfFooreturn_self の返り値は Foo だとタイプチェッカーに認識されてしまいます。
サブクラスでも自身のクラスを返り値としてタイプチェッカーに認識させるためにも Self を使いましょう。

class Foo:
    def return_self(self) -> "Foo":
        ...
        return self

class SubclassOfFoo(Foo): pass

Unpack

この型はオブジェクトが展開されたものであると表すタイプになります。

from typing import TypedDict, Unpack

class Movie(TypedDict):
    name: str
    year: int

# 下記の Unpack を用いた記載内容は def foo(name: str, year: in ): ... と同義になります。
def foo(**kwargs: Unpack[Movie]): ...

TypeVarTuple

可変長ジェネリック型を表す為に使用します。

順を追って理解していきましょう。

まずジェネリック型についてです。
ジェネリック型とは特定の型を示さずに汎用的な型として使用できる型のことを指します。

Any を使用する場合と違い、引数の型によって動的に返り値の型を変化させることができます。

引数が int 型なら返り値は tuple[int]
引数が str 型なら返り値は tuple[str]
といったことが可能となります。

from typing import TypeVar

T = TypeVar("T")

def convert_to_tuple(any_value: T) -> tuple[T]:
    return (any_value,)

v = conver_to_tuple(1) # vの型は tuple[int]
v = conver_to_tuple("1") # vの型は tuple[str]

今度は複数のタイプを持つ tuple 型を表現したい場合を考えましょう。
TypeVar は任意の単一タイプしか表現できません。そこで登場するのが TypeVarTuple です。

下記のように複数の任意のタイプを含む tuple 型を表現できます。

from typing import TypeVarTuple

Ts = TypeVarTuple("Ts")

def do_nothing(ts: tuple[*Ts]) -> tuple[*Ts]:
    return ts

result = do_nothing(ts=(1, "1")) # Pass type checker
result = do_nothing(ts=(1, "1", 1)) # Pass type checker
result = do_nothing(ts=1) # Fail type checker
🚨Pylance
型 "Literal[1]" の引数を、関数 "do_nothing" の型 "tuple[*Ts@do_nothing]" のパラメーター "ts" に割り当てることはできません
  "Literal[1]" は "tuple[*Ts@do_nothing]" と互換性がありません

tuple 型の先頭や最後尾の element をタイプとして表現する際のコードも見ていきましょう。

T = TypeVar("T")
Ts = TypeVarTuple("Ts")

def move_first_element_to_last(tup: tuple[T, *Ts]) -> tuple[*Ts, T]:
    return (*tup[1:], tup[0])

# 返り値の型タイプは先頭のエレメントを最後尾に移動したものとして認識されてます。
result1: tuple[str, str, int] = move_first_element_to_last(tup=(1, "3", "3"))

def move_end_element_to_last(tup: tuple[*Ts, T]) -> tuple[T, *Ts]:
    return (tup[0], *tup[1:])

# 返り値の型タイプは最後尾のエレメントを先頭に移動したものとして認識されてます。
result2: tuple[str, int, str] = move_end_element_to_last(tup=(1, "3", "3"))

assert_type

開発中に、想定通りの型となっているかを確認する用途で使用します。
タイプチェックを行うだけであり動作には何の影響も与えません。

def complex_function(arg: object):
    # 下記に何かしらの複雑な処理を記載したとします。
    ...
    # 途中の結果が想定通り int 型になっているかを確認したいので assert_type を使用
    tmp_var = arg
    assert_type(tmp_var, int)
    print("test") # 仮に assert_type で想定通りの結果でなくても print は実行される。
🚨Pylance
"assert_type" の不一致: "int" が必要ですが、"object" を受信しました

assert_never

静的タイプチェッカーに該当ラインに到達不可能であることを確認させるために使用します。
assert_never に到達するとエラーが生じます。

def int_or_str(arg: int | str) -> None:
    match arg:
        case int():
            print("It's an int")
        case str():
            print("It's a str")
        case _ as unreachable:
            assert_never(unreachable)

int_or_str(arg=[]) # Cause error
# > AssertionError: Expected code to be unreachable, but got: []

reveal_type

引数がどの型として扱われているかを診断するために使用します。
debug 用途で役に立つもので、静的タイプチェッカーが変数をどの型で扱っているかを確認しながら開発することが可能となります。

x: int = 1
reveal_type(x)  # Revealed type is "builtins.int"

dataclass_transform

静的タイプチェッカーにマークされたオブジェクトを dataclass のように振る舞うことを伝えるために使用します。
デコレーターを使用して実装され、付与されたクラスは __init__ メソッドが実装されたかのように扱われることになります。

@dataclass_transform()
class ModelBase: ...

class CustomerModel(ModelBase):
    id: int
    name: str

デコレーターによる裏側の処理はとてもシンプルで下記のように __dataclass_transoform__情報を付与しているだけです。
どうやらこの情報を付与された dataclass はタイプチェックを行う挙動になっているみたいです。。。🤔

def dataclass_transform(
    *,
    eq_default: bool = True,
    order_default: bool = False,
    kw_only_default: bool = False,
    field_specifiers: tuple[type[Any] | Callable[..., Any], ...] = (),
    **kwargs: Any,
) -> Callable[[T], T]:
    ...
    def decorator(cls_or_fn):
        cls_or_fn.__dataclass_transform__ = {
            "eq_default": eq_default,
            "order_default": order_default,
            "kw_only_default": kw_only_default,
            "field_specifiers": field_specifiers,
            "kwargs": kwargs,
        }
        return cls_or_fn
    return decorator

これいつ使うのかな?と思って色々調べて見たんですが、 dataclass のように振る舞うサードパーティーの実装で役に立つみたいです。
例としてよく上がっているのが Django models.Model クラスです。 models クラスでは default では型チェックを行うことができません。

from django.db import models


class Book(models.Model):
    title = models.CharField(max_length=50)
    price = models.IntegerField()

book = Book(title="Python実践レシピ", price="本体2700円+税") # Expected failed by type checker but passed

それを typing.dataclass_transform を使用して書き直してみると下記のようになります。

from typing import dataclass_transform, TypeVar

T = TypeVar("T")

@dataclass_transform()
def create_model(cls: type[T]) -> type[T]:
    ...
    return cls

@create_model
class User(models.Model):
    name: str
    age: int

User(name="test", age="test") # Fail type checker
User(name="test", age=100) # Pass type checker
🚨Pylance
型 "Literal['test']" の引数を、関数 "__init__" の型 "int" のパラメーター "age" に割り当てることはできません
  "Literal['test']" は "int" と互換性がありません

上記の例では Django独自の フィールドクラス(CharField, IntegerField) が使用できていません。[4]

今後、Django側で str -> CharField, int -> IntegerField といったように 型アノテーションに対応する独自フィールドクラスに
マッピングする実装を行なってくれれば dataclass_transform を用いた静的方チェックが簡単に行えそうです。[5]

get_overloads, clear_overloads

まずは overloads デコレーターについて簡単に復習します。
overloads は複数の組み合わせの引数を関数にサポートさせるために使用されます。
実際の挙動には影響を与えずに型チェックの為だけに使用されます。

from typing import overload

@overload
def process(response: None) -> None:
    ...
@overload
def process(response: int) -> tuple[int, str]:
    ...
@overload
def process(response: bytes) -> str:
    ...

# 実際に使用する関数を定義
def process(response):
    ...

# @overload で定義されて process 関数のどれかの引数定義にマッチするためタイプチェックをパスする。
# Pass type checker
process(response=None)
process(response=1)
process(response=b"test")

# Fail type checker
process(response="test")
process(response=1.11)

overload について少し学んだところで get_overloads 関数を呼んでみます。
すると上記で定義した @overload 関数の一覧が取得できます。

print(get_overloads(process))
# > [<function process at ...>, <function process at ...>, <function process at ...>]

今度は clear_overloadsget_overloads の前に呼び出します。
すると 登録していた overloads 関数がメモリから解放されます。

clear_overloads()
print(get_overloads(process))
# > []

第2部:主要ライブラリの違いと使い分け 📚

この章では、第一章で勉強した型ヒントを基にドメインモデルクラスを実装する際によく使用する主要ライブラリ dataclass, pydantic について整理していきます。

比較表

違いを簡単な表にまとめました。

比較項目 dataclass pydantic
標準ライブラリ ⚪︎ ✖︎
データ型バリデーションの有無
メソッドを定義すれば可能
⚪︎
Dict/JSON型のシリアライズ、デシリアライズの可否 ⚪︎ ⚪︎

基本的には バリデーションを行いたい場合は pydantic そうでない場合は dataclass を使用します。

各ライブラリの基本的仕様

比較表の内容を基にそれぞれのライブラリをみていきましょう

Dataclass

  • 標準ライブラリ
  • データバリデーションなし
from dataclasses import dataclass # 標準ライブラリです。


@dataclass
class User:
    name: str

user = User(name=1) # データバリデーションは実行されずにインスタンス化できてしまいます。
  • メソッドを定義すればバリデーション可能(dataclass以外でもできますが、、、)
from dataclasses import dataclass

@dataclass
class User:
    name: str

    # メソッドを定義することでバリデーションを行うことが可能となります。
    def __post_init__(self):
        if not isinstance(self.name, str):
            raise TypeError('Name should be of type str')

user = User(name=1)
# > Name should be of type str
  • Dict型のシリアライズ、デシリアライズ
from dataclasses import dataclass, asdict

@dataclass
class User:
    name: str

user_dict = {"name": "test"}

user = User(**user_dict) # dict -> dataclass
result = asdict(user) # dataclass -> dict
  • JSON型のシリアライズ、デシリアライズ
from dataclasses import dataclass, asdict
from dataclasses_json import dataclass_json # dataclasses-jsonという外部ライブラリを使用します。

@dataclass_json
@dataclass
class User:
    name: str

user = User(name="test")
user_json = user.to_json(indent=4, ensure_ascii=False) # dataclass -> json
user = User.from_json(user_json) # json -> dataclass

Pydantic

  • 外部ライブラリ
  • バリデーションあり
  • Dict/JSON型のシリアライズ、デシリアライズ
from pydantic import BaseModel # 外部ライブラリ


class Whatever(BaseModel):
    hoge: str

class User(BaseModel):
    name: str
    age: int
    whatever: Whatever

user = User(name=1) # バリデーションによりエラーが生じる

user = User.model_validate({"name": "test", "age": 20, "whatever": Whatever(hoge="hoge")}) # dict -> BaseModel

print(user.model_dump()) # BaseModel -> dict
#> {'name': 'test', 'age': 20, 'whatever': {'hoge': 'hoge'}}

# キーを一部だけ指定してdict化
print(user.model_dump(include={'name'}))
#> {'name':'test'}

# キーを一部だけ除外してdict化
print(user.model_dump(exclude={'age'}))
#> {'name': 'test', 'whatever': {'hoge': 'hoge'}}

print(dict(user)) # dict(model)でもdict変換は可能だが`for field_name, field_value in model:`を使用してイテレートしているだけなのでサブモデルはdictに変換はされない。
#> {'name':'test', 'age': 20, 'whatever': Whatever(hoge="hoge")}

user_json = user.model_dump_json() # BaseModel -> json
print(user_json)
#> {"name":"test","age":20,"whatever":{"hoge":"hoge"}}

user = user.model_validate_json(user_json) # json -> BaseModel

第3部:型ヒントの現場での応用 👩‍💻

実際に開発で使用する際に使えそうな Tips を紹介します。

Pydantic のシリアライズをカスタマイズ

Pydantic v2 からシリアライズをカスタマイズすることが可能となりました。
これまではPydantic, Dataclassを用いてドメインモデルを構築し、それをdict変換する際に
何かしらの処理を挟みたい場合は下記のように関数を作成してました。

from typing import Dict, Union
from datetime import datetime
from pydantic import BaseModel


class Response(BaseModel):
    score: float

    def to_dict(self) -> Dict[str, Union[float, datetime]]:
        return {
            "score": self.score,
            "date": datetime.now()
        }

response = Response(score=1.11)
dict_response = response.to_dict()

しかし、v2からは model_serialize デコレーターを使用することでmodel_dump, model_dump_jsonの挙動を制御できるようになりました。
記述量としては変わらないかも知れませんが、dict 化の処理を共通のinterfaceにて行えるようになることはメリットではないかと思います。

class Response(BaseModel):
    score: float

    @model_serializer
    def ser_model(self) -> Dict[str, Union[float, datetime]]:
        return {
            "score": self.score,
            "date": datetime.now()
        }

response = Response(score=1.11)
dict_response = response.model_dump_json()

TypedDict はキーの存在を保証しない

TypedDictではキーの存在が保証できないため、いざ使用しようとすると処理の前にキーが存在するかの条件分岐を入れないといけないケースが出てくる可能性があります。なので個人的には使いたくない。。。

from typing import TypedDict, Optional

class User(TypedDict):
    name: str
    age: Optional[int]

user = User(name="hoge")

# optional_keyは存在しない為、エラーとなる。
example["age"]

# そのため下記のように条件分岐をする必要がある。
if user.get("age"):
    print(user["age"])
else:
    print("user does not have a key: age")

TypedDict は dict のサブクラスではないので dict として扱えない

以下、公式ドキュメントより参照

First, any TypedDict type is consistent with Mapping[str, object].

from typing import Mapping, TypedDict, Any, Dict


class User(TypedDict):
    name: str
    age: int

user = User(name="hoge", age=1)

# Passed typing check
user_mapping: Mapping[str, Any] = user

# Failed typing check
user_dict: Dict[str, Any] = user
🚨Pylance
型 "User" の式を宣言された型 "Dict[str, Any]" に割り当てることはできません
  "User" は "Dict[str, Any]" と互換性がありません

dict と TypedDict は比較可能

下記のように dictTypedDict を比較することは可能です

class User(TypedDict):
    name: str
    age: Optional[int]

user = User(name="hoge", age=1)

d = {"name": "hoge", "age": 1}

print(user == d) # True

BaseModel のコンストラクタを用いた初期処理

BaseModel でコンストラクタを使用する際は親クラス(BaseModel) の __init__ を呼び出す必要があります。
ただし、初期値が付与されている場合はサブクラスによる __init__ で直接定義することが可能です、

from pydantic import BaseModel


class User1(BaseModel):
    name: str

    def __init__(self, name: str):
        self.name = name

User1(name="test") # Failed
# > AttributeError: 'User1' object has no attribute '__pydantic_fields_set__'


class User2(BaseModel):
    name: str

    def __init__(self, name: str) -> None:
        super().__init__(name = name)

User2(name="test") # OK


class User3(BaseModel):
    name: str = "hoge"

    def __init__(self, name: str) -> None:
        super().__init__()
        self.name = "name"

User3(name="test") # OK

dataclass -> BaseModelへの移行

pydantic.dataclass を用いると既存のdataclassから簡単に移行できます。
大きなプロジェクトなど BaseModel への切り替えが大変な場合は、まずは pydantic.dataclass に乗り換えるのも一つの手です。
ただし、公式ドキュメントにあるように、pydantic.BaseModel の完全な代替ではないため注意が必要です。

Keep in mind that pydantic.dataclasses.dataclass is not a replacement for pydantic.BaseModel. pydantic.dataclasses.dataclass provides a similar functionality to dataclasses.dataclass with the addition of Pydantic validation. There are cases where subclassing pydantic.BaseModel is the better choice.

from pydantic.dataclasses import dataclass # dataclass を pydantic.dataclasses から import するように変更

@dataclass
class User:
    name: str

BaseModel, Dataclass を Immutable なデータ型として扱う

frozen=True とすることで、Immutable なデータ型として扱うことができます。
イミュータブルな開発思想に則る場合は設定しましょう

from pydantic import BaseModel


class User(BaseModel, frozen=True):
    name: str

user = User(name="test")
user.name = "test2"
# > Instance is frozen [type=frozen_instance, input_value='test2', input_type=str]

BaseModel の Strict Model について

Pydantic の挙動に型に変換できない場合にエラーが生じるというものがあります。
どういうことかというと、
下記のように int 型が設定されている場合 str 型の値が挿入された際にエラーとなることが想定されますが、
実際には int 型に変換されてしまい、エラーも生じません。

from pydantic import BaseModel, ValidationError


class MyModel(BaseModel):
    x: int


print(MyModel.model_validate({'x': '123'}))  # Pass validation
# > x=123

しかし、この挙動は厳密な型を実装したい際には望ましくありません。
Pydanticでは strict model を使用することでこの挙動を変えることが可能です。

try:
    MyModel.model_validate({'x': '123'}, strict=True)  # strict mode
except ValidationError as e:
    ...
🚨Error
> 1 validation error for MyModel
> x
>   Input should be a valid integer [type=int_type, input_value='123', input_type=str]

公式ドキュメント

People have long complained about pydantic for coercing data instead of throwing an error. E.g. input to an int field could be 123 or the string "123" which would be converted to 123 While this is very useful in many scenarios (think: URL parameters, environment variables, user input), there are some situations where it's not desirable.
pydantic-core comes with "strict mode" built in. With this, only the exact data type is allowed, e.g. passing "123" to an int field would result in a validation error.
This will allow pydantic V2 to offer a strict switch which can be set on either a model or a field.

typing.List vs list, typing.Dict vs dict

python3.9から built-inのlist, dictをジェネリックタイプとして使えるようになりました。
import する必要がなくなるのは地味に嬉しい。

In type annotations you can now use built-in collection types such as list and dict as generic types instead of importing the corresponding capitalized types (e.g. List or Dict) from typing. Some other types in the standard library are also now generic, for example queue.Queue.

pd.DataFrameをバリデーションしたい

pandera を使用することで pd.DataFrame の型バリデーションを行うことが可能です。
pandera の基本的な仕様は以下です。

import pandas as pd
import pandera as pa # 外部モジュール

# data to validate
df = pd.DataFrame({
    "column1": [1, 4, 0, 10, 9],
    "column2": [-1.3, -1.4, -2.9, -10.1, -20.4],
    "column3": ["value_1", "value_2", "value_3", "value_2", "value_1"],
})

# define schema
schema = pa.DataFrameSchema({
    "column1": pa.Column(int, checks=pa.Check.le(10)),
    "column2": pa.Column(float, checks=pa.Check.lt(-1.2)),
    "column3": pa.Column(str, checks=[
        pa.Check.str_startswith("value_"),
        # define custom checks as functions that take a series as input and
        # outputs a boolean or boolean Series
        pa.Check(lambda s: s.str.split("_", expand=True).shape[1] == 2)
    ]),
})

validated_df = schema(df) # Pass validations

試しに下記のように column1 の値を変えて実行してみるとエラーになり、バリデーションが働くことを確認できます。

df = pd.DataFrame({
    "column1": [1, 4, 0, 100, 9],
    "column2": [-1.3, -1.4, -2.9, -10.1, -20.4],
    "column3": ["value_1", "value_2", "value_3", "value_2", "value_1"],
})

validated_df = schema(df) # Failed validations
🚨error message
pandera.errors.SchemaError: <Schema Column(name=column1, type=DataType(int64))> failed element-wise validator 0:
<Check less_than_or_equal_to: less_than_or_equal_to(10)>
failure cases:
   index  failure_case
0      3            15

panderapydantic と併用して使用することが可能です。

from pydantic import BaseModel
import pandas as pd
import pandera as pa
from pandera.typing import DataFrame, Series


class DataSchema(pa.DataFrameModel):
    str_col: Series[str] = pa.Field(unique=True)


class Data(BaseModel):
    x: int
    df: DataFrame[DataSchema]


valid_df = pd.DataFrame({"str_col": ["hello", "world"]})
Data(x=1, df=valid_df)

invalid_df = pd.DataFrame({"str_col": ["hello", "hello"]})
Data(x=1, df=invalid_df)
🚨error message
pydantic_core._pydantic_core.ValidationError: 1 validation error for Data
df
  Value error, series 'str_col' contains duplicate values:
0    hello
1    hello
Name: str_col, dtype: object [type=value_error, input_value=  str_col
0   hello
1   hello, input_type=DataFrame]

第4部:型チェックによる速度比較 ⌛️

python の型ヒントとドメインモデルクラスに頻繁に使用されるライブラリについて見てきました。

ドメインモデルクラスのライブラリに関しては
pydantic一択なのでは!?
と個人的には思っていますがバリデーションによる負荷も気になるところです。

そこで、この章では各ライブラリの処理速度を簡単に比較していきたいと思います。

検証方法

ドメインクラスへのインスタンス化処理を100万回 loop で処理します。
下記は pydantic v2 を想定したのコードです。
同様にして pydantic_v1 , Dataclass でも同様の実験を行います。

実行環境

  • M1 Mac mini 16GB
  • python 3.11.5
  • pydantic v1(1.9.1) or pydantic v2(2.5.1)
pydantic_v2.py
import time
import random
from datetime import datetime
from pydantic import BaseModel

random.seed(314) # seed値を固定


class Prediction(BaseModel):
    score: float
    version: str
    predicted_at: datetime

start = time.time()

for _ in range(1000000):
    prediction_result = Prediction.model_validate(
        {
            "score": random.random(),
            "version": "v1",
            "predicted_at": datetime.now(),
        }
    )

end = time.time()

time_diff = end - start
pydantic_v1
pydantic_v1.py
class Prediction(BaseModel):
    score: float
    version: str
    predicted_at: datetime

start = time.time()

for _ in range(1000000):
    prediction_result = Prediction.parse_obj(
        {
            "score": random.random(),
            "version": "v1",
            "predicted_at": datetime.now(),
        }
    )

end = time.time()

time_diff = end - start
print(time_diff)
dataclass
dataclass.py
@dataclass
class Prediction:
    score: float
    version: str
    predicted_at: datetime


start = time.time()

for _ in range(1000000):
    prediction_result = Prediction(
        **{
            "score": random.random(),
            "version": "v1",
            "predicted_at": datetime.now(),
        }
    )

end = time.time()

time_diff = end - start

追加で下記のようなネストが深いケースでも同様の検証を行いました。

class Whatever(BaseModel):
    hoge: str
    hoge2: str
    hoge3: str


class User(BaseModel):
    name: str
    age: int
    whatever: Whatever


class Prediction(BaseModel):
    score: float
    version: str
    predicted_at: datetime
    user: User

start = time.time()

for _ in range(1000000):
    prediction_result = Prediction.model_validate(
        {
            "score": random.random(),
            "version": "v1",
            "predicted_at": datetime.now(),
            "user": {
                "name": "test",
                "age": random.randint(0,150),
                "whatever": {
                    "hoge": "hoge",
                    "hoge2": "hoge2",
                    "hoge3": "hoge3"
                },
            }
        }
    )

end = time.time()

time_diff = end - start

結果は下記の表のようになりました。[6]

ライブラリ ネストなし処理時間(s) ネストあり処理時間(s)
pydantic v1(1.9.1) 2.5587868690490723 8.116864919662476
pydantic v2(2.5.1) 1.0503969192504883 2.5027918815612793
dataclass 0.5260927677154541 -

以下、実験結果の考察です。

  • pydantic v2 では pydantic v1 に比べて処理時間が半分以上短縮
  • ネストが深くなると pydantic v2/v1 間の処理速度の差がより大きくなる
  • dataclass はバリデーションを行っていない為最速

🌟 もっとも注目すべき箇所は、pydantic v2 による高速化によって pydanticdataclass の処理速度の差にそこまで大きな開きがなくなっているところです。

レイテンシーやスループットの要件が厳しいケースを除き、pydantic を使用して良さそうに感じました。

まとめ

python3.8以降の型ヒントの進化について学んできました。
型のバリエーションが増えたことによって、より堅牢な実装が可能になっていると感じます。

また、pydantic, dataclassといったドメインモデルクラスを定義する代表的なライブラリについても比較してきました。pydantic v2 へと進化を経たことでpythonのドメインクラスでは pydantic をとりあえず使っておけば間違いないといった感じになりそうです。

参考文献

https://docs.pydantic.dev/latest/

https://pandera.readthedocs.io/en/stable/

https://docs.python.org/ja/3/library/typing.html

脚注
  1. duck-typing とはあるオブジェクトが正しいインターフェースを持っているかを決定するのにオブジェクトの型を見ないプログラミングスタイルです。
    代わりに、単純にオブジェクトのメソッドや属性が呼ばれたり使われたりします。(「アヒルのように見えて、アヒルのように鳴けば、それはアヒルである。」)インターフェースを型より重視することで、上手くデザインされたコードは、ポリモーフィックな代替を許して柔軟性を向上させます。 ↩︎

  2. NotRequired はバージョン 3.11 で追加されてます ↩︎

  3. Required はバージョン 3.11 で追加されてます ↩︎

  4. 独自フィールドを使用すると型チェックが行われません ↩︎

  5. Django そんなに詳しくないのでこの辺りの理解間違ってたらすみません🙏 ↩︎

  6. 専用テスト環境作成や、繰り返し実行など、他のアプリケーションなどによる影響を最小限に抑えるような厳密な調査は行ってません。参考の数値としてご覧ください。 ↩︎

Discussion