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

# はじめに

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

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


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

### 普通の書き方
 
```python

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

### dataclassでの書き方

```python

class User:
    user_name: str
    user_id: int
```

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

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

今回の本題です。

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

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

### コード
```python

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}')
```

### 実行結果
```powershell

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

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

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

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

```python

@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

```
### 実行結果
```powershell

> 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_name`と`user_id`の２つでしたが、たくさんあると大変です。
そのため、`__post_init__`で書いた型チェックを変数の数だけますようにしたいですね。

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

```python

@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行程度で書けます

### 実行結果
```powershell
> 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]`で引数の型を指定したもの。

```python

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のところでエラーが出ています。

```powershell
> 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型ではありません。

```python
>>> from typing import List 
>>> print(type(List[dir]))  
<class 'typing._GenericAlias'>
```
型チェック時に変換する必要がありそうですね。

## おわりに

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

## 参考
 - Python Documentation contents dataclasses --- データクラス
   - https://docs.python.org/ja/3/library/dataclasses.html