クラスの__init__や__repr__を手で書くのをやめたいとき、dataclassを使います。ただfrozenやasdictには、宣言の手軽さからは見えない挙動がある。Python 3.14.6時点の標準dataclassesを、事故が起きやすい箇所から整理します。
dataclassが自動で書くもの
フィールドを型ヒント付きで並べるだけで、@dataclassが__init__・__repr__・__eq__を生成します。
from dataclasses import dataclass
@dataclass
class Article:
title: str
views: int = 0
a = Article("dataclass入門")
print(a)
print(a == Article("dataclass入門"))
実行結果。
Article(title='dataclass入門', views=0)
True
消える定型コード
同じ機能を素のクラスで書くと、__init__で属性を代入し、__repr__で文字列を組み立て、__eq__で全フィールドを比較する。合わせて15行前後。dataclassはフィールド宣言2行に畳みます。
eq・orderで比較を生成する
デフォルトはeq=Trueだけ。大小比較まで欲しいならorder=Trueを渡します。
@dataclass(order=True)
class Version:
major: int
minor: int
print(Version(1, 2) < Version(1, 5))
True
order=Trueはフィールドを宣言順のタプルとみなして比較します。majorが先、次にminor。宣言の順序を変えれば比較結果も変わります。
可変デフォルトはValueErrorで弾かれる
リストや辞書をそのままデフォルト値にすると、クラス定義の時点で落ちます。
@dataclass
class Post:
tags: list = []
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
デフォルト値はクラス属性として1個だけ作られ、全インスタンスで共有される。可変オブジェクトを共有すると、あるインスタンスのappendが他のインスタンスにも波及します。公式ドキュメントの“Mutable default values”セクションがこの挙動を説明しています。
3.11で判定が変わった
以前はデフォルト値がlist・dict・set型のときだけ弾いていました。Python 3.11の”Changed in version 3.11″にある通り、いまはハッシュ不可なオブジェクト全般を許しません。自作の可変クラスをデフォルトに置いても検出されます。
default_factoryで各インスタンスに別物を渡す
from dataclasses import dataclass, field
@dataclass
class Post:
tags: list = field(default_factory=list)
print(Post().tags is Post().tags)
False
field(default_factory=...)はインスタンスを作るたびに呼ばれ、毎回新しいリストを返す。だから2つのインスタンスのtagsは別物になります。default_factoryはゼロ引数のcallableで、dictや自作の関数も渡せる。defaultとの同時指定はエラーです。公式の“Default factory functions”に仕様があります。
frozen=Trueは浅い凍結でしかない
frozen=Trueを付ければ変更不可になる、と考えると事故ります。凍結されるのは属性の再代入だけ。フィールドが指す先の中身までは守りません。
再代入はFrozenInstanceError
@dataclass(frozen=True)
class Config:
name: str
hosts: list = field(default_factory=list)
c = Config("prod")
c.name = "dev"
dataclasses.FrozenInstanceError: cannot assign to field 'name'
frozen=Trueは__setattr__と__delattr__を差し込み、代入や削除でFrozenInstanceErrorを投げます。公式の“Frozen instances”セクションにある挙動です。
listフィールドの中身は書き換わる
同じcに対して、フィールドが持つリストの中身はそのまま操作できます。
c.hosts.append("10.0.0.1")
print(c.hosts)
['10.0.0.1']
凍結が止めるのはc.hosts = [...]という差し替えだけ。c.hostsが指すリストへのappendは通ります。中身まで固定したいなら、フィールドの型をtupleやfrozensetにする。可変コレクションを持たせると、frozenの不変性はフィールドの差し替えにしか効きません。
frozenとeqでhashが決まる
frozenインスタンスは辞書のキーやsetの要素にできます。__hash__が生成される条件はeqとfrozenの組み合わせで決まる。
| eq | frozen | __hash__ |
|---|---|---|
| True | True | 自動生成される |
| True | False | None(ハッシュ不可) |
| False | 任意 | 親クラスのまま |
@dataclass(frozen=True)
class Fr:
x: int
@dataclass
class Mu:
x: int
print(Fr(1).__hash__ is not None)
print(Mu(1).__hash__ is not None)
True
False
通常のdataclassはeq=Trueかつfrozen=False。この組み合わせだと__hash__がNoneになり、setに入れようとするとTypeError: unhashable typeで落ちます。setやdictのキーに使う値オブジェクトは、frozen=Trueとセットで設計するのが素直です。
__post_init__とInitVarで初期化を補う
公式の“Post-init processing”セクションに、生成された__init__の末尾で__post_init__が呼ばれると書かれています。他のフィールドから導出する値を、ここで埋める。
他フィールドから導出する
from dataclasses import dataclass, field
@dataclass
class Rect:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
self.area = self.width * self.height
print(Rect(3, 4).area)
12
areaはfield(init=False)で__init__の引数から外し、__post_init__で計算した値を入れます。呼び出し側はRect(3, 4)だけ書けばよく、面積は自動で埋まる。
InitVarで初期化専用の引数を受ける
インスタンスには残さず、初期化のときだけ受け取りたい引数がある。パスワードの生値などがそれ。InitVarで宣言すると、フィールドにはならず__post_init__へ渡されます。
from dataclasses import dataclass, field, InitVar
@dataclass
class User:
name: str
password: str = field(init=False)
raw_password: InitVar[str] = ""
def __post_init__(self, raw_password):
self.password = f"hashed:{raw_password}"
u = User("moha", raw_password="secret")
print(u)
User(name='moha', password='hashed:secret')
raw_passwordはインスタンス属性として残らず、reprにも出ません。生値を保持せず、ハッシュ済みの値だけ持てます。
asdict・replaceの挙動を理解する
asdict()とreplace()はよく使う道具ですが、コピーの深さを取り違えると事故ります。
asdictは全ネストをdeepcopyする
asdict()はネストしたdataclassやリストを再帰的にたどり、丸ごとdictへ変換します。この変換は深いコピー。
@dataclass
class Inner:
items: list
@dataclass
class Outer:
inner: Inner
o = Outer(Inner([1, 2]))
d = asdict(o)
o.inner.items.append(3)
print(d)
{'inner': {'items': [1, 2]}}
変換後に元のitemsへ3を足しても、dは[1, 2]のまま。asdictが値を全部コピー済みだからです。ネストしたInnerもdictになっている点に注目してください。1階層だけ辞書にしたいときには重すぎる道具になります。
コストも測っておきます。業務で深いレスポンス構造をasdictで整形していたら、プロファイルにasdictが乗ってきたことがありました。深さ6・分岐3で1093ノードのツリーを変換すると、1回あたり約0.46ms。ノード数に比例して伸びます。
import timeit
from dataclasses import dataclass, field, asdict
@dataclass
class Node:
val: int
children: list = field(default_factory=list)
def build(depth, width):
if depth == 0:
return Node(0)
return Node(depth, [build(depth - 1, width) for _ in range(width)])
tree = build(6, 3) # 1093 ノード
t = timeit.timeit(lambda: asdict(tree), number=200)
print(f"{t / 200 * 1000:.2f} ms/回")
0.46 ms/回
リクエストごとに大きな構造をasdictするホットパスでは、必要なフィールドだけ手で辞書に詰める方が軽い。全変換が要らないならasdictを避ける判断もあります。
replaceでfrozenを安全に更新する
frozenは再代入できない。一部だけ変えた新インスタンスが欲しいならreplace()で作ります。
from dataclasses import replace
@dataclass(frozen=True)
class Setting:
name: str
retries: int = 3
base = Setting("prod")
staging = replace(base, name="staging")
print(staging)
Setting(name='staging', retries=3)
replaceは指定しなかったフィールドを元インスタンスから引き継ぎ、新しい__init__を呼びます。ただしinit=Falseのフィールドがあるとreplaceに渡せず、そのままではエラーになります。
slots=Trueはメモリと安全性に効く
slots=True(Python 3.10で追加)は__slots__付きのクラスを生成し、インスタンスから__dict__を取り除きます。
__dict__を持たない分メモリが減る
3フィールドのdataclassを20万個作り、消費メモリを比べました。
Plain 200000個: 19.9MB
Slotted 200000個: 12.2MB
38%減。一方で属性アクセスの速度はほぼ変わりません。500万回のアクセスで45msと48ms、差は誤差の範囲。slotsの効果は速度ではなくメモリに出ます。
未定義属性の代入を弾く
@dataclass(slots=True)
class Point:
x: int
y: int
p = Point(1, 2)
p.d = 4 # タイプミスのつもり
AttributeError: 'Point' object has no attribute 'd' and no __dict__ for setting new attributes
通常のdataclassはp.d = 4を黙って受け入れ、タイプミスがそのまま通ります。slots=Trueなら定義外の属性で気づける。ただしslots=Trueは元のクラスではなく新しいクラスを返すので、既存の__slots__やデコレータと衝突する場合がある。公式ドキュメントも、フィールド名の取得に__slots__を使わずfields()を使うよう注意しています。
dataclass / Pydantic / TypedDictの使い分け
dataclass・Pydantic・TypedDictは見た目が近いのに役割が違います。入力を検証するのか、内部で持ち回すのか、辞書に型を付けたいのか。用途で選びます。
| やりたいこと | 選ぶもの | 理由 |
|---|---|---|
| 外部入力の検証・変換 | Pydantic | 型強制とバリデーションを実行時に走らせる |
| 内部のデータ構造 | dataclass | 標準ライブラリで軽い。検証は持たない |
| 辞書の形に型を付ける | TypedDict | 実体はdict。生成コストがない |
検証が要るならPydantic
dataclassは型ヒントを見るだけで、実行時の検証はしません。
@dataclass
class Article:
title: str
print(Article(title=123).title)
123
title: strと書いても123がそのまま通ります。APIの入力など信頼できないデータを受けるなら、PydanticのBaseModelで実行時に弾く。速度と検証のトレードオフはmsgspec vs Pydanticで扱っています。
辞書の型付けならTypedDict
JSONをdictのまま扱いたいが、キーと値の型は固定したい。この用途はTypedDictが合います。実体はただのdictなのでインスタンス生成コストがなく、既存のdictをそのまま型付けできる。逆にメソッドを持たせたい、生成時に処理を挟みたいなら、dataclassの側です。
まとめ
- 可変デフォルトは
field(default_factory=...)で渡す。素の[]や{}はValueError frozen=Trueは属性の再代入だけを止める。リストの中身は書き換わる。完全な不変にはtupleを使う__post_init__とInitVarで、他フィールドから導出する値や初期化専用の引数を扱えるasdictはネスト全体を深いコピーする。1093ノードで0.46ms、ホットパスでは手書きの辞書化も選択肢slots=Trueはメモリを4割ほど削り、未定義属性の代入を弾く。速度差は小さい- 実行時の検証が要るならPydantic、辞書の型付けならTypedDict、内部の素直な入れ物ならdataclass

