🐍

PEP557から読み解くPythonのdataclassの嬉しさと他手段との比較

2022/11/04に公開約11,400字

1. はじめに 🚀

1-1. dataclassってなに

みなさんPythonのdataclass使ってますか?

dataclassは真新しい機能ではなくPython3.7からある[1]標準ライブラリです。

dataclassの解説記事ではよく『dataclassとはデータを保持のためのclassである』という説明がされていることが多いです。これはPEP557にある

store values which are accessible by attribute lookup
アトリビュート検索でアクセス可能な値の保持

Data Classes can be thought of as “mutable namedtuples with defaults”. Because Data Classes use normal class definition syntax, you are free to use inheritance, metaclasses, docstrings, user-defined methods, class factories, and other Python class features.
dataclassとは『デフォルト付きのミュータブルなnamedtuple(名前付きタプル)』と考えることが出来る。
またdataclassは通常のクラス定義構文を用いるので、継承、メタクラス、ドキュメンテーション文字列、ユーザー定義メソッド、クラスファクトリ、その他諸々のPythonのクラスに関わる機能を自由に使うことが出来る。

といった一連の文章を上手く要約した良い説明であると思います。

from dataclasses import dataclass

// デコレーターとして使うやり方が一般的
@dataclass
class InventoryItem:
    '''Class for keeping track of an item in inventory.'''
    name: str
    unit_price: float
    quantity_on_hand: int = 0

    def total_cost(self) -> float:
        return self.unit_price * self.quantity_on_hand

apple = NewInventoryItem(name="Apple", unit_price=100)

1-2. この記事の目的、dataclassの嬉しさを説明したい!

けれどもデータ保持はdictやnamedtupleでも可能なのに、なぜわざわざdataclassを用いるのでしょうか?

また通常のclassをデータ保持の目的で使うのと比べてどういった優位性があるのでしょうか?

dataclassの嬉しさは (1)型の明示 (2)ダンダー等のクラス機能 (3)容易なイミュータブル化の3つにあると自分は考えています。

この記事ではPEP557のドキュメントを適宜引用しながら、dataclassの使いどころや他手段との優位性について説明していきます。

この記事の一連の説明を読むことで「データのやり取りを行う際の各種処理に関わるPythonコードの可読性や安全性を上げていきたい方」が dataclassの良さや使いどころ を腹落ち出来るようになるはずです!

※もし「ここの説明は違くないか?」等あればコメント等で指摘いただけると幸いです。

2. dataclassの嬉しさ 🌻

dataclassの嬉しさは (1)型の明示 (2)ダンダー等のクラス機能 (3)容易なイミュータブル化の3つにあると自分は考えています。この3点の嬉しさについて1つ1つ説明してきます。

2-1. 型の明示

One main design goal of Data Classes is to support static type checkers.
dataclassの主な設計目標は、静的型チェッカをサポートすることです。

PEP537のドキュメントでも述べられているようにdataclassの嬉しさ1つ目はデータの型が明示されることです。

なぜデータの型が明示されると嬉しいかと言うと、コードの可読性があがり、IDEでの補完機能が効くことによる開発効率の向上が見込めるからです。

Pythonのような動的型付け言語のみでやっていると「型が明示されてると何が嬉しいの...?」となる方もいるかもしれませんので少し補足しておくと、他人[2]の書いた(型のない言語の)ライブラリ・スクリプトを使うとなると、どうしても「どういうデータが来るのかイマイチわからない」状態が生まれてしまいます。

ここで言う「イマイチわからない」というのは該当のデータに存在するkeyやそのkeyの値の型がどんなものかわからない、ということです。

こうした場合「存在しないkeyにアクセスしないように」したり、「違う型のデータにある組み込み関数を使わないようにしたり」といったチェックをするために、いちいち他人の書いたコードをしっかり読み込まないといけなくなってしまいます。

このようなことを防ぐためにも、どういった型のデータがあるのかを明示できるdataclassはある程度以上の規模・期間の開発をする際において特にdictやnamedtuple[3]と比較して優位性があります。

