閉じる

Pythonのdataclassをイミュータブルにするfrozen引数の使い方と注意点

Pythonのdataclassは、2018年に導入されて以来、現代的なPython開発において欠かせない標準機能となりました。

特に、データの整合性を保ち、予期せぬ状態変化を防ぐために利用される「イミュータブル(不変)」な設計は、大規模なアプリケーション開発において非常に重要です。

Pythonのdataclassでは、デコレータの引数にfrozen=Trueを指定するだけで、簡単にイミュータブルなオブジェクトを生成できます。

本記事では、このfrozen引数の基本的な使い方から、内部的な動作の仕組み、さらには実務で直面しやすい落とし穴について詳しく解説します。

2026年現在のモダンなPython開発においても、データの信頼性を担保するための基本技術として広く活用されています。

frozen引数の基本的な使い方

dataclassを定義する際、@dataclassデコレータに対してfrozen=Trueを指定することで、そのクラスのインスタンスは作成後に属性を変更できなくなります。

まずは、基本的な実装例を見てみましょう。

Python
from dataclasses import dataclass

@dataclass(frozen=True)
class UserConfig:
    user_id: int
    username: str
    is_admin: bool = False

# インスタンスの生成
config = UserConfig(user_id=1, username="sato_taro")

# 属性の参照は通常通り可能
print(config.username)

# 属性の変更を試みる(エラーが発生する)
try:
    config.username = "tanaka_jiro"
except Exception as e:
    print(f"エラーが発生しました: {type(e).__name__}")
実行結果
sato_taro
エラーが発生しました: FrozenInstanceError

上記のコードからわかるように、frozen=Trueを設定したクラスの属性を書き換えようとすると、FrozenInstanceErrorが送出されます。

これにより、開発者が意図せずにデータを上書きしてしまうミスを未然に防ぐことが可能です。

また、属性の削除を試みた場合にも同様のエラーが発生します。

このように、オブジェクトの状態を固定することで、プログラムの予測可能性を高めることができます。

イミュータブルであることのメリット

なぜ多くの開発者が、わざわざfrozen=Trueを使用して不変なオブジェクトを作るのでしょうか。

それには、大きく分けて3つの強力なメリットがあります。

1. ハッシュ化が可能になる(dictのキーやsetの要素に使える)

通常のdataclassインスタンスは、デフォルトでは「ハッシュ可能(hashable)」ではありません。

しかし、frozen=Trueを指定すると、自動的に__hash__メソッドが生成されます

これにより、インスタンスを辞書(dict)のキーや集合(set)の要素として扱うことができるようになります。

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

# セットの要素として利用可能
points_set = {Point(0, 0), Point(1, 2)}
print(Point(0, 0) in points_set)
実行結果
True

値が同じであれば同じハッシュ値を持つため、データ構造の管理が非常に容易になります。

2. スレッドセーフな設計の助けになる

並列処理や非同期処理を行うプログラムにおいて、複数のスレッドから共有されるオブジェクトの状態が変化することは、バグの温床となります。

イミュータブルなオブジェクトは、一度生成されたら状態が変わらないことが保証されているため、排他制御(ロック)などの複雑な考慮を減らすことができます。

「状態を持たない(あるいは変わらない)」という性質は、安全なマルチスレッドプログラミングの基礎となります。

3. デバッグの容易性とコードの堅牢性

関数の引数として渡されたオブジェクトが、関数内部で勝手に書き換えられる心配がありません。

「どこで値が変わったのかわからない」というデバッグの苦労を、設計レベルで排除できるのは大きな利点です。

frozenが「完全な不変」ではない点に注意

ここで、初心者が最も陥りやすい重要な注意点について説明します。

frozen=Trueは、あくまで「そのインスタンス自身の属性」への再代入を防ぐものに過ぎません。

オブジェクトが参照している中身(ミュータブルなリストなど)までは保護されないという点です。

この現象を「シャロー(浅い)なイミュータブル」と呼ぶこともあります。

Python
from dataclasses import dataclass
from typing import List

@dataclass(frozen=True)
class Team:
    members: List[str]

my_team = Team(members=["Alice", "Bob"])

# 属性そのものの入れ替えはエラーになる
# my_team.members = ["Charlie"]  # これはFrozenInstanceError

# しかし、リストの中身は変更できてしまう
my_team.members.append("Charlie")

print(my_team.members)
実行結果
['Alice', 'Bob', 'Charlie']

上記の通り、membersというリスト自体に要素を追加することは可能です。

本当の意味で完全なイミュータブルを実現したい場合は、リストの代わりにtuple(タプル)を使用するなどの工夫が必要です。

「frozenは再代入を防ぐが、参照先の変更までは防げない」というルールは、実務において必ず覚えておくべきポイントです。

