dataclasses: Pythonの簡潔なデータクラス

1. 概要

データを保持するだけのクラス(Order、Customer、Pointなど)を書くとき、いつも__init__、__repr__、__eq__を手で書かなければいけません。これはボイラープレートコードの典型です。dataclassesモジュール(Python 3.7以降)を使えば、デコレータ1つでこれらのメソッドを自動生成できます。この記事を読み終わったあと、@dataclassでシンプルなデータクラスを書く方法、__post_init__でデータを検証する方法、イミュータブル(不変)クラスにする方法、そしてdictやJSONに変換する方法を理解することができます。

2. 基本的なdataclassとは

@dataclassはデコレータで、クラス内の型ヒントを読み取り、それらに基づいて__init__、__repr__、__eq__を自動生成します。

from dataclasses import dataclass

@dataclass
class Customer:
    name: str
    email: str
    age: int = 0
  • @dataclassは型ヒント付きの属性(name、email、age)をスキャンし、これらをフィールドとして扱います。
  • age: int = 0にはデフォルト値があり、関数のデフォルト引数と同じです。
  • __init__を自分で書く必要はありません。フィールドの順序に従って自動生成されます。
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)

__repr__は自動生成され、フィールド名と値を完全に表示するため、デバッグ時に非常に便利です。通常のクラスではこれを自分で書く必要があります。

3. 通常のクラスとnamedtupleとの比較

手で書いた同等のクラスはずっと長くなります:

class CustomerPlain:
    def __init__(self, name, email, age=0):
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"

    def __eq__(self, other):
        if not isinstance(other, CustomerPlain):
            return NotImplemented
        return (self.name, self.email, self.age) == (other.name, other.email, other.age)

@dataclassがあれば、この部分すべてを書く手間が省けます。namedtupleとの違いは以下の通りです:

  • オブジェクトはデフォルトでミュータブル(可変)です(c1.age = 20と書き換えられます)。namedtupleはそうではありません。
  • 型ヒントが明確に書け、mypyなどの型チェッカーと統合しやすいです。
  • __post_init__、複雑なデフォルト値、継承などをサポートします。namedtupleではこれらが不便です。

注意: 変更不可の軽量なタプルが必要なだけで、メソッドは不要な場合は、namedtupleまたはtyping.NamedTupleのほうがシンプルです。

4. field()とミュータブルなデフォルト値

よくあるバグ:listやdictをデフォルト値として直接使うことです。

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: str
    items: list[str] = field(default_factory=list)
  • items: list[str] = []と書くと、Python はValueError: mutable default ... is not allowedエラーを出します。なぜなら、デフォルトのlistがすべてのインスタンスで共有されてしまい、見つけにくいバグになるからです。
  • field(default_factory=list)を使うと、dataclassは各インスタンスに対して新しくlist()を呼びます。
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")

print(o1.items, o2.items)
['Sách'] []

2つのオブジェクトのitemsは独立しており、期待通りです。

5. __post_init__でデータを検証する

dataclassが生成した__init__では、直接ロジックを挿入することはできません。しかし__post_init__フックがあり、__init__の直後に実行されます。

from dataclasses import dataclass

@dataclass
class PaymentRequest:
    amount: float
    currency: str = "VND"

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("amountは0より大きい必要があります")
        self.currency = self.currency.upper()
  • __post_init__はフィールドをすべて割り当てた後に自動的に呼ばれます。
  • ここではamountを検証し、currencyを大文字に正規化します。
try:
    PaymentRequest(amount=-100)
except ValueError as e:
    print(e)
amountは0より大きい必要があります

6. frozen=Trueでイミュータブルなクラスを作る

オブジェクトを作成後に変更されないようにしたい場合(Javaのrecordのように)、frozen=Trueを使います。

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
  • frozen=Trueにすると、__setattr__がブロックされます。初期化後にフィールドに値を割り当てようとするとエラーが発生します。
  • イミュータブルのおかげで、frozenオブジェクトはdictのキーまたはsetの要素として使用できます(dataclassが自動的に__hash__を生成するため)。
p = Point(1.0, 2.0)
try:
    p.x = 5.0
except Exception as e:
    print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'

注意: frozen=Trueは第1レベルの属性への割り当てだけをブロックします。フィールドがlistやdictの場合、内側の内容は変更可能です。属性の再割り当てだけがブロックされます。

7. dataclassの継承

dataclassは通常のPythonクラスのように継承をサポートし、子クラスのフィールドは親クラスのフィールドの後に続きます。

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    salary: float = 0.0
  • EmployeeはPersonからnameとageを継承し、さらにsalaryを追加します。
  • 生成された__init__のパラメータの順序はname, age, salaryです。
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)

