Python3.7以上のデータ格納はdataclassを活用しよう

## はじめに

Pythonでデータを格納する際に辞書や普通のクラスを使っていませんか？Python3.7からはデータ格納に便利なdataclassデコレータが用意されています。

この記事では[公式ドキュメント](https://docs.python.org/ja/3/library/dataclasses.html)や[PEP557](https://www.python.org/dev/peps/pep-0557/)の説明ではいまいち掴めない、どういった時に便利で、なぜ使うべきなのかという点に触れつつ、使い方を説明していきます。

なお、以前のバージョンではPython3.6に限り`pip install dataclasses`によって使えるようになります。執筆時点ではGoogle Colaboratoryの環境がPython3.6.9ですが、デフォルトでdataclassesがインストールされています。

### 想定読者

- dataclassの存在を知ったが何なのかよく分からない人
- 可読性高くデータを扱いたい人
- 「前はこんな機能なかったし、自分は別に使わなくて良いよ・・・」と思っている人

## よく見かける最低限の説明

↓これが

```py
class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name

person1 = Person(0, 'Alice')
print(person1.number) # 0
print(person1.name) # Alice
```

↓こう書けます。（区別のためクラス名を明示的に変更しています）

```py
import dataclasses
@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.number) # 0
print(dataclass_person1.name) # Alice
```

デコレータ`@dataclasses.dataclass`を付けて、`__init__()`の代わりに定義したい変数名を型アノテーション付きで書くことで使えます。

### `__init__()`が自動的に作られたり、型アノテーションが必須になったりしている

何が変わったのかというと、まず`__init__()`で**引数をわざわざインスタンス変数に代入する必要がなくなりました。**`__init__()`を自動的に作ってくれているということです。**変数が多い時には面倒じゃなくなるし、すっきりして嬉しいです。**また、後述しますが`__eq__()`や`__repr__()`といった他の特殊メソッドも自動的に作られています。

そして、型アノテーションが必須になっているので、型が分かって嬉しいです。（ただし、これは通常のクラスでも`def __init__(self, number: int, name: str='XXX')`としておきたいところ）

**このクラスはデータを格納するために存在しているんだぞと明示できる**というのも可読性の観点では重要な要素です。

### 辞書は避けたい

上記の例をやるだけであれば、辞書を使えばできます。なんでわざわざクラス、ましてdataclassデコレータなんて使うのでしょう。入出力はとりあえず辞書にしているという人は多いかと思われます。

```py
dict_person1 = {'number': 0, 'name': 'Alice'}
print(dict_person1['number']) # 0
print(dict_person1['name']) # Alice
```

辞書の分かりやすいデメリットとしては、こんなところでしょうか。

1. ドットアクセスができない。（ただし、できなくても別に良いかもしれない）
1. 格納時の処理といったメソッドは入れられない。
1. 型アノテーションができない。
1. 決まった形になっていることがコードから掴みにくい。

後から読みやすい、メンテナンスしやすいコードを目指す上では3と4は大切なため、メソッドが不要な場合でも辞書を避ける理由となります。ただし、これらは通常のクラスでもカバーできます。

## dataclassのメリット

dataclassデコレータを使ったクラスが通常のクラスよりどう優れているかを深堀していきます。

### メリット：`__eq__()`が自動的に作られunittestもしやすい

インスタンスを比較した時、通常のクラスでは中身が同じでも異なるインスタンスは`False`となります。`id()`が返す値を比較しているためですが、これはあまり役立ちません。**unittestをするようなことを考えると、要素が一致している時はTrueになって欲しいです。**

↓通常のクラスで何もしないとこうなります。

```py
class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name

person1 = Person(0, 'Alice')

print(person1 == Person(0, 'Alice')) # False
print(person1 == Person(1, 'Bob')) # False
```

↓通常のクラスで要素で比較するためには、`__eq__()`を自分で定義することになります。

```py
class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name
        
    def __eq__(self, other):
        if not isinstance(other, Person):
            return NotImplemented
        return self.number == other.number and self.name == other.name

person1 = Person(0, 'Alice')

print(person1 == Person(0, 'Alice')) # True
print(person1 == Person(1, 'Bob')) # False
```

↓dataclassデコレータを使えば、この`__eq__()`は自動的に作られます。手間が減りますし、見た目もすっきりします。

```py
@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')

print(dataclass_person1 == DataclassPerson(0, 'Alice')) # True
print(dataclass_person1 == DataclassPerson(1, 'Bob')) # False
```

また、`@dataclasses.dataclass(order=True)`とすれば、大小比較の演算のための`__lt__()`、`__le__()`、`__gt__()`、`__ge__()`も作られます。これらはタプルを比較した時と同様に、最初に異なる要素同士で比較する仕様です。やや分かりづらいため、必要な場合は自分で定義した方が良いかもしれません。

### メリット：asdictを使うとネストしていても綺麗に辞書に変換できる

JSONとして出力したいと時など、辞書に変換したい時には`dataclasses.asdict()`を使います。dataclassをネストしていても問題ありません。

```py
@dataclasses.dataclass
class DataclassScore:
    writing: int
    reading: int
    listening: int
    speaking: int
        
@dataclasses.dataclass
class DataclassPerson:
    score: DataclassScore
    number: int
    name: str = 'Alice'
        
dataclass_person1 = DataclassPerson(DataclassScore(25, 40, 30, 35), 0, 'Alice')
dict_person1 = dataclasses.asdict(dataclass_person1)
print(dict_person1) # {'score': {'writing': 25, 'reading': 40, 'listening': 30, 'speaking': 35}, 'number': 0, 'name': 'Alice'}

import json
print(json.dumps(dict_person1)) # '{"score": {"writing": 25, "reading": 40, "listening": 30, "speaking": 35}, "number": 0, "name": "Alice"}'
```

通常のクラスでも`__dict__`を使うことで辞書の形式に変換できますが、ネストしている時は一手間が必要です。

辞書からクラスに戻す時はアンパックを使い以下のようになります。

```py
DataclassPerson(**dict_person1)
```

### メリット：簡単にイミュータブルにできる

dataclassを使えば簡単にイミュータブルにできます。書き換えることがないデータに対してはイミュータブルにしておけば、どこかで変わっているのではないかという不安から逃れられます。

↓何も指定しないとミュータブルですが、

```py
@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.number) # 0
print(dataclass_person1.name) # Alice

dataclass_person1.number = 1
print(dataclass_person1.number) # 1
```

↓デコレータの引数で`frozen=True`とすると、イミュータブルになります。この時には`__hash__()`が自動的に作られ、`hash()`を使いハッシュ値を取得することもできます。

```py
@dataclasses.dataclass(frozen=True)
class FrozenDataclassPerson:
    number: int
    name: str = 'Alice'
    
frozen_dataclass_person1 = FrozenDataclassPerson(number=0, name='Alice')
print(frozen_dataclass_person1.number) # 0
print(frozen_dataclass_person1.name) # Alice
print(hash(frozen_dataclass_person1)) # -4135290249524779415

frozen_dataclass_person1.number = 1 # FrozenInstanceError: cannot assign to field 'number'
```

### イミュータブルにできるnamedtupleとは何が違うのか

イミュータブルにしたい用途では以下のような標準ライブラリもあります。

- collections.namedtuple
- typing.NamedTuple （Python3.6.1から）

これらを使うと、ドットアクセスができるタプル（＝イミュータブルなオブジェクト）が作れます。

```py
from collections import namedtuple

CollectionsNamedTuplePerson = namedtuple('CollectionsNamedTuplePerson', ('number' , 'name'))

collections_namedtuple_person1 = CollectionsNamedTuplePerson(number=0, name='Alice')
print(collections_namedtuple_person1.number) # 0
print(collections_namedtuple_person1.name) # Alice
print(collections_namedtuple_person1 == (0, 'Alice')) # True

collections_namedtuple_person1.number = 1 # AttributeError: can't set attribute
```

↓さらにtyping.NamedTupleは型アノテーションも可能です。

```py
from typing import NamedTuple

class NamedTuplePerson(NamedTuple):
    number: int
    name: str = 'XXX'

namedtuple_person1 = NamedTuplePerson(0, 'Alice')
print(namedtuple_person1.number) # 0
print(namedtuple_person1.name) # Alice
print(typing_namedtuple_person1 == (0, 'Alice')) # True

namedtuple_person1.number = 1 # AttributeError: can't set attribute
```

詳しくは[namedtupleで美しいpythonを書く！（翻訳） - Qiita](https://qiita.com/Seny/items/add4d03876f505442136)が分かりやすいです。

dataclassとtyping.NamedTupleは似ていますが、細かい点では異なります。上記コードに載せたように、同じ要素を持つタプルとの比較でTrueになることはデメリットと言えそうです。

typing.NamedTupleの方が便利な機能としては、タプルですからアンパック代入ができることが挙げられます。使い所によっては無理にdataclassにするより良いでしょう。

## 各種機能

### `__repr__()`が作られているので中身が簡単に確認できる

`__repr__()`が自動的に作られているので、`print()`などで中身が簡単に確認できます。

```py
@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1) # DataclassPerson(number=0, name='Alice')
```

通常のクラスで同じ表示をさせようとすると、以下を書く必要があります。

```py
class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name

    def __repr__(self):
        return f'{self.__class__.__name__}({", ".join([f"{key}={value}" for key, value in self.__dict__.items()])})' 
    
person1 = Person(0, 'Alice')
print(person1) # Person(number=0, name=Alice)
```

### `__post_init__()`で初期化後の処理を書ける

通常のクラスの`__init__()`で代入以外の処理をしていたような時は、`__post_init__()`を使います。代入後にこのメソッドが呼ばれることになります。また、引数として渡さないインスタンス変数を作る場合は`dataclasses.field(init=False)`を使います。

```py
@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
    is_even: bool = dataclasses.field(init=False)
    
    def __post_init__(self):
        self.is_even = self.number%2 == 0
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.number) # 0
print(dataclass_person1.name) # Alice
print(dataclass_person1.is_even) # True
```

### `InitVar`で初期化用の引数を渡せる

以下の例のように、初期化時に引数として渡したいが、インスタンス変数にはしたくない値がある場合もあります。

```py
class Person:
    def __init__(self, number, name='XXX'):
        self.name = name
        self.is_even = number%2 == 0

person1 = Person(0, 'Alice')
print(person1.name) # Alice
print(person1.is_even) # True
```

そういった時には`InitVar`を使います。

```py
@dataclasses.dataclass
class DataclassPerson:
    number:  dataclasses.InitVar[int]
    name: str = 'XXX'
    is_even: bool = dataclasses.field(init=False)
    
    def __post_init__(self, number):
        self.is_even = number%2 == 0
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.name) # Alice
print(dataclass_person1.is_even) # True
```

## さいごに

入社後1年弱でのアドベントカレンダーということで、個人開発だとまあテキトウで良いかとなりがちだけど、チーム開発だと大事にしたい箇所の紹介でした。

使うと便利だけど、使わなくてもどうにかなる機能はキャッチアップを怠りがちですが、新機能には追加されるだけの理由があります。最近のPythonは型アノテーションが随分と取り入れられたりと数年前とは雰囲気もだいぶ変わりつつあります。好き嫌いはあるかもしれませんが、まずは知っておかないことには考えることもできませんので、置いてかれないようにしたいものですね！

## 参考文献

[dataclasses --- データクラス — Python 3.9.1 ドキュメント](https://docs.python.org/ja/3/library/dataclasses.html)
[PEP 557 -- Data Classes | Python.org](https://www.python.org/dev/peps/pep-0557/)

---

### おしらせ

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします！

また [DeNA 公式 Twitter アカウント @DeNAxTech](https://twitter.com/DeNAxTech) では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい！
<a href="https://twitter.com/DeNAxTech?ref_src=twsrc%5Etfw" class="twitter-follow-button" data-size="large" data-lang="ja" data-show-count="false">Follow @DeNAxTech</a><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>