from dataclasses import dataclass

@dataclass
class User:
    user_id: int
    name: str
    email: str
    favorite_foods: list = field(default_factory=list)


"""
この関数を呼び出す人はdictで返されるとどんなデータが来るのか分からないので
関数の中身を読まないといけない😓
"""
def get_user_dict(user_id: int) -> dict:
    # 複雑なロジック


"""
この関数を呼び出す人は関数の中身を読まなくても
dataclassのUserを読めばどんなデータが返ってくるかすぐ分かる!😄
"""
def get_user_info(user_id: int) -> User:
    # 複雑なロジック

2-2. ダンダー等のクラス機能

dataclassはクラスの一種なのでdictやnamedtupleと違ってフィールドへのデフォルト値の設定が出来ます。

またダンダーによって値の出し入れの際に決まった処理を行ったり、テストが楽かつ容易にかけるような仕組みを導入することが出来ます。

ダンダー(dunder、double underscoreの略)とはPythonのObjectクラス内の関数で2つアンダーバー(_)がついた関数のことです。[4]

さらにdataclassが通常のクラスと比較して凄いところはデフォルトの設定のままではダンダー内での処理をかなり良い感じに設定しておいてくれることです。

dataclassは宣言の際のパラメータをデフォルトのままにしていても下記のように一部のダンダーが設定されます。

パラメータ デフォルト値 関連するダンダー 補足
init True __init__ デフォルトで出来ます。
repr True __repr__ デフォルトで出来ます。デバッグに便利。
eq True __eq__ デフォルトで出来ます。テストコードを書く際に便利
order False __lt__ __le__ __gt__ __ge__ Trueに変えると関連ダンダーが生成されます。
unsafe_hash False __hash__ デフォルト値(False)の場合、dataclassがfrozenなら関連ダンダーが生成されます。frozenでないならNoneの値を持つ__hash__プロパティを保持するようになります。
frozen False - Trueならdataclassのフィールドは値の変更をしようとすると例外を起こします。(別のセクションで詳しく説明)

上記のようにダンダー(dunder)がデフォルトで設定されるおかげでスッキリした見た目になります。

通常のクラスだとダンダーがゴチャゴチャしてしまう😓
class OldInventoryItem:

    # frozenでないdataclassの場合__hash__メソッドは実装されない
    __hash__ = None

    def __init__(self, name: str, unit_price: float, quantity_on_hand=3):
        self.name = name
        self.unit_price = unit_price
        self.quantity_on_hand = quantity_on_hand

    def __repr__(self):
        return f"OldInventoryItem(name='{self.name}', unit_price={self.unit_price}, quantity_on_hand={self.quantity_on_hand})"

    def __eq__(self, other):
        if self.__class__ != other.__class__:
            return False
        if (self.name == other.name) and (self.unit_price == other.unit_price) and (self.quantity_on_hand == other.quantity_on_hand):
            return True
        return False
dataclassならスッキリ😆
from dataclasses import dataclass

@dataclass
class NewInventoryItem:
    """
    Class for keeping track of an item in inventory.
    """
    name: str
    unit_price: float
    quantity_on_hand: int = 3

必須の__init__が無くなってスッキリした見た目になるのはわかったけど、なぜ__repr____eq__に書いてあるような内容が実装されていると嬉しいの?と疑問に思う方向けに説明していくと......

__repr__は元のObjectに戻せる文字列をrepr関数に入れると吐いてくれて、printデバッグでの作業が楽になり地味に嬉しいです。

repr関数で元のObjectに戻せる
from dataclasses import dataclass
@dataclass
class NewInventoryItem:
    """
    Class for keeping track of an item in inventory.
    """
    name: str
    unit_price: float
    quantity_on_hand: int = 3

# evalに突っ込むと元のObjectに戻せる文字列をrepr関数に入れると出してくれる
print(type(repr(apple)), repr(apple)) # <class 'str'> NewInventoryItem(name='apple', unit_price=1.5, quantity_on_hand=3)