注意: デフォルト値なしのフィールドは、継承チェーン全体でデフォルト値ありのフィールドより前に来る必要があります。親クラスがデフォルト値ありのフィールドを持つのに、子クラスがデフォルト値なしのフィールドを追加すると、クラス定義時にTypeErrorが発生します。

8. order=Trueで比較とソートを実現する

デフォルトではdataclassは__eq__だけを生成します。<、>、<=などで比較したい場合はorder=Trueを使います。

from dataclasses import dataclass

@dataclass(order=True)
class Product:
    price: float
    name: str = field(compare=False)
  • order=Trueは__lt__、__le__、__gt__、__ge__を追加で生成し、フィールドの順序に従って比較します(tupleの比較と同様)。
  • field(compare=False)はnameを比較から除外します。priceのみで比較します。
products = [Product(50_000, "Bút"), Product(20_000, "Vở")]
print(sorted(products))
[Product(price=20000, name='Vở'), Product(price=50000, name='Bút')]

sorted()はdataclassが生成した__lt__を使い、手動でkey=lambda p: p.priceと書く必要がありません。

9. dictとtupleへの変換

dataclassesはasdictとastupleの便利な関数を提供して、オブジェクトをシリアライズします。

from dataclasses import dataclass, asdict, astuple

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Customer2:
    name: str
    address: Address
cust = Customer2("Chi", Address("Hà Nội", "Việt Nam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hà Nội', 'country': 'Việt Nam'}}
('Chi', ('Hà Nội', 'Việt Nam'))
  • asdictは再帰的にネストされたdataclass(Address)も含めてdictに変換します。APIからJSONを返すのに便利です。
  • astupleは同様ですがtupleを返します。

注意: asdictとastupleはすべてのデータをディープコピーします。大きなオブジェクトやファイルハンドル、接続など、コピーできないフィールドを含む場合、呼び出しは遅くなるか失敗する可能性があります。

10. dataclassのテストを書く

__eq__がフィールドの値に基づいて自動生成されるため、オブジェクトの比較テストはpytestで自然に書けます。

import pytest
from dataclasses import FrozenInstanceError

def test_customer_equality():
    c1 = Customer("An", "an@example.com", 20)
    c2 = Customer("An", "an@example.com", 20)
    assert c1 == c2

def test_payment_request_rejects_non_positive_amount():
    with pytest.raises(ValueError):
        PaymentRequest(amount=0)

def test_point_is_frozen():
    p = Point(1.0, 2.0)
    with pytest.raises(FrozenInstanceError):
        p.x = 10.0
  • test_customer_equalityは、異なるインスタンスでもフィールド値が同じならば==が成立することを確認します。これは自動生成された__eq__のおかげです。
  • test_payment_request_rejects_non_positive_amountは__post_init__の検証が正しく機能することを確認します。
  • test_point_is_frozenはfrozen=Trueが属性割り当てをブロックすることを確認します。

11. よくあるエラーとdataclassを使うべきでない場合

  • ミュータブルなデフォルト値でdefault_factoryを忘れる:= []または= {}を直接使うとクラス定義時にエラーが出ます。field(default_factory=...)を使う必要があります。
  • デフォルト値の有無が混在する際の継承で順序を間違える:TypeErrorがモジュール読み込み時に発生し、デバッグが難しいです。
  • frozen=Trueがディープイミュータブルだと思い込む:list/dictフィールドの内側の内容は編集可能です。属性の再割り当てだけがブロックされます。
  • 複雑なビジネスロジックに対してdataclassを使う:クラスに多くのメソッドがあり、他のオブジェクトとの関係が複雑な場合、dataclassは初期化と比較の部分を簡潔にするだけです。ロジック部分は自分で書く必要があります。データクラスが「実は複雑だ」というのを隠さないようにしましょう。
  • Python < 3.7と互換性が必要:dataclassesはビルトインではないため、PyPIからdataclassesパッケージ(バックポート)をインストールするか、attrsを使う必要があります。

注意: 複雑な検証、カスタムシリアライゼーション、JSONスキーマとの深い統合が必要な場合、pydanticのようなライブラリはしばしばdataclassesより強力です。プロジェクトが大きくなるなら検討する価値があります。

12. まとめ

dataclassesは、データを保持するクラスを書くときのボイラープレートをほぼすべて削除してくれます。__init__、__repr__、__eq__を自動生成し、__post_init__で検証をサポートし、frozen=Trueでイミュータブルにでき、asdict/astupleで簡単にシリアライズできます。シンプルから中程度のデータクラスなら、手で書くより明らかにシンプルで読みやすいです。検証やシリアライゼーションがより複雑になったら、pydanticやattrsの使用を検討する価値があります。

pythondataclassesoopclean-code