結論:条件はdataclassへまとめ、可変値はdefault_factoryで作る
実験条件を辞書や多数の引数で渡しているなら、dataclassへまとめると各項目の意味を名前で追いやすい。リストなどの既定値はfield(default_factory=list)でインスタンスごとに作る。変更を抑えたい場合はfrozen=Trueも使えるが、内部のリストまで不変にする指定ではない。どこまで変更を禁止したいかを分けて考える必要がある。
型注釈を書くだけで、渡された値の型や範囲が実行時に検証されるわけではない。repeats: intと書いても文字列を自動で拒否する仕組みにはならないため、外部入力を受けるなら明示的に確認する。dataclassは初期化や表示、比較などの定型コードを減らす道具であり、設定値の検証を全て代行するものではない。
そのまま動かせる例
例には二つのクラスを用意した。Looseはdefault_factoryとfrozenの限界を見るための小さな比較用で、Experimentは条件を検証し、タグをtupleにする例である。標準ライブラリだけで実行でき、ファイルや外部サービスは使わない。インスタンス間の共有がないこと、属性の再代入が拒否されること、不正な値が弾かれることを確認する。
Looseのtagsへappendする操作は、frozenでも内部の可変オブジェクトを変更できることを意図的に示している。実験条件を不変に保ちたい用途へそのまま勧めているわけではない。Experimentでは文字列のtupleへ限定する方針を採り、リストが渡された場合も自動で曖昧に変換せず、エラーとして知らせる。
from dataclasses import dataclass, field, FrozenInstanceError, replace
@dataclass(frozen=True)
class Loose:
tags: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class Experiment:
name: str
repeats: int = 3
tags: tuple[str, ...] = ()
def __post_init__(self):
if not isinstance(self.name, str) or not self.name:
raise ValueError("name must be a nonempty string")
if type(self.repeats) is not int or self.repeats <= 0:
raise ValueError("repeats must be a positive integer")
if type(self.tags) is not tuple or not all(isinstance(x, str) for x in self.tags):
raise ValueError("tags must be a tuple of strings")
a, b = Loose(), Loose()
a.tags.append("changed")
assert a.tags == ["changed"] and b.tags == []
print("separate default lists:", a.tags, b.tags)
cfg = Experiment("trial-A", tags=("baseline",))
updated = replace(cfg, repeats=5)
assert cfg.repeats == 3 and updated.repeats == 5
print("original and replaced repeats:", cfg.repeats, updated.repeats)
try:
cfg.repeats = 9
except FrozenInstanceError:
print("attribute reassignment rejected: FrozenInstanceError")
else:
raise AssertionError("expected frozen assignment rejection")
for bad in [0, True, "3"]:
try:
Experiment("bad", repeats=bad)
except ValueError:
print("invalid repeats rejected:", repr(bad))
else:
raise AssertionError("invalid repeats accepted")
try:
Experiment("bad", tags=["mutable"])
except ValueError:
print("mutable tags rejected: ValueError")
else:
raise AssertionError("list tags accepted")
実行結果
separate default lists: ['changed'] []
original and replaced repeats: 3 5
attribute reassignment rejected: FrozenInstanceError
invalid repeats rejected: 0
invalid repeats rejected: True
invalid repeats rejected: '3'
mutable tags rejected: ValueError
default_factoryで共有を避ける
同じ既定のリストを複数のインスタンスで使うと、ある試料のタグ変更が他の試料にも現れる可能性がある。default_factoryは初期化時に呼ばれるので、別々の空リストを作れる。例ではaへ追加してもbは空のままである。factoryへlist()の結果を渡すのではなく、呼び出せるlist自体を渡す点がポイントとなる。
現在のdataclassは多くの可変な既定値を検出してエラーにするが、全ての共有状態を自動的に安全へ直す仕組みではない。外側で作ったオブジェクトを明示的に二つのインスタンスへ渡せば共有になる。自作クラスや入れ子構造でも、どの値が独立に作られ、どの参照が共通になるかを意識して設計しよう。
frozenは浅い変更防止である
frozen=Trueでは属性への通常の代入や削除が制限される。例のrepeatsへの再代入はFrozenInstanceErrorになる。一方、属性が指すリストのappendは属性そのものの付け替えではないため可能である。不変にしたい構造なら、内部にもtupleやfrozensetなど目的に合う値を選ぶ。tupleの内部にさらに可変オブジェクトがあれば、その部分まで不変とは限らない。
変更した条件を作るときは、dataclasses.replaceで元の値を引き継いだ新しいインスタンスを作れる。元のcfgは3回のまま、新しいupdatedは5回となる。通常の初期化と同じく__post_init__も通るため、変更後の条件も検証できる。ただしこれはセキュリティ境界を提供する機能ではなく、プログラム上の誤変更を減らすための設計である。
検証と保存形式は別に決める
__post_init__では、名前が空でないか、回数が正の整数か、タグが文字列のtupleかを確認している。boolはintの派生なので、真偽値を回数として認めない場合は厳密な型確認が必要になる。数値の上限や単位、項目間の整合性など、実験の契約に応じた条件もここか入力境界で検証しよう。同じ条件をあちこちで別々に検証すると仕様が食い違いやすい。
dataclassになったからJSONやTOMLへ自動的に安全に保存できるわけでもない。asdictで辞書へ変換しても、日時や配列などは保存形式に応じた変換が必要になる。クラスの項目を増減した場合は、古い設定との互換性も考えたい。実際に使った条件を明示的な形式で残し、読込後も検証してからExperimentを作る流れにすると、再解析へつなげやすくなる。
確認環境と参考資料
例はLinux・CPython 3.12.14で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。
- Python公式:dataclasses(2026年10月2日参照)
関連項目:関数を呼ぶたびにリストが増える?可変デフォルト引数を直す / TOML設定ファイルをtomllibで読み込み、入力ミスを早めに見つける / JSONで保存できない値をどう扱う?日時・数値・日本語の受け渡し