__eq__は比較演算子(==<=等)に該当のオブジェクトを突っ込むと発動するダンダーですが、これがあると後々のテストコードの記述が非常に楽になります。

オブジェクトの比較が出来るように
apple = NewInventoryItem(name="apple", unit_price=1.5)
apple2 = NewInventoryItem(name="apple", unit_price=1.5)
orange = NewInventoryItem(name="orange", unit_price=1.3)

# 同じ
if apple == apple2:
    print(f"同じだよ")
else:
    print("違うよ")

# 違う
if apple == orange:
    print(f"同じだよ")
else:
    print("違うよ")

また__eq__に関して付け加えるとnamedtupleと違い、値だけでなくインスタンス元のクラスが同じかということも比較してくれるのでミスを防げるのが嬉しいです。

namedtupleでは防げない😓
from collections import namedtuple
NewInventoryItem = namedtuple("NewInventoryItem", ["name", "unit_price", "quantity_on_hand"])
AnotherInventoryItem = namedtuple("AnotherInventoryItem", ["name", "unit_price", "quantity_on_hand"])

# 地味にデフォルト値の設定も出来ないのも辛い
apple = NewInventoryItem(name="apple", unit_price=1.5, quantity_on_hand=3)
another_apple = AnotherInventoryItem(name="apple", unit_price=1.5, quantity_on_hand=3)

# 元のnamedtupleは異なるが同じと判定されてしまう
if apple == another_apple:
    print(f"同じだよ")
else:
    print("違うよ")
dataclassなら元のクラスも比較対象なので防げます!😆
from dataclasses import dataclass
@dataclass
class NewInventoryItem:
    """
    Class for keeping track of an item in inventory.
    """
    name: str
    unit_price: float
    quantity_on_hand: int = 3

@dataclass
class AnotherInventoryItem:
    """
    Class for keeping track of an item in inventory2.
    """
    name: str
    unit_price: float
    quantity_on_hand: int = 3


apple = NewInventoryItem(name="apple", unit_price=1.5)
another_apple = AnotherInventoryItem(name="apple", unit_price=1.5)

# 値は同じでも元のクラスが違うので違うと出る
if apple == another_apple:
    print(f"同じだよ")
else:
    print("違うよ")

2-3. 簡単にイミュータブルな値を設定できる

値を変更できるオブジェクトのことを mutable と呼びます。生成後に値を変更できないオブジェクトのことを immutable と呼びます。
参考:3. データモデル — Python 3.11.0b5 ドキュメントより

dataclassでは先程のTODOで触れたfrozenパラメーターを(デフォルトのFalseから)Trueにすることで簡単にオブジェクトをイミュータブル(値が変更できない状態)にすることが出来ます。

イミュータブルに設定しておくと、変更してはいけない値を他の人が誤って変更しようとした際にエラーが出るようになり安全です。

from dataclasses import dataclass
import datetime


@dataclass(frozen=True)
class Company:
    company_id: int
    name: str
    email: str
    created: datetime.datetime


kumamoto = Company(
    company_id=3,
    name="くまもと株式会社",
    email="kuma@example.com",
    created=datetime.datetime.now(),
)

"""
勝手に変更しようとすると
(dataclasses.FrozenInstanceError: cannot assign to field 'name')
とエラーになる
"""
kumamoto.name = "かごしま株式会社"

下記のように頑張れば値の変更をすることは可能[5]ですが他の人が強い意図をもって敢えてimmutableな値を強制的に変えたいと思わない限り大丈夫なので安全性を損なうことにはなりません。[6]

# 頑張れば値は変えられる
object.__setattr__(kumamoto, "name", "大分株式会社")
print("kumamoto.name", kumamoto.name)  # debug

また、Frozen(immutable)なdataclassの良さはミスを防げるということだけでなく、(クラスの定義をした人でない)他の開発者「これは値がコードの中で勝手に書き換わらない(ずっと一定)なのだな」と判断出来てコードを読むコストが下がることに繋がるのが嬉しいポイントです。

