中学英語808712 views
いろは2986023 views
高校日本史189857 views
ヒストリア284143 views
教育148875 views
中学社会667106 views
高校物理158224 views
高校国語785655 views
数学講師2852771 views
MathPython491378 views
Help
Tools

English

frozen=True でイミュータブルなデータクラスを作る|Python

Python の dataclass はデフォルトでミュータブルだ。インスタンス生成後にフィールドを自由に書き換えられる。しかし、設定値や座標のように「一度作ったら変更されるべきでない」データも多い。そうしたケースで使うのが frozen=True オプションである。

frozen=True の基本

@dataclass(frozen=True) を指定すると、インスタンスのフィールドへの代入が禁止される。値を変更しようとすると FrozenInstanceError が発生する。

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
print(p.x)  # 1.0

p.x = 3.0  # FrozenInstanceError

内部的には __setattr____delattr__ がオーバーライドされ、属性の変更・削除を一律にブロックする仕組みになっている。通常の dataclass が生成する __setattr__ をそのまま使うのではなく、例外を送出するバージョンに差し替えているわけだ。

通常の dataclass との比較

frozen を指定しない場合と比べると、振る舞いが明確に変わる。

通常の dataclass

フィールドへの再代入が自由にできる。辞書のキーや集合の要素には使えない。__hash__ はデフォルトで None になる。

frozen=True

フィールドへの再代入で FrozenInstanceError が発生する。__hash__ が自動生成されるため、辞書のキーや集合の要素として使える。

この違いは設計上の判断に直結する。データが変更されないことをコードレベルで保証できるのは、バグの予防において大きな意味を持つ。

ハッシュ可能になる利点

frozen なデータクラスは __hash__ が自動生成される。これにより、dict のキーや set の要素として使えるようになる。

from dataclasses import dataclass

@dataclass(frozen=True)
class Color:
    r: int
    g: int
    b: int

palette = {
    Color(255, 0, 0): "red",
    Color(0, 128, 0): "green",
    Color(0, 0, 255): "blue",
}

print(palette[Color(255, 0, 0)])  # red

unique_colors = {Color(255, 0, 0), Color(0, 0, 255), Color(255, 0, 0)}
print(len(unique_colors))  # 2

通常の dataclass でこれをやると TypeError: unhashable type になる。ハッシュ可能であることは、キャッシュやルックアップテーブルを構築するときにも重要だ。

値を変更したい場合は replace を使う

frozen なインスタンスは変更できないが、一部のフィールドだけ変えた新しいインスタンスを作ることはできる。dataclasses.replace を使えばよい。

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Config:
    host: str
    port: int
    debug: bool

base = Config(host="localhost", port=8080, debug=False)
dev = replace(base, debug=True)

print(base)  # Config(host='localhost', port=8080, debug=False)
print(dev)   # Config(host='localhost', port=8080, debug=True)
print(base is dev)  # False

元のインスタンスはそのまま残り、変更箇所だけ反映された別のオブジェクトが生成される。関数型プログラミングでいう「永続データ構造」に近い考え方で、状態の変遷を追跡しやすくなる。

post_init での初期化

frozen なデータクラスでは __post_init__ 内であっても通常の代入はできない。object.__setattr__ を使って直接書き込む必要がある。

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Circle:
    radius: float
    area: float = field(init=False)

    def __post_init__(self):
        object.__setattr__(self, "area", 3.14159 * self.radius ** 2)

c = Circle(radius=5.0)
print(c.area)  # 78.53975

self.area = ... と書くと FrozenInstanceError になるため、object.__setattr__ で Python のオブジェクトシステムを直接叩く。これは frozen dataclass における定番のパターンである。

この object.<span class="rollpie-article-note__mark">setattr</span> による書き込みは <span class="rollpie-article-note__mark">post_init</span> 内でのみ使うべきで、外部から呼び出すのは frozen の意図に反する。あくまで初期化時の計算フィールド設定に限定するのが望ましい。

生成後に外部から object.setattr を使えば変更は可能だが、それは設計上の契約違反になる。

frozen と継承

frozen な dataclass を継承する場合、子クラスも frozen でなければならない。frozen と非 frozen の混在は許可されていない。

from dataclasses import dataclass

@dataclass(frozen=True)
class Base:
    x: int

@dataclass(frozen=True)
class Child(Base):
    y: int

c = Child(x=1, y=2)
print(c)  # Child(x=1, y=2)

もし子クラスで frozen=True を外すと TypeError が発生する。

from dataclasses import dataclass

@dataclass(frozen=True)
class Base:
    x: int

@dataclass  # frozen=True がない
class Child(Base):
    y: int

# TypeError: cannot inherit frozen dataclass from a non-frozen one

frozen=True の dataclass を継承するとき、子クラスはどうするべきか?

  • frozen を指定しなくてよい
  • 子クラスにも frozen=True を指定する
  • eq=False を指定する
  • __hash__ を手動で定義する
__RESULT__

frozen な親クラスを継承する場合、子クラスにも frozen=True が必要です。frozen と非 frozen の混在は TypeError になります。

frozen を使うべき場面

frozen dataclass が特に効果を発揮するのは、次のような場面だ。

設定値や定数の表現

アプリケーション設定、接続情報、環境変数をまとめたオブジェクトなど。一度読み込んだら変更されるべきでないデータに適している。

辞書のキーや集合の要素

ハッシュ可能であることが求められる場面。複合キー(複数のフィールドを組み合わせた一意識別子)としても使える。

マルチスレッド環境

イミュータブルなオブジェクトはスレッド間で安全に共有できる。ロックなしで読み取りが可能になるため、並行処理のコードが簡潔になる。

一方で、頻繁にフィールドを更新するようなデータには向かない。毎回 replace で新しいインスタンスを作るのはオーバーヘッドになるため、そうしたケースでは通常の dataclass を使うほうが合理的である。設計段階で「このデータは変更されるか」を判断し、適切に使い分けることが重要だ。