内部実装の仕組み:どのように禁止しているのか

Pythonがどのようにして属性の変更を禁止しているのかを知ることは、トラブルシューティングに役立ちます。

実は、frozen=Trueを指定すると、クラスに特殊メソッドである__setattr____delattr__が自動的に定義されます。

これらのメソッドが呼び出されると、無条件でFrozenInstanceErrorを投げるように実装されているのです。

具体的には、以下のような仕組みに近い動作が背後で行われています。

Python
def __setattr__(self, name, value):
    raise FrozenInstanceError(f"cannot assign to field {name}")

ただし、初期化時(__init__内)だけは値を設定する必要があります。

そのため、dataclassは内部的にobject.__setattr__を直接呼び出すことで、制限をバイパスして初期値を設定しています。

この仕組みを知っていると、パフォーマンス面でわずかなオーバーヘッドがある理由も理解しやすくなります。

frozen dataclassの継承におけるルール

継承を利用する場合、frozenの設定には厳格なルールが存在します。

以下の表に、親クラスと子クラスの組み合わせにおける可否をまとめました。

親クラスの状態子クラスの状態判定
frozen=Falsefrozen=False○ 許可
frozen=Truefrozen=True○ 許可
frozen=Truefrozen=False× 継承不可(TypeError)
frozen=Falsefrozen=True× 継承不可(TypeError)

つまり、「frozenの有無を混ぜて継承することはできない」ということです。

親がfrozenなら子もfrozenである必要があり、その逆も同様です。

これは、一貫性のないオブジェクト構造が作られるのを防ぐための仕様上の制約です。

名前付きタプル(NamedTuple)との比較

イミュータブルなデータ構造を作成する手段として、古くからあるcollections.namedtupleや、型ヒントに対応したtyping.NamedTupleがあります。

これらとfrozen dataclassにはどのような違いがあるのでしょうか。

主な違いは、「タプルとしての性質を持つかどうか」です。

  • NamedTuple: タプルのサブクラスであるため、インデックス(obj[0])でのアクセスや、イテレーションが可能です。
  • frozen dataclass: 純粋なクラスであり、タプルの性質は持ちません。

また、デフォルト値の設定やメソッドの定義、継承の柔軟性においては、dataclassの方が優れています。

基本的には、単なる値の入れ物としてタプルのように扱いたい場合はNamedTupleを、よりリッチな振る舞いを持たせたい場合はfrozen dataclassを選択するのが一般的です。

パフォーマンスに関する考察

frozen=Trueを使用すると、通常のdataclassと比較して、インスタンス生成時の速度がわずかに低下します。

前述の通り、内部的にobject.__setattr__を経由して値を設定する特別なステップが必要になるためです。

しかし、この差はミリ秒以下の極めて小さなものであり、ほとんどのアプリケーションにおいて問題になることはありません

むしろ、イミュータブルにすることによるバグ削減やメンテナンス性の向上というメリットの方が、実行速度のわずかな低下よりも遥かに価値があると言えます。

もし、数千万個のインスタンスを生成するようなクリティカルなループ処理を行う場合は、計測を行った上で判断してください。

frozen dataclassを更新する方法(replace関数の活用)

イミュータブルなオブジェクトの一部だけを変更した新しいインスタンスが欲しい場合、どのようにすればよいでしょうか。

属性を直接変更することはできないため、標準ライブラリのdataclasses.replace関数を使用します。

Python
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Player:
    id: int
    score: int
    level: int

player1 = Player(id=101, score=500, level=5)

# scoreだけを更新した新しいインスタンスを作成
player2 = replace(player1, score=600)

print(player1)
print(player2)
実行結果
Player(id=101, score=500, level=5)
Player(id=101, score=600, level=5)

replace関数を使うことで、元のオブジェクト(player1)を壊さずに、変更後のデータを保持する新しいオブジェクトを得ることができます。

これは、関数型プログラミングの手法を取り入れたい場合に非常に有効なパターンです。

まとめ

Pythonのdataclassにおけるfrozen=Trueは、データの堅牢性を高めるための非常に強力なオプションです。

設定一つでインスタンスを不変にし、ハッシュ可能にすることで、バグの混入を防ぎつつ、データ構造の自由度を広げることができます。

ただし、リストや辞書などのミュータブルなオブジェクトがフィールドに含まれる場合は、その中身まで保護されない点には十分に注意が必要です。

継承時のルールやreplace関数の使い方をマスターすることで、より安全でクリーンなPythonコードを書くことができるようになります。

近年の開発トレンドである「副作用の最小化」を実現するためにも、積極的にfrozen引数を活用していきましょう。

URLをコピーしました!