Python開発: dataclassesを活用してデータを扱うコードを簡潔に書こう！

# はじめに
Python 3.7以降で登場した`dataclasses`モジュールは、シンプルなデータ構造を扱う際に非常に強力なツールです。
この記事では`dataclasses`モジュールを活用することでPythonコードをより読みやすく、保守しやすいものにするためのデータクラスの使い方や活用のメリットなどを解説します。

:::note info
`dataclasses`はPython 3.7以降標準ライブラリで使用できるため、追加のインストールは不要です。
:::

# `dataclasses.dataclass`を活用するメリット

## 1. dataclassでデータを扱うことでコードの簡潔さと可読性の向上

`dataclass`のデコレーターを付けたclassはデフォルトで`__init__()`、`__repr__() `、`__eq__() `メソッドを定義してくれます。
そのため簡潔にオブジェクトの型を定義でき、printするときやアサーションするときなどに便利です。
以下はdataclassを使用しない場合と使用した場合を比べてみました。

### dataclassを使用しない場合
オブジェクトの内容を出力したり、同じ内容のオブジェクトをassertする場合は別途`__init__()`や`__repr__()`、`__eq__()`メソッドを定義する必要があります。
```python
class Person:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

p1 = Person(name='佐藤', age=30)
p2 = Person(name='佐藤', age=30) 
print(p1)  # => <__main__.Person object at 0x1068d0f10>のようにメモリアドレスが表示される
assert p1 == p2 # => 違うオブジェクトとしてAssertionErrorが発生する
```

### dataclassを使用した場合
`__init__()`が不要になり、`__repr__()`と`__eq__()`が定義されていることで、オブジェクトの内容が綺麗にprintされ、アサーションで同じ内容であればTrueになります。
```python
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

p1 = Person(name='佐藤', age=30)
p2 = Person(name='佐藤', age=30) 
print(p1)  # => Person(name='佐藤', age=30)
assert p1 == p2  # OK
```

