概要
@dataclass(frozen=True)
を使うことで、イミュータブルなデータクラスを実現することが出来ます。
from dataclasses import dataclass, field
@dataclass(frozen=True)
class User:
name: str
age: int
hobbies: list[str] = field(default_factory=list)
def main():
user_info = User("Alice", 20, ["Programming", "Sports"])
# 下記は`FrozenInstanceError`が返される -> 代入不可
# user_info.name = "Bob"
# user_info.age = 15
# user_info.hobbies = ["Sleeping"]
if __name__ == "__main__":
main()
このように見ると不変として扱ってくれるように見えますが、この中に罠が存在しています…
ミュータブル型を使わない
frozen=True
を使うことは、インスタンス変数の書き換えを防止したいという開発者の思考が読み取れますが、ミュータブル型を定義してしまうと意図しない実装になってしまう可能性があります。
上記のコードを用いていくつか例を見てみましょう。
1.値の追加が出来る
from dataclasses import dataclass, field
@dataclass(frozen=True)
class User:
name: str
age: int
hobbies: list[str] = field(default_factory=list)
def main():
user_info = User("Alice", 20, ["Programming", "Sports"])
user_info.hobbies.append("Sleeping")
print(user_info.hobbies) # ['Programming', 'Sports', 'Sleeping']
if __name__ == "__main__":
main()
エラーにならず値の追加が出来てしまいます。
理屈は後述しますが、この時点で不変ではなくなってしまうことが分かります。
2. 値の削除が出来る
from dataclasses import dataclass, field
@dataclass(frozen=True)
class User:
name: str
age: int
hobbies: list[str] = field(default_factory=list)
def main():
user_info = User("Alice", 20, ["Programming", "Sports"])
user_info.hobbies.remove("Sports")
print(user_info.hobbies) # ['Programming']
if __name__ == "__main__":
main()
今回はremove
を使いましたがpop
でも同様の結果になります。
ただしインスタンス変数自体を削除するdel user_info.hobbies
はエラーになります(FrozenInstanceError
)。
3.値の書き換えが出来る
from dataclasses import dataclass, field
@dataclass(frozen=True)
class User:
name: str
age: int
hobbies: list[str] = field(default_factory=list)
def main():
user_info = User("Alice", 20, ["Programming", "Sports"])
user_info.hobbies[0] = "Sleeping"
print(user_info.hobbies) # ['Sleeping', 'Sports']
if __name__ == "__main__":
main()
こうして見るとfrozen=True
が仕事をしていないように見えますね…
なぜ起きてしまうのか
ドキュメントを参照すると、
真に不変な Python のオブジェクトを作成するのは不可能です。 しかし、 frozen=True を dataclass() デコレータに渡すことで、不変性の模倣はできます。 このケースでは、データクラスは
__setattr__()
メソッドと__delattr__()
メソッドをクラスに追加します。 これらのメソッドは起動すると FrozenInstanceError を送出します
__setattr__
と__delattr__
がエラーを検知するポイントとなっており、この2つは上記の処理では呼び出されないため通ってしまいます。
簡単にこのダンダーメソッドについておさらいすると、
-
__setattr__
: 値の代入時に呼び出される -
__delattr__
: 変数の削除時に呼び出される
なので、概要で書いたコードや少し触れたdel user_info.hobbies
はエラーとなります。
じゃあ、なぜuser_info.hobbies[0] = "Sleeping"
はエラーにならないのでしょうか?
list
の要素の値の設定の場合は、list
自身が持つ__setitem__
が呼び出されるためです。
対策
イミュータブル型を使いましょう。
~ 完 ~
おまけ
実はイミュータブル型であっても値を書き換える方法が存在します。
自メソッドの__setattr__
を使わず、親クラスの__setattr__
を使って書き換える方法です。
下記のプログラムで、イミュータブル型で定義されているname
を書き換えています。
from dataclasses import dataclass, field
@dataclass(frozen=True)
class User:
name: str
age: int
hobbies: list[str] = field(default_factory=list)
def ChangeName(self, new_name: str) -> None:
object.__setattr__(self, "name", new_name)
def main():
user_info = User("Alice", 20, ["Programming", "Sports"])
user_info.ChangeName("Bob")
print(user_info) # User(name='Bob', age=20, hobbies=['Programming', 'Sports'])
if __name__ == "__main__":
main()
正しい使い方(?)としては、__post_init__
で値を格納する際に使用します。
例えば、受け取ったn
の数の要素を持つ配列を定義するプログラムを書いてみます。
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ListGenerator:
n: int
array: list[int] = field(init=False, default_factory=list)
def __post_init__(self):
self.array = [0] * self.n
hoge = ListGenerator(5)
この書き方ではFrozenInstanceError
になってしまいます。
self.array = [0] * self.n
部分が__setattr__
に引っ掛かってしまうためです。
このように__post_init__
などで値を格納したいときに、親クラスの__setattr__
を使用します。
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ListGenerator:
n: int
array: list[int] = field(init=False, default_factory=list)
def __post_init__(self):
object.__setattr__(self, "array", [0]*self.n)
hoge = ListGenerator(5)
print(hoge) # ListGenerator(n=5, array=[0, 0, 0, 0, 0])
うーん、めんどくさすぎる……
終わりに
そもそも値の変更を封じたいのであればイミュータブル型を使うべきなんだけど、「frozen=True
が封じてくれるでしょう!」とミュータブル型を使うと痛い目を見ます。
後は@dataclass(frozen=True)
を使うと、必ず全てのインスタンス変数が不変になるわけではないことに注意しましょう。