Python 3.8以降の型ヒント革命:DataclassとPydanticの徹底比較
はじめに 📘
この記事は ラクスパートナーズ 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
型 "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
"MAX_SIZE" は定数であり (大文字であるため)、再定義できません
親クラス "Connection" が Final として宣言しているため、"TIMEOUT" を再宣言できません
Protocol
主に duck-typing
[1] を認識する静的型チェッカーとして使用します。
ダックタイピングは type()
や isinstance()
による判定を避けます。その代わり hasattr()
判定や EAFP プログラミングを利用します。
例えば、下記のように Dog2
は Duck
と同じ 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
型 "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
型 "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
...
メソッド "done" は、クラス "Base" で定義されている最終的なメソッドをオーバーライドできません
基底クラス "Leaf" は final とマークされており、サブクラス化できません
Python3.9 で追加された型
Annotated
簡単に言うと、型に追加情報を記載することが可能となります。
また追加された情報は型そのものには影響を与えません。
例えば、
下記のコードはBMIを計算するための関数ですが weight
や hight
の単位がわかりません.
単位の情報に関してはコメントにも残せますが,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!")
型 "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
型 "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
を使わなくてもいけるでしょ?
と思う方もいると思いますが、その場合サブクラスにおいて問題が生じます。
下記のコードの場合、 SubclassOfFoo
の return_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
型 "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 は実行される。
"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
型 "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_overloads
を get_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
型 "User" の式を宣言された型 "Dict[str, Any]" に割り当てることはできません
"User" は "Dict[str, Any]" と互換性がありません
dict と TypedDict は比較可能
下記のように dict
と TypedDict
を比較することは可能です
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:
...
> 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
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
pandera
は pydantic
と併用して使用することが可能です。
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)
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)
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
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
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
による高速化によって pydantic
と dataclass
の処理速度の差にそこまで大きな開きがなくなっているところです。
レイテンシーやスループットの要件が厳しいケースを除き、pydantic
を使用して良さそうに感じました。
まとめ
python3.8以降の型ヒントの進化について学んできました。
型のバリエーションが増えたことによって、より堅牢な実装が可能になっていると感じます。
また、pydantic
, dataclass
といったドメインモデルクラスを定義する代表的なライブラリについても比較してきました。pydantic v2
へと進化を経たことでpythonのドメインクラスでは pydantic
をとりあえず使っておけば間違いないといった感じになりそうです。
参考文献
-
duck-typing とはあるオブジェクトが正しいインターフェースを持っているかを決定するのにオブジェクトの型を見ないプログラミングスタイルです。
代わりに、単純にオブジェクトのメソッドや属性が呼ばれたり使われたりします。(「アヒルのように見えて、アヒルのように鳴けば、それはアヒルである。」)インターフェースを型より重視することで、上手くデザインされたコードは、ポリモーフィックな代替を許して柔軟性を向上させます。 ↩︎ -
NotRequired
はバージョン 3.11 で追加されてます ↩︎ -
Required
はバージョン 3.11 で追加されてます ↩︎ -
独自フィールドを使用すると型チェックが行われません ↩︎
-
Django
そんなに詳しくないのでこの辺りの理解間違ってたらすみません🙏 ↩︎ -
専用テスト環境作成や、繰り返し実行など、他のアプリケーションなどによる影響を最小限に抑えるような厳密な調査は行ってません。参考の数値としてご覧ください。 ↩︎
Discussion