これはJavaScriptにおいて出来る限りletよりもconstを使うことが推奨されると似た理由です。

3. 補足:その他の機能と注意点 🍬

機能:slotパラメーターを使ってdataclassに無いアトリビュートを制限する

Python3.10からはslotパラメーターによって__slot__が設定させることができます。
これによって余計なアトリビュートやtypoによる事故が防ぐことが出来ます。

from dataclasses import dataclass

@dataclass
class OldPerson:
    name: str
    age: int

@dataclass(slots=True)
class NewPerson:
    name: str
    age: int

old_person = OldPerson(name="くまもと", age=28)
new_person = NewPerson(name="おおいた", age=20)

"""
😓
dataclassに無い新しいアトリビュートを付けられてしまう
"""
old_person.job = "エンジニア"

"""
😄
dataclassに無い新しいアトリビュートを付けようとすると
AttributeError: 'NewPerson' object has no attribute 'job'
とエラーが出て防がれる
"""
new_person.job = "営業"

機能:fieldでデフォルト値を設定

TODO(追記予定)

機能:dataclasses_jsonと組み合わせて簡単にdictやjsonに変換する

dataclassを使いたい!となってもdictやjson形式でのデータしか受け入れてくれないプログラムにデータを送る必要があればどうすれば......💧という方にはdataclasses_jsonという別ライブラリの導入がオススメです。

これをデコレーターに付けてやるだけでdataclassをdictやjsonに簡単に変換することが出来るようになります。

from dataclasses import dataclass
from dataclasses_json import dataclass_json

@dataclass
class Company:
    corporate_id: int
    name: str


# アタリマエだがdataclass_jsonはdataclassデコレーターの上に付けること
@dataclass_json
@dataclass
class Person:
    name: str
    age: int
    company: Company


company = Company(corporate_id=1, name="九州合同会社")
person = Person(name="ながさき", age=17, company=company)
"""
dict, jsonどちらも対応、ネストされていても問題なし
{'name': 'ながさき', 'age': 17, 'company': {'corporate_id': 1, 'name': '九州合同会社'}}
"""
print(person.to_dict())
print(person.to_json())

注意:型と違う値を入れても無視されてしまう

TODO(追記予定、mypy上で警告は出来るが実行してもエラーにはならない)

pydanticを使ってより安全に書く

TODO(別記事で書くカモです)

参考にさせていただいた記事・書籍 📘

PEP 557 – Data Classes | peps.python.org
dataclasses --- データクラス — Python 3.11.0b5 ドキュメント
Python 3.10の新機能(その10) Dataclassでslotsが利用可能に: Python3.10の新機能 - python.jp
PEP 537 – Python 3.7 Release Schedule | peps.python.org
typing --- 型ヒントのサポート class typing.NamedTuple — Python 3.11.0b5 ドキュメント
【Python】typing.NamedTuple を使う - TIL
Dataclassをjson形式でシリアライズ - どこから見てもメンダコ
書籍:「エキスパートPythonプログラミング 改訂3版」 Michal Jaworski[PC・理工科学書] - KADOKAWA

脚注
  1. Python3.6でも使えるようにバックポートが用意されている(ericvsmith/dataclasses) ↩︎

  2. 過去の自分もある意味では他人なので1人開発でも同じことが言えると個人的には思います ↩︎

  3. ただしtyping.NamedTupleを用いれば型のあるnamedtupleを書ける ↩︎

  4. Special Method(特殊メソッド)、Magic Methodと呼ばれることもあります ↩︎

  5. dataclassのFrozenは実はimmutableなインスタンスのエミュレートに過ぎないので ↩︎

  6. じゃあいつ使うのという話ですが、初期化の__init__の中身の処理を自分で書きたいときや、__post_init__でインスタンス化の際の初期データの加工を行いたい時に使います。 ↩︎

GitHubで編集を提案

Discussion

ログインするとコメントできます