@dataclass(frozen=True) の注意点

## 概要  

`@dataclass(frozen=True)`を使うことで、イミュータブルなデータクラスを実現することが出来ます。  

```python
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.値の追加が出来る
```python
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. 値の削除が出来る  
```python
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.値の書き換えが出来る  
```python
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`が仕事をしていないように見えますね…  

## なぜ起きてしまうのか  
[ドキュメント](https://docs.python.org/ja/3.10/library/dataclasses.html#frozen-instances)を参照すると、
> 真に不変な 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`を書き換えています。  
```python
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`の数の要素を持つ配列を定義するプログラムを書いてみます。  
```python
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__`を使用します。  
```python
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)`を使うと、必ず全てのインスタンス変数が不変になるわけではないことに注意しましょう。