Pythonのclassで値をvalidationする方法4選

# はじめに
「[良いコード/悪いコードで学ぶ設計入門](https://amzn.to/3PQiNuu)」を読んでから、値のバリデーションについて意識するようになりました。Pythonで早速クラスのバリデーションについて調べてみると色々と方法があったので、それぞれ動かしてみました。


# バリデーションを行う方法
### 方法１. クラス内にバリデーション用の関数を作る
```python
class Gadget:
    def __init__(self, name, price, brand):
        self.name = self._validate_name(name)
        self.price = self._validate_price(price)
        self.brand = brand

    def _validate_name(self, name):
        if len(name) > 15:
            raise ValueError("name cannot exceed 15 characters.")
        return name

    def _validate_price(self, price):
        if price < 0:
            raise ValueError("Price cannot be negative.")
        return price


if __name__ == "__main__":
    MBP15 = Gadget(
        name="MacBookPro15",
        price=150000,
        brand="Apple"
    )
    TP = Gadget(
        name="ThinkPadX1Carbon",
        price=120000,
        brand="lenovo"
    )
    # ValueError: name cannot exceed 15 characters.
    foo = Gadget(
        name="Something",
        price=-111,
        brand=None
    )
    # ValueError: Price cannot be negative.
```
方法1はクラス内にバリデーション用の関数を作る方法です。
この方法はシンプルに見えますが、問題があります。mainの中を以下のように書き換え、実行してみてください。
```python
if __name__ == "__main__":
    MBP15 = Gadget(
        name="MacBookPro15",
        price=150000,
        brand="Apple"
    )
    MBP15.name = "GadgetNameLengthIsMoreThan15."
    print(MBP15.name)
```
本来であれば`name`には15文字以上入れたくないのですが、上記は問題なく実行できてしまいます。
さらに`__init__`関数の中がシンプルなことを好む人が多いのでこの方法は基本なしと感じました。

また、`@dataclass`を使ったバリデーション方法もありますが、上記のように値を更新するときにバリデーションが行われないそうです。

### 方法2. `@property`を使う
```python
class Gadget:
    def __init__(self, name, price, brand):
        self.name = name
        self.price = price
        self.brand = brand

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

    @name.setter
    def name(self, value):
        if len(value) > 15:
            raise ValueError("name cannot exceed 15 characters.")
        self._name = value

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative.")
        self._price = value


if __name__ == "__main__":
    MBP15 = Gadget(
        name="MacBookPro15",
        price=150000,
        brand="Apple"
    )
    TP = Gadget(
        name="ThinkPadX1Carbon",
        price=120000,
        brand="lenovo"
    )
    # ValueError: name cannot exceed 15 characters.
    foo = Gadget(
        name="Something",
        price=-111,
        brand=None
    )
    # ValueError: Price cannot be negative.
```
Pythonの組み込み関数の`@property`を使った方法は、方法1よりも多くのコードがありますが、`__init__`関数をシンプルにすることが可能です。さらに方法1であった値更新時にエラーが出ない問題もクリアされています。
```python
if __name__ == "__main__":
    MBP15 = Gadget(
        name="MacBookPro15",
        price=150000,
        brand="Apple"
    )
    MBP15.name = "GadgetNameLengthIsMoreThan15."
    print(MBP15.name)
     # ValueError: name cannot exceed 15 characters.
```


### 方法3. Descriptorsを使う
```python
class Name:
    def __get__(self, obj, objtype=None):
        return self.value

    def __set__(self, obj, value):
        if len(value) > 15:
            raise ValueError("name cannot exceed 15 characters.")
        self.value = value


class Price:
    def __get__(self, obj, objtype=None):
        return self.value

    def __set__(self, obj, value):
        if value < 0:
            raise ValueError("Price cannot be negative.")
        self.value = value


class Gadget:

    name = Name()
    price = Price()

    def __init__(self, name, price, brand):
        self.name = name
        self.price = price
        self.brand = brand


if __name__ == "__main__":
    MBP15 = Gadget(
        name="MacBookPro15",
        price=150000,
        brand="Apple"
    )
    TP = Gadget(
        name="ThinkPadX1Carbon",
        price=120000,
        brand="lenovo"
    )
    # ValueError: name cannot exceed 15 characters.
    foo = Gadget(
        name="Something",
        price=-111,
        brand=None
    )
    # ValueError: Price cannot be negative.
```
3つ目の方法は、`__get__`、`__set__`、`__delete__`のメソッド定義する方法です。
この方法で嬉しいことは、例として、`Gadget`クラス以外に`Furniture`クラスを作る時に、バリデーションの記述を再利用できるとことです。
```python
class Furniture:
  
    name = Name()
    price = Price()

    def __init__(self, name, price, brand):
        self.name = name
        self.price = price
        self.brand = brand
```
プロジェクトが巨大になった時にはこの方法が一番重宝しそうだと思いました。


### 方法4. attrsライブラリを使う
```python
from attrs import define, field

@define
class Gadget:

    name: str = field()
    price: int = field()
    brand: str = field()

    @name.validator
    def validate_name(self, attribute, value):
        if len(value) > 15:
            raise ValueError("name cannot exceed 15 characters.")
            
    @price.validator
    def validate_price(self, attribute, value):
        if value < 0:
            raise ValueError("Price cannot be negative.")


if __name__ == "__main__":
    MBP15 = Gadget(
        name="MacBookPro15",
        price=150000,
        brand="Apple"
    )
    TP = Gadget(
        name="ThinkPadX1Carbon",
        price=120000,
        brand="lenovo"
    )
    # ValueError: name cannot exceed 15 characters.
    foo = Gadget(
        name="Something",
        price=-111,
        brand=None
    )
    # ValueError: Price cannot be negative.
```
外部ライブラリ`attr`を使う方法です。詳しくは[ライブラリのドキュメント](https://www.attrs.org/en/stable/examples.html)を読むのが良いのですが、クラスの中にデコレータを作ってバリデーションを行うことができます。

ある程度シンプルに書けますが、デフォルトでも十分バリデーションはできると思ったので方法2,3よりは優先度下がるかなと思います。

# 終わりに
個人的には方法2, 3あたりが`__init__`もシンプルで良さそうだなと思いました。
どんどんバリデーションしてわかりやすいコードを心がけたいと思います。

# 参考
- https://towardsdatascience.com/6-approaches-to-validate-class-attributes-in-python-b51cffb8c4ea
- https://www.attrs.org/en/stable/examples.html