## 2. dictやtupleへの変換が容易
標準のclassオブジェクトをdictやtupleに変換しようとすると上手く変換できなかったりして別途実装が必要になりますが、`dataclasses`モジュールでは`asdict`というdictに変換する便利な関数を用意してくれています。
tupleへの変換などは記事内の[asdictやastupleのユーティリティ関数の活用](#asdictやastupleのユーティリティ関数の活用)で説明を記載しています。
以下はdataclassを使用しない場合と使用した場合を比べてみました。

### dataclassを使用しない場合
ネストしていると綺麗にdictへ変換されない。
```python
class Person:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

class Article:
    def __init__(self, person: Person, title: str, category: str):
        self.person = person
        self.title = title
        self.category = category

person = Person(name='佐藤', age=30)
article = Article(person=person, title="サンプルのタイトル", category="Python")
print(article.__dict__)
# 出力結果
# {'person': <__main__.Person object at 0x1069c43d0>, 'title': 'サンプルのタイトル', 'category': 'Python'}
```

### dataclassを使用した場合
`asdict`を使うことで綺麗にdictへ変換してくれます。
```python
from dataclasses import asdict, dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Article:
    person: Person
    title: str
    category: str

person = Person(name='佐藤', age=30)
article = Article(person=person, title="サンプルのタイトル", category="Python")
print(asdict(article))
# 出力結果
# {'person': {'name': '佐藤', 'age': 30}, 'title': 'サンプルのタイトル', 'category': 'Python'}
```

## 3. データの属性をIDEが補足してくれるようになる

### dataclassを使用せずにdictの型情報だけの場合
dictの型情報だけではIDE側もkeyとなる属性情報をサジェストしてくれません。
![dataclassを使わない場合.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314085/1ed51ee5-4a1b-6539-7eea-0cbc6535b0bd.png)

### dataclassの型情報を活用する場合
`.`アクセスが可能になり、IDEが型情報とともに属性をサジェストしてくれます。
![dataclassを使った場合.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3314085/7e555246-815b-8476-f5ad-483542dee7ff.png)

# `dataclass`の使い方
`dataclass`はdataclassesからimportすることで使用できます。
以下は記事(Article)をdataclassで表現した場合の実装です。
```python
from dataclasses import dataclass

@dataclass
class Article:
    id: int
    title: str
    category: str
```
インスタンス化する方法は以下です。
```python
# 引数を指定してインスタンス化する場合
article1 = Article(id=1, title="サンプル1のタイトルです", category="Python")
print(article1)

# 引数を指定せずインスタンス化する場合
article2 = Article(2, "サンプル2のタイトルです", "Rust")
print(article2)

# dictからインスタンス化する場合
article3 = Article(**{"id": 3, "title": "サンプル3のタイトルです", "category": "Ruby"})
print(article3)

# tupleからインスタンス化する場合
article4 = Article(*(4, "サンプル4のタイトルです", "PHP"))
print(article4)

# 出力結果
# Article(id=1, title='サンプル1のタイトルです', category='Python')
# Article(id=2, title='サンプル2のタイトルです', category='Rust')
# Article(id=3, title='サンプル3のタイトルです', category='Ruby')
# Article(id=4, title='サンプル4のタイトルです', category='PHP')
```

インスタンス化したデータクラスの属性を使用する場合は`.フィールド名`で使用できます。
```python
article = Article(id=1, title="サンプル1のタイトルです", category="Python")

print(article.id)
print(article.title)
print(article.category)

# 出力結果
# 1
# サンプル1のタイトルです
# Python
```

:::note warn
dataclassの注意点
`dataclass`は型安全性を保証するものではなく、型アノテーションはあくまでヒントに過ぎません。
そのため定義した型とは違うデータでデータクラスを初期化した場合もエラーなく実行されてしまいます。

型安全性を保証する場合は、別途型チェックの自作する、もしくは`pydantic`のような厳密な型チェックを行えるサードパーティのライブラリを使用するなどが必要になります。
[型チェックを自作する](#__post_init__を活用してタイプチェックのバリデーション処理を追加)方法もこの記事内で紹介しているので参考にしてください。

```python
from dataclasses import dataclass

@dataclass
class Article:
    id: int
    title: str
    category: str

article = Article(id=None, title=200, category=None)
print(article)
# 出力結果(エラーなく実行される)
# Article(id=None, title=200, category=None)
```
:::

## 継承
共通の属性や処理を継承でき、コードの再利用性を高めるこができます。

```python
from typing import Any

@dataclass
class Base:
    x: Any
    y: int

@dataclass
class C(Base):
    x: int  # 基底クラスのxをオーバーライド
    z: int

c = C(x=1, y=2, z=3)
print(c)
# 出力結果
# C(x=1, y=2, z=3)
```

## `__post_init__`でインスタンス化後に処理を追加
`__post_init__`は、Pythonのdataclassesモジュールで定義された特別なメソッドで、データクラスのインスタンス生成後に追加の初期化処理を行うために使用されます。
`__post_init__`を活用することでバリデーション処理を追加したり、フィールドの値を動的に計算する処理を作るなどができます。

以下はバリデーション処理を追加したケースです。
```python
@dataclass
class Product:
    name: str
    price: int
    discount_rate: float  # 割引率

    def __post_init__(self):
        # 割引率が0〜1の範囲外ならエラーを発生させる
        if not (0 <= self.discount_rate <= 1):
            raise ValueError(f"割引率が無効な数値です: {self.discount_rate}")

# 想定外なインスタンス生成
product = Product(name="スマホケース", price=1000, discount_rate=1.5)
# エラーが出力される
# ValueError: 割引率が無効な数値です: 1.5
```

## `__post_init__`を活用してタイプチェックのバリデーション処理を追加
基礎クラスを作成し`__post_init__`内にタイプチェックをする処理を書くことで、そのクラスを継承したデータクラスでタイプチェックをしてくれるdataclassを作成することも可能です。

```python
from dataclasses import dataclass, fields

@dataclass
class Base:
    def __post_init__(self) -> None:
        self.validate_fields()

    def validate_fields(self):
        for field in fields(self):
            value = getattr(self, field.name)
            # タイプチェック
            field_type = field.type
            if not isinstance(value, field_type):
                raise TypeError(f"{self.__class__.__name__}インスタンス化処理: {field.name}が設定していタイプと一致しませんでした。: {value=}")

@dataclass
class User(Base):
    id: int
    name: str
    age: int

user = User(id="543af591-bb5b-2970-e5be-095abbd33e85", name="佐藤", age=24)
# タイプのバリデーションでエラーが出力される
# TypeError: Userインスタンス化処理: idが設定していタイプと一致しませんでした。: value='543af591-bb5b-2970-e5be-095abbd33e85'
```

## デフォルト値の設定
デフォルト値の設定もできます。
注意することは、リストなどの可変オブジェクトにはdefault_factoryを使用する必要があります。
```python
from dataclasses import dataclass, field

@dataclass
class Product:
    name: str
    price: float
    in_stock: bool = True  # デフォルト値の設定
    tags: list = field(default_factory=list)  # 可変オブジェクトにはdefault_factoryにて設定

product = Product(name="Mouse", price=1200.0)
print(product)

# 出力結果
# Product(name='Mouse', price=1200.0, in_stock=True, tags=[])
```

## ネストしたdataclassの活用
dataclassは、別のdataclassをフィールドとして持つことも可能です。
例えば、顧客情報と注文情報をネストして持つようなデータ構造を作成できます。
```python
from dataclasses import dataclass

@dataclass
class Customer:
    id: int
    name: str

@dataclass
class Order:
    order_id: int
    customer: Customer  # 他のdataclassをネスト
    amount: float

customer = Customer(id=1, name="Yamada")
order = Order(order_id=101, customer=customer, amount=250.0)
print(order)
# 出力結果
# Order(order_id=101, customer=Customer(id=1, name='Yamada'), amount=250.0)
```
このようにネストしたdataclassを利用することで、複雑なデータ構造を持つクラスもシンプルに記述できます。
これにより、関連するデータをまとめて扱いやすくなり、コードの可読性が向上します。

## データの不変性を保つ (frozen)
dataclassの`frozen=True`オプションを使用すると、インスタンスが不変（イミュータブル）になり、インスタンス生成後にフィールドの値を変更できなくなります。
これにより、設定値や定数を保持するデータクラスに適しています。
```python
from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    host: str
    port: int

config = Config(host="localhost", port=8080)
# config.port = 9090  # 変更しようとするとFrozenInstanceErrorが発生する
```
また、`frozen=True`を使用すると`集合操作(和集合や積集合など)`を行ったりすることもできます。便利ですね！

```python
from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    id: int
    name: str
    email: str

a_group_users = {
    User(id=1, name="Yamada", email="yamada@example.com"),
    User(id=2, name="Tanaka", email="tanaka@example.com"),
    User(id=3, name="Asai", email="Asai@example.com"),
}

b_group_users = {
    User(id=1, name="Yamada", email="yamada@example.com"),  # 重複データ
    User(id=4, name="Morita", email="morita@example.com"),
}

# 和集合で重複データが省かれている!
print(a_group_users | b_group_users)
# 出力結果
# {
#     User(id=1, name='Yamada', email='yamada@example.com'),
#     User(id=2, name='Tanaka', email='tanaka@example.com'),
#     User(id=3, name='Asai', email='Asai@example.com'),
#     User(id=4, name='Morita', email='morita@example.com')
# }

# 積集合で重複したデータを取得できる！
print(a_group_users & b_group_users)
# 出力結果
# {
#    User(id=1, name='Yamada', email='yamada@example.com')}
# }
```

# asdictやastupleのユーティリティ関数の活用
dataclassでインスタンスしたものをdictやtupleなどに変換したい時がよくあります。
そんな時は`asdict`, `astuple`で簡単に実現できます。

## `asdict`によるdataclassインスタンスから辞書(dict)への変換
dataclassesモジュールの`asdict`関数を使うと、dataclassのインスタンスを辞書(dict)に変換できます。
これにより、各フィールドの名前と値がキーとバリューのペアとして辞書に格納されます。

```python
from dataclasses import dataclass, asdict

@dataclass
class User:
    id: int
    name: str
    email: str

user = User(id=1, name="Yamada", email="yamada@example.com")

# dataclassインスタンスを辞書に変換
user_dict = asdict(user)
print(user_dict)
# 出力結果 => {'id': 1, 'name': 'Yamada', 'email': 'yamada@example.com'}
```

## `astuple`によるdataclassインスタンスからタプル(tuple)への変換
dataclassesモジュールの`astuple`関数を使うと、dataclassのインスタンスをタプル（tuple）に変換できます。

```python
from dataclasses import dataclass, astuple

@dataclass
class User:
    id: int
    name: str
    email: str

user = User(id=1, name="Yamada", email="yamada@example.com")

# dataclassインスタンスを辞書に変換
user_dict = astuple(user)
print(user_dict)

# 出力結果 => (1, 'Yamada', 'yamada@example.com')
```

## 参考

https://docs.python.org/ja/3/library/dataclasses.html#dataclasses.replace

## まとめ
今回はdataclassesのモジュールについて説明する記事を書いてみました。
dataclass便利なのでぜひ使ってみてください！