Python dataclass実践—field・frozen・asdictの正しい書き方

Python dataclass実践—field・frozen・asdictの正しい書き方 | mohablog

クラスの__init____repr__を手で書くのをやめたいとき、dataclassを使います。ただfrozenasdictには、宣言の手軽さからは見えない挙動がある。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で判定が変わった

以前はデフォルト値がlistdictset型のときだけ弾いていました。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は通ります。中身まで固定したいなら、フィールドの型をtuplefrozensetにする。可変コレクションを持たせると、frozenの不変性はフィールドの差し替えにしか効きません。

frozenとeqでhashが決まる

frozenインスタンスは辞書のキーやsetの要素にできます。__hash__が生成される条件はeqfrozenの組み合わせで決まる。

eqfrozen__hash__
TrueTrue自動生成される
TrueFalseNone(ハッシュ不可)
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

areafield(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が値を全部コピー済みだからです。ネストしたInnerdictになっている点に注目してください。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
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次