Pythonクラス定義のTips① — dataclass・eq・__post_init__を実務目線で
「dataclassは“宣言した事実”から安全な初期化と比較を自動化し、
__post_init__
は“仕上げ”、__eq__
は“意味としての等しさ”を約束するフックである。」
1. dataclassの使い方 — “データの事実”を宣言し、自動生成に任せる
@dataclass
は、フィールド宣言から __init__
・__repr__
・__eq__
・(必要なら)__hash__
・順序比較などを安全に自動生成する仕組みです。自分で __init__
を書き始める前に「本当に手で書く必要があるか?」を一度疑ってみると、だいたい不要です。
1.1 まずは最小の例
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
active: bool = True
- 生成される
__init__(id: int, name: str, active: bool = True)
は宣言順で代入。 -
eq=True
(既定)なので全フィールド比較の__eq__
が自動生成。 -
repr=True
(既定)で見やすい__repr__
も自動。
1.2 フィールドの役割を明示する
from dataclasses import dataclass, field
@dataclass(slots=True, frozen=True)
class Point:
x: float
y: float
# 生成後に計算する派生値。比較・repr からも除外
r: float = field(init=False, repr=False, compare=False)
-
slots=True
で軽量かつ属性のタイポを防止。 -
frozen=True
でイミュータブル。ハッシュ(__hash__
)は安全に自動生成。 -
field(init=False)
で__init__
の引数から外し、後で__post_init__
で設定する前提にできる。
1.3 dataclassオプション比較表(同じ階層の比較)
オプション | 役割 | 既定 | 注意点 |
---|---|---|---|
eq |
__eq__ を自動生成 |
True |
frozen=False かつ eq=True だと __hash__=None (非ハッシュ)。 |
order |
<, <=, >, >= 自動生成 |
False |
eq=True が前提。意味のある順序か要検討。 |
frozen |
イミュータブル化 | False |
変更不可。__post_init__ で object.__setattr__ 使用。 |
slots |
__slots__ 付与 |
False |
動的属性不可。継承戦略も事前設計。 |
repr |
__repr__ 自動生成 |
True |
機密情報の露出に注意。field(repr=False) で隠せる。 |
unsafe_hash |
強制ハッシュ化 | False |
可変とハッシュの不整合に注意(原則避ける)。 |
kw_only |
後続フィールドをキーワード専用に | False |
APIの明確化に有効。 |
1.4 field(...)
の主なパラメータ比較表
パラメータ | 目的 | 典型値 | ワンポイント |
---|---|---|---|
default |
既定値 |
0 , "" など |
ミュータブルは禁止。リスト等は default_factory 。 |
default_factory |
生成時に関数呼び出し |
list , dict
|
ミュータブルの鉄板。lambda: {} も可。 |
init |
__init__ 引数に含めるか |
True |
派生値は False にして __post_init__ で設定。 |
repr |
__repr__ へ含めるか |
True |
機密/冗長な値は False 。 |
compare |
等価/順序に含めるか | True |
キャッシュや一時値は False 。 |
hash |
ハッシュ対象に含めるか | None |
微調整が必要なときのみ。 |
2. __eq__
の使い方 — “意味としての等しさ”を定義する
__eq__
は a == b
の規範を記述する場所です。NotImplemented を適切に返すことで、対称性(a == b
と b == a
の整合)や他型との相互運用性を保てます。
2.1 最小実装
class Vec2:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if isinstance(other, Vec2):
return (self.x, self.y) == (other.x, other.y)
return NotImplemented
- 型が違う場合は
NotImplemented
を返す。False
を返すと左右入れ替えが効かず、拡張性が落ちる。
2.2 浮動小数の“近さ”で比べる
import math
class Point:
def __init__(self, x: float, y: float):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (
math.isclose(self.x, other.x, rel_tol=1e-9, abs_tol=0.0)
and math.isclose(self.y, other.y, rel_tol=1e-9, abs_tol=0.0)
)
- 数値誤差を抱える領域では厳密一致ではなく近似一致を等価とするのが実務的。
2.3 __hash__
との整合
-
原則:
a == b
ならhash(a) == hash(b)
を満たすこと。 -
dataclass では:
-
eq=True
かつfrozen=True
→ 安全に__hash__
が自動。 -
eq=True
かつfrozen=False
→ 既定で非ハッシュ(集合・辞書キーにできない)。 - やむなく可変でもキー化したいなら
unsafe_hash=True
(更新で壊れる危険を理解した上で)。
-
2.4 比較表:__eq__
の返値と意味
返り値 | 意味 | 結果の流れ |
---|---|---|
True |
等価 | その場で True
|
False |
非等価 | その場で False (左右入替の再試行なし) |
NotImplemented |
この型では判断不可 | 右オペランドの __eq__ を試し、なければ最終的に非等価 |
2.5 dataclass と __eq__
の住み分け
方針 | 向くケース | 例 | 注意点 |
---|---|---|---|
自動生成に任せる | 「全フィールド一致=等価」 | 設定値のDTO、素直な値オブジェクト | フィールドの compare=False を忘れない |
自前実装する | 近似一致/部分一致/別型対応 | 浮動小数、座標、IDのみで比較 |
NotImplemented を返す作法を徹底 |
3. __post_init__
の使い方 — 初期化後の“仕上げフック”
__post_init__
は dataclass の __init__
が全フィールド代入を終えた直後に呼ばれます。バリデーション、正規化、派生値の計算に最適です。順序が不安定だから必要なのではなく、「全部そろった後にやりたい処理」のための場所です。
3.1 代表的な用途
- 入力の正規化:スペース除去、小文字化、Pathの展開など。
-
整合チェック:複数フィールドの関係を検証(
start < end
など)。 -
派生フィールド:
field(init=False)
とセットでキャッシュや要約を計算。 -
InitVar
の処理:初期化時だけ受け取り、フィールドには保持しない値を扱う。
from dataclasses import dataclass, field, InitVar
import os
@dataclass
class Config:
home: str
# InitVar は __init__ 引数には現れるが、フィールドには保存されない
env_home: InitVar[str | None] = None
home_abs: str = field(init=False)
def __post_init__(self, env_home):
base = env_home or self.home
self.home_abs = os.path.abspath(os.path.expanduser(base))
3.2 frozen=True
と組み合わせる
from dataclasses import dataclass, field
import math
@dataclass(frozen=True)
class Circle:
r: float
area: float = field(init=False, repr=False)
def __post_init__(self):
if self.r <= 0:
raise ValueError("r は正の数")
# frozen でも後処理だけは object.__setattr__ で代入できる
object.__setattr__(self, "area", math.pi * self.r ** 2)
3.3 比較表:__init__
に書く/__post_init__
に書く
書き場所 | 向く処理 | メリット | デメリット |
---|---|---|---|
__init__ を自前実装 |
dataclass の自動生成では表現できない複雑な初期化 | あらゆる自由度 | 自動生成の恩恵を失いバグ源に。引数順・既定値管理が重い |
__post_init__ |
正規化、検証、派生値、InitVar
|
自動生成の利点を維持しつつ後処理を一括 | 代入は全フィールド完了後。frozen では object.__setattr__ が必要 |
4. 3者の連携パターン(実務で使う形)
4.1 値オブジェクト(不変×比較×派生キャッシュ)
from dataclasses import dataclass, field
import math
@dataclass(frozen=True, slots=True)
class Vec2:
x: float
y: float
_len: float = field(init=False, repr=False, compare=False)
def __post_init__(self):
object.__setattr__(self, "_len", math.hypot(self.x, self.y))
def __eq__(self, other):
if not isinstance(other, Vec2):
return NotImplemented
# 浮動小数の近似比較
return math.isclose(self.x, other.x, rel_tol=1e-9) and \
math.isclose(self.y, other.y, rel_tol=1e-9)
-
compare=False
でキャッシュを比較対象外に。 -
frozen=True
なのでハッシュ可能、集合や辞書のキーに安全に使える。
4.2 IDベース等価+表示名は無関係
from dataclasses import dataclass, field
@dataclass(eq=True)
class User:
id: int
name: str
email: str
# 等価判定から除外(IDだけで等価とみなす)
_display: str = field(init=False, repr=False, compare=False)
def __post_init__(self):
self._display = f"{self.name} <{self.email}>"
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.id == other.id
4.3 受け取りだけする一時値(InitVar
)
from dataclasses import dataclass, field, InitVar
@dataclass
class Record:
raw: str
strip: InitVar[bool] = True
normalized: str = field(init=False)
def __post_init__(self, strip):
self.normalized = self.raw.strip() if strip else self.raw
5. よくある落とし穴とチェックリスト
5.1 ありがちミスの比較表
症状 | 原因 | 対策 |
---|---|---|
TypeError: unhashable type |
eq=True かつ frozen=False
|
キーにするなら frozen=True か unsafe_hash=True (推奨は前者) |
集合キーに入れた後で値を更新して壊れる | 可変なのにハッシュ化 | ハッシュ化しない/不変にする/キーに使わない |
リスト/辞書の既定値が共有される |
default=[] や default={} を書いた |
default_factory=list / dict に置換 |
異型比較で期待外れ | 型違いでも False を返した |
NotImplemented を返す作法に直す |
order=True で変な順序 |
意味のない順序比較を自動生成 | ドメインの順序があるか再検討。必要なら自前で実装 |
slots=True で動的代入に失敗 |
__slots__ が属性追加を禁止 |
設計として動的属性をなくすか、slots を外す |
5.2 ミュータブル既定値の正しい書き方
from dataclasses import dataclass, field
@dataclass
class Bag:
items: list[str] = field(default_factory=list) # これが正解
5.3 変更不能と後処理の相性
-
frozen=True
でも__post_init__
内だけはobject.__setattr__
で派生値を設定可能。 - 逆に、コンストラクト後に外部からフィールドを書き換える必要があるなら
frozen=False
を選ぶ。
6. まとめと最小テンプレ
「まず dataclass で“事実”を宣言し、__post_init__
で“整える”。“等価の意味”は必要なときだけ __eq__
で定義する。」これが実務で壊れにくい方針です。
6.1 最小テンプレ(値オブジェクト)
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Value:
a: int
b: int
_derived: int = field(init=False, repr=False, compare=False)
def __post_init__(self):
if self.a < 0 or self.b < 0:
raise ValueError("a, b >= 0")
object.__setattr__(self, "_derived", self.a + self.b)
6.2 最小テンプレ(近似等価)
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class FloatBox:
v: float
def __eq__(self, other):
if not isinstance(other, FloatBox):
return NotImplemented
return math.isclose(self.v, other.v, rel_tol=1e-9)
6.3 判断基準の一言まとめ(表)
判断ポイント | こう書く | なぜ |
---|---|---|
「初期化を安全に、最小コードで」 |
@dataclass + field
|
自動生成でバグ面積を減らす |
「等価は意味で決めたい」 |
__eq__ を自前実装 |
近似/ID比較/異型対応を表現 |
「初期化後に整える」 | __post_init__ |
正規化/検証/派生値を一括 |
「ハッシュに使いたい」 | frozen=True |
不変+整合した __hash__ を自動 |
実務では「自前で書く前に dataclass で表現できないか」を常に検討し、__post_init__
に“整える責務”を寄せ、__eq__
ではドメインの等価を明示しましょう。これだけでクラス設計はかなり頑健になります。