LoginSignup
7
5

More than 3 years have passed since last update.

【python】dataclassでお手軽引数型チェック

Posted at

はじめに

  • DDDでコード書く練習していたときに、ValueObjectの型チェック方法を試行錯誤したのでその時のメモ
  • Qiitaの記事練習として(初投稿)。

結論

  • __post_init__()で初期化時の処理がかけるので、ここで型チェックを実行
  • isinstanceで型チェック
    • self.__annotations__から期待値の型を取得する
    • dataclasses.asdict(self)でインスタンスをdictに変換。これをisinstanceにかける。

dataclassとは?

  • init()を自動生成してくれる。
    • __init__()に引数を入れて、self.hoge=arg_hogeとかする必要ない。
  • ValueObjectを生成するのに適している。

普通の書き方


class Users:
    def __init__(self, user_name: str, user_id: int):
        self.user_name = user_name
        self.user_id = user_id

dataclassでの書き方


class User:
    user_name: str
    user_id: int

dataclassで書いたほうがきれいに書けますね!

dataclassでは型チェックしてくれない?

今回の本題です。

user_name: struser_id: intで型指定していて、型チェックしているように見えますが、実際は普通のアノテーションです。

str型で指定しているのに、int型で入れられてしまいます。

コード


import dataclasses

@dataclasses.dataclass(frozen=True)
class User:
    user_name: str
    user_id: int

c = User(user_name='ヒノヤコマ', user_id=1)
print(f'{c.user_id}:{c.user_name}')

c_fail = User(user_name=2, user_id='ファイアロー')
print(f'{c_fail.user_id}:{c_fail.user_name}')

実行結果


> python .\dataclassテスト.py
1:ヒノヤコマ
ファイアロー:2

上記のようになんでも入れられてしまいます。

__post_init__()で初期化時に型チェックを実行

下記のようにdataclass内で、__post_init__()関数を定義することで、初期化時の処理を書くことができます。
ここにisinstanceを使用して型チェックしましょう


@dataclasses.dataclass(frozen=True)
class User:
    user_name: str
    user_id: int

    def __post_init__(self):
        if not isinstance(self.user_name, str):
            raise Exception
        if not isinstance(self.user_id, int):
            raise Exception

実行結果


> python .\dataclassテスト.py
1:ヒノヤコマ
Traceback (most recent call last):
  File ".\dataclassテスト.py", line 17, in <module>
    c_fail = User(user_name=2, user_id='ファイアロー')
  File "<string>", line 4, in __init__
  File ".\dataclassテスト.py", line 10, in __post_init__
    raise Exception
Exception

期待通り、例外を出すことができました!

変数が多いと型チェックが大変

いままでの例では、user_nameuser_idの2つでしたが、たくさんあると大変です。
そのため、__post_init__で書いた型チェックを変数の数だけますようにしたいですね。

というわけで書いたのが下記になります。


@dataclasses.dataclass(frozen=True)
class User:
    user_name: str
    user_id: int

    def __post_init__(self):
        # 1. asdictでUserインスタンスをdict型に変換
        user_dict = dataclasses.asdict(self)
        # 2. self.__annotations__から期待値の型を取得
        #    self.__annotations__には引数の名前とその指定する型がdictで入ってます。
        #    これから期待値の型を取得して、isinstanceで型チェックします。
        for user_arg_name, user_arg_expected_type in self.__annotations__.items():
            # 3. isinstance実行
            #    dict型に変換したUserから、アノテーションのKeyで指定して対象の変数を取り出して実行します。
            if not isinstance(user_dict[user_arg_name], user_arg_expected_type):
                print(f'{user_arg_name} is not ok')
                raise Exception
            else:
                print(f'{user_arg_name} is ok')

細かいことはコメントで入れました。
asdictでインスタンスが持つすべての引数(変数)を取得し、self.__annotations__で期待値の型を取得して、isinstanceをかけます。
コメントアウトやprintを除くと、4、5行程度で書けます

実行結果

> python .\dataclassテスト.py
user_name is ok
user_id is ok
1:ヒノヤコマ
user_name is not ok
Traceback (most recent call last):
  File ".\dataclassテスト.py", line 21, in <module>
    c_fail = User(user_name=2, user_id='ファイアロー')
  File "<string>", line 4, in __init__
  File ".\dataclassテスト.py", line 13, in __post_init__
    raise Exception
Exception

このコードの弱点

typingなどで型を指定すると、この方法だとうまくいきません。
下記はコメントを抜いて、List[int]で引数の型を指定したもの。


import dataclasses
from typing import List 

@dataclasses.dataclass(frozen=True)
class User:
    user_name: str
    user_id: int
    status_list: List[int]

    def __post_init__(self):
        user_dict = dataclasses.asdict(self)
        for user_arg_name, user_arg_expected_type in self.__annotations__.items():
            if not isinstance(user_dict[user_arg_name], user_arg_expected_type):
                print(f'{user_arg_name} is not ok')
                raise Exception
            else:
                print(f'{user_arg_name} is ok')

status_list=[50,51]

c = User(user_name='ヒノヤコマ', user_id=1, status_list=status_list)
print(f'{c.user_id}:{c.user_name}')

c_fail = User(user_name=2, user_id='ファイアロー', status_list=status_list)
print(f'{c_fail.user_id}:{c_fail.user_name}')

以下のようにlistのところでエラーが出ています。

> python .\dataclassテスト.py
user_name is ok
user_id is ok
Traceback (most recent call last):
  File ".\dataclassテスト.py", line 27, in <module>
    c = User(user_name='ヒノヤコマ', user_id=1, status_list=status_list)
  File "<string>", line 5, in __init__
  File ".\dataclassテスト.py", line 19, in __post_init__
    if not isinstance(user_dict[user_arg_name], user_arg_expected_type):
  File "C:\Users\proje\AppData\Local\Programs\Python\Python37\lib\typing.py", line 708, in __instancecheck__
    return self.__subclasscheck__(type(obj))
  File "C:\Users\proje\AppData\Local\Programs\Python\Python37\lib\typing.py", line 716, in __subclasscheck__
    raise TypeError("Subscripted generics cannot be used with"
TypeError: Subscripted generics cannot be used with class and instance checks

なぜだめなのか

以下を実行するとわかりますが、List[int]は型を指定するための型なので、実際のlist型ではありません。

>>> from typing import List 
>>> print(type(List[dir]))  
<class 'typing._GenericAlias'>

型チェック時に変換する必要がありそうですね。

おわりに

__post_init__を使用して型チェックするのは問題なさそうです。
ですが、型を指定する際にtypingを使用していると、for文で回してチェックするのはもう一工夫必要そうです。
mypyなどでコード内にいれるのではなく、外から確認するのもありかもしれません。
(push時に自動確認するなど、環境がしっかりしていればこちらもありですね)

参考

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5