デスクリプタ(descriptor)で属性へのアクセスロジックを共通化

この記事は [Pythonのコードを短く簡潔に書くテクニック Advent Calendar 2017](https://qiita.com/advent-calendar/2017/python-short-code) の25日目です。

# はじめに

クラスを定義する際に、複数の属性に対してバリデーションなどの共通ロジックを使いたいという場合があります。

こういう時はデスクリプタ(descriptor)を使うと、同じコードを何度も書くことなく共通ロジックを再利用することができます。

# デスクリプタ(descriptor)とは

デスクリプタは属性へのアクセスロジックを実装したクラスです。

デスクリプタには`__get__`、`__set__`、`__delete__`のメソッド全て又は一部を実装します。

- `def __get__(self, instance, owner):`
    - getterに相当するメソッドで、`instance`から属性値を取得する際に呼ばれます。
- `def __set__(self, instance, value):`
    - setterに相当するメソッドで、`instance`に属性値を設定する際に呼ばれます。
- `def __delete__(self, instance):`
    - `instance`から属性値を削除する際に呼ばれます。

3.6からは以下のメソッドも使えます。

- `def __set_name__(self, owner, name):`
    - デスクリプタをクラス属性に設定した時に属性名が渡されます。

# デスクリプタの簡単な例

デスクリプタの動作がわかるように、各メソッドに`print()`を入れただけの単純なデスクリプタを作成してみます。

```py3:デスクリプタ
class TraceDescriptor:

    def __set_name__(self, owner, name):
        print(f'__set_name__: {name}')
        self.name = name

    def __get__(self, instance, owner):
        value = instance.__dict__[self.name]
        print(f'__get__: {self.name} {value}')
        return value

    def __set__(self, instance, value):
        print(f'__set__: {self.name} {value}')
        instance.__dict__[self.name] = value
```

このデスクリプタを使用するには、以下のようにクラス属性としてデスクリプタのインスタンスを作成します。

```py3:デスクリプタを使用するクラス
class MyClass:
    a = TraceDescriptor()
    b = TraceDescriptor()
```

上記クラスのインスタンスを作成して属性にアクセスすると、デスクリプタのメソッドが呼び出されます。

```py3
instance = MyClass()

instance.a = 123
print(instance.a)

instance.b = 'abc'
print(instance.b)
```

```:実行結果
__set_name__: a
__set_name__: b
__set__: a 123
__get__: a 123
123
__set__: b abc
__get__: b abc
abc
1 2
```

# 型変換を行うデスクリプタ

もう少し実践的な例を考えてみます。

テキストファイルなどからデータを読み込んでオブジェクトにセットするときに、いちいち文字列を特定の型に変換するのは面倒です。

そんな時のために、値を設定する際に指定の型に変換するデスクリプタを作ってみます。

```py3
class TypedField:

    def __init__(self, field_type):
        self.field_type = field_type

    def __set_name__(self, owner, name):
        self.name = name

    def __set__(self, instance, value):
        if not isinstance(value, self.field_type):
            value = self.field_type(value)
        instance.__dict__[self.name] = value
```

デスクリプタのコンストラクタで型を受け取るようにして、`__set__`でその型に変換を行います。

```py3
class MyClass:
    str_value = TypedField(str)
    int_value = TypedField(int)
```

これで値を設定する時に自動的に型を変換してくれます。

```pycon
>>> instance = MyClass()
>>>
>>> instance.str_value = 123
>>> instance.str_value
'123'
>>> type(instance.str_value)
<class 'str'>
>>>
>>> instance.int_value = '123'
>>> instance.int_value
123
>>> type(instance.int_value)
<class 'int'>
```

当然ですが型変換ができない時には`ValueError`が発生します。

```pycon
>>> instance.int_value = 'abc'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 8, in __set__
ValueError: invalid literal for int() with base 10: 'abc'
```

# デスクリプタを使わない場合

参考までにデスクリプタを使わないで書くとこうなります。

```py3
def convert(value, value_type):
    if not isinstance(value, value_type):
        value = value_type(value)
    return value


class MyClass:

    @property
    def str_value(self):
        return self.__dict__['str_value']

    @str_value.setter
    def str_value(self, value):
        value = convert(value, int)
        self.__dict__['str_value'] = value

    @property
    def int_value(self):
        return self.__dict__['int_value']

    @int_value.setter
    def int_value(self, value):
        value = convert(value, int)
        self.__dict__['int_value'] = value
```

属性一つ定義するのにgetterとsetterを実装しなければならず、属性が増えるたびに同じようなコードを書かなくてはなりません。

# 最後に

Pythonドキュメントの [デスクリプタ HowTo ガイド](https://docs.python.jp/3/howto/descriptor.html) には、以下のように書かれています。

> デスクリプタについて学ぶことにより、新しいツールセットが使えるようになるだけでなく、Python の仕組みや、洗練された設計のアプリケーションについてのより深い理解が得られます。

デスクリプタはプロパティ(`@property`)やメソッド(インスタンスメソッド、`@staticmethod`、`@classmethod`）などPythonのクラス定義のコアな部分に使用されている他、Djangoのモデルなどフレームワークの実装でも活用されています。

デスクリプタを使いこなせるかどうかが、Python上級者になるためのひとつの境目のような気がしてきました。

# 参考

- Pythonドキュメント
    - 言語リファレンス
        - 3. データモデル
            - [3.3.2.1. デスクリプタ (descriptor) の実装](https://docs.python.jp/3/reference/datamodel.html#implementing-descriptors)
    - Python HOWTO
        - [デスクリプタ HowTo ガイド](https://docs.python.jp/3/howto/descriptor.html)
- Qiita
    - [ディスクリプタを制する者は Python を制す](https://qiita.com/koshigoe/items/848ddc0272b3cee92134#%E3%83%87%E3%82%A3%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%82%BF)
    - [Python を支える技術 ディスクリプタ編 #pyconjp](https://qiita.com/knzm/items/a8a0fead6e1706663c22)