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の使用を検討する価値があります。