Pythonのattributeは面白い！

# はじめに

Pythonに再入門しました。
いざ、DDDをベースにコードを書き進めると、当時は見えなかった面白さが目についたので、基礎的なポイントをご紹介します。

# Pythonのattributeは外部から追加できる

サンプルのコードは以下の通りです。

```python:sample1.py
class Employee:
    def __init__(self, name, entry_date):
        self.__name = name
        self.__entry_date = entry_date
    
    @property
    def name(self):
        return self.__name

    @property
    def entry_date(self):
        return self.__entry_date
    
emp = Employee("John Doe", "2001-04-01")
print("Employee Name:", emp.name, "/Entry Date:", emp.entry_date)

emp.retire_date = "2024-03-31"
print("Retire Date:", emp.retire_date)
```

ポイントは最後から2行目。コンストラクタに存在しない「何か」に値を代入しています。
このスクリプトを実行すると、以下のような出力が得られます。

```shell:zsh
% python sample1.py
Employee Name: John Doe /Entry Date: 2001-04-01
Retire Date: 2024-03-31
```

なんと、エラーは発生せずに`Employee`インスタンスの`retire_date`に値が設定されています。
この辺は、古いバージョンのPHPを触っていた人ならば何となく理解できるポイントだと思いますが、動的に属性（PHPでいうところのプロパティ）を増やせることは、珍しいことではないのかもしれません。

:::note
PHPではバージョン8.2から動的にプロパティを追加することが非推奨となりました。
https://wiki.php.net/rfc/deprecate_dynamic_properties
:::

Pythonでも同じように「外部から属性を追加できないように制御したい！」という場合は、以下のように`__slots__`節を記述することで、外部から属性を追加できないようになります。

```diff_python:sample1.py
class Employee:
    def __init__(self, name, entry_date):
        self.__name = name
        self.__entry_date = entry_date

+    __slots__ = ['__name', '__entry_date']

    @property
    def name(self):
        return self.__name

    @property
    def entry_date(self):
        return self.__entry_date
    
emp = Employee("John Doe", "2001-04-01")
print("Employee Name:", emp.name, "/Entry Date:", emp.entry_date)

emp.retire_date = "2024-03-31"
print("Retire Date:", emp.retire_date)
```

このコードを実行すると、以下のような結果になります。

```shell:zsh
% python sample1.py
Employee Name: John Doe /Entry Date: 2001-04-01
Traceback (most recent call last):
  File "/Users/yoda/python/sample1.py", line 19, in <module>
    emp.retire_date = "2024-03-31"
    ^^^^^^^^^^^^^^^
AttributeError: 'Employee' object has no attribute 'retire_date'
```

きちんと`retire_date`は存在しない属性として、エラーを返すようになりました。

# Pythonのattributeは代入するまで「存在しない」

続いてのサンプルコードはこちら。

```python:sample2.py
class Employee:
    def __init__(self, name, entry_date):
        self.__name = name
        self.__entry_date = entry_date

    __slots__ = ['__name', '__entry_date', '__retire_date']

    @property
    def name(self):
        return self.__name

    @property
    def entry_date(self):
        return self.__entry_date

    @property
    def retire_date(self):
        return getattr(self, '__retire_date', None)

    @entry_date.setter
    def entry_date(self, value):
        if self.__retire_date is not None:

            if value > self.__retire_date:
                raise ValueError("Entry date cannot be after retire date.")

        self.__entry_date = value

    @retire_date.setter
    def retire_date(self, value):
        if value < self.__entry_date:
            raise ValueError("Retire date cannot be before entry date.")
        self.__retire_date = value

emp = Employee("John Doe", "2001-04-01")
emp.retire_date = "2024-03-31"
emp.entry_date = "2000-01-01"

print("Employee Name:", emp.name, "/Entry Date:", emp.entry_date, "/Retire Date:", emp.retire_date)

emp2 = Employee("Jane Smith", "2005-06-15")
emp2.entry_date = "2004-01-01"

print("Employee Name:", emp2.name, "/Entry Date:", emp2.entry_date, "/Retire Date:", emp2.retire_date)
```

期待しているのは、`retire_date`は初期状態で設定されておらず、インスタンス生成後にセッター経由で値を設定する必要があること、`entry_date`の値を再設定する場合、`retire_date`設定値との相関をチェックし、日付が逆転しないよう制御されていること、という仕様にしています。
なので、上記コードの`emp.entry_date = "2000-01-01"`と、`emp2.entry_date = "2004-01-01"`は、いずれもエラーが発生しないことを望みます。

それでは、実際にコードを実行すると、以下の通りになります。

```shell:zsh
% python sample2.py
Employee Name: John Doe /Entry Date: 2000-01-01 /Retire Date: None
Traceback (most recent call last):
  File "/Users/yoda/python/sample2.py", line 42, in <module>
    emp2.entry_date = "2004-01-01"
    ^^^^^^^^^^^^^^^
  File "/Users/yoda/python/sample2.py", line 22, in entry_date
    if self.__retire_date is not None:
       ^^^^^^^^^^^^^^^^^^
AttributeError: 'Employee' object has no attribute '_Employee__retire_date'. Did you mean: '_Employee__entry_date'?
```

`emp`は期待通りの結果となりましたが、`emp2`でエラーが発生します。
エラーメッセージの通り、Pythonのattributeは、例え`__slots__`節で定義していても、代入するまでは存在しない扱いとなるのです。

よって、期待通り動作させるためには、コンストラクタで明示的に`None`を代入するよう変更します。

```diff_python:sample2.py
class Employee:
    def __init__(self, name, entry_date):
        self.__name = name
        self.__entry_date = entry_date
+        self.__retire_date = None

    __slots__ = ['__name', '__entry_date', '__retire_date']

    @property
    def name(self):
        return self.__name

    @property
    def entry_date(self):
        return self.__entry_date

    @property
    def retire_date(self):
        return getattr(self, '__retire_date', None)

    @entry_date.setter
    def entry_date(self, value):
        if self.__retire_date is not None:

            if value > self.__retire_date:
                raise ValueError("Entry date cannot be after retire date.")

        self.__entry_date = value

    @retire_date.setter
    def retire_date(self, value):
        if value < self.__entry_date:
            raise ValueError("Retire date cannot be before entry date.")
        self.__retire_date = value

emp = Employee("John Doe", "2001-04-01")
emp.retire_date = "2024-03-31"
emp.entry_date = "2000-01-01"

print("Employee Name:", emp.name, "/Entry Date:", emp.entry_date, "/Retire Date:", emp.retire_date)

emp2 = Employee("Jane Smith", "2005-06-15")
emp2.entry_date = "2004-01-01"

print("Employee Name:", emp2.name, "/Entry Date:", emp2.entry_date, "/Retire Date:", emp2.retire_date)
```
上記修正後の実行結果は以下の通り。エラーは発生しなくなります。
```shell:zsh
% python sample2.py
Employee Name: John Doe /Entry Date: 2000-01-01 /Retire Date: None
Employee Name: Jane Smith /Entry Date: 2004-01-01 /Retire Date: None
```

# まとめ

静的型付けされていない言語は、ところどころに独特な挙動があるので、触っていて面白い一方で、オブジェクト指向開発する場合は予期せぬ落とし穴があちこちにありそうです。
新しい言語を学ぶ際は、イミュータブルなクラス（言語によってはレコード）を実際に一つ作ってみて、それを破壊できるかどうか試行錯誤してみると面白いかもしれません。