30
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

@dataclass(frozen=True) の注意点

Last updated at Posted at 2022-08-28

概要

@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)を使うと、必ず全てのインスタンス変数が不変になるわけではないことに注意しましょう。

30
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?