python3
Descriptor

Pythonのディスクリプタ(descriptor)の応用例

はじめに

前回の続きとして、今回はまずdescriptorの応用例を一個紹介します。
あくまでも個人の見解なので、間違いがあったらご容赦ください。

descriptorの応用例の前に

映画の情報を管理するコードを書くとしましょう。

class Movie:
    def __init__(self, title, rating, runtime, budget, gross):
        self.title = title
        self.rating = rating
        self.runtime = runtime
        self.budget = budget
        self.gross = gross

    def profit(self):
        return self.gross - self.budget

このコードは問題があって、例えば、映画の点数がマイナスになってはいけません。しかし、このコードはそういう例外処理をしていません。
最初に思い浮かべる修正方法は次のようになります。

class Movie:
    def __init__(self, title, rating, runtime, budget, gross):
        self.title = title
        if rating < 0:
            raise ValueError(f"Negative value not allowed: {rating}")
        self.rating = rating
        self.runtime = runtime
        self.budget = budget
        self.gross = gross

    def profit(self):
        return self.gross - self.budget

これは、インスタンス化するとき、値がマイナスであるかどうかをチェックします。もしマイナスでれば、例外をおこします。
しかし、これも不十分です、なぜかというと、初期化した後の修正は自由です。
例えば、次のようにやると

m = Movie("SWars", 1, 2, 3, 4)
m.rating = -10
print(m.rating) # -10

となります。

これは好ましくないですね。このような問題に対しては、ratingという属性に対してget/setメソッドを書くことで解決できます。しかし、それはPythonicなやり方ではありません。

ある程度Pythonについて詳しい方であれば、ここで@propertyを使えばいいじゃんと思い浮かべるでしょう。

そうですね、次のように、@propertyを使うと非常にきれいなコードが書けます。

class Movie:
    def __init__(self, title, rating, runtime, budget, gross):
        self.title = title
        self.rating = rating
        self.runtime = runtime
        self.budget = budget
        self.gross = gross

    def profit(self):
        return self.gross - self.budget

    @property
    def rating(self):
        """
        動的に._ratingを追加する
        """
        return self._rating

    @rating.setter
    def rating(self, value):
        if value < 0:
            raise ValueError(f"Negative value not allowed: {value}")
        self._rating = value

こうすることで、インスタンス化するときも、後で値を修正しようと思うときも、値のチェックをしてくれるので、非常に便利です。

m = Movie("SWars", 1, 2, 3, 4)
try:
    m.rating = -1
except ValueError as e:
    print(e)

# Negative value not allowed: -1

try:
    Movie("SWars", -10, 2, 3, 4)
except ValueError as e:
    print(e)

# Negative value not allowed: -10

しかし、この@propertyも一個だけよくないところがあります。それは、違う属性に対して、重複利用できないという点です。
ここの例で言うと、rating, budget, runtime, grossのそれぞれに@propertyを定義する必要があります。
実際に書くと、以下のようになるので、非常にみにくいです。

class Movie:
    def __init__(self, title, rating, runtime, budget, gross):
        self.title = title
        self.rating = rating
        self.runtime = runtime
        self.budget = budget
        self.gross = gross

    def profit(self):
        return self.gross - self.budget

    @property
    def rating(self):
        """
        動的に._ratingを追加する
        """
        return self._rating

    @rating.setter
    def rating(self, value):
        if value < 0:
            raise ValueError(f"Negative value {value} not allowed for rating ")
        self._rating = value

    @property
    def runtime(self):
        """
        動的に._runtimeを追加する
        """
        return self._runtime

    @runtime.setter
    def runtime(self, value):
        if value < 0:
            raise ValueError(f"Negative value {value} not allowed for runtime ")
        self._runtime = value

    @property
    def gross(self):
        """
        動的に._grossを追加する
        """
        return self._gross

    @gross.setter
    def gross(self, value):
        if value < 0:
            raise ValueError(f"Negative value {value} not allowed for gross ")
        self._gross = value


    @property
    def budget(self):
        """
        動的に._budgetを追加する
        """
        return self._budget

    @budget.setter
    def budget(self, value):
        if value < 0:
            raise ValueError(f"Negative value {value} not allowed for budget ")
        self._budget = value

動作確認してみると、確かに予想通りに動きます。

m = Movie("SWars", 1, 2, 3, 4)
try:
    m.budget= -1
except ValueError as e:
    print(e)

# Negative value not allowed: -1

try:
    Movie("SWars", 1, -2, 3, 4)
except ValueError as e:
    print(e)

# Negative value -2 not allowed for runtime

このようにして、Movie()の外部からは使いやすいですが、実は内部は重複しているロジックが多くて、とてもきれいなコードと言えません。

@propertyの拡張版としてカスタムdescriptorを使おう

@propertyの拡張というのは、上記のような値チェックのロジックを満たすようなクラスを作ることです。

今回は以下のようなデータディスクリプタ/data descriptor(前回の記事を参照)を使います。

from weakref import WeakKeyDictionary

class NonNegative:
    def __init__(self, name=None):
        self.name = name
        self.data = WeakKeyDictionary()

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.data.get(instance, 0)

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError(f"Negative value {value} not allowed for {self.name}")
        self.data[instance] = value


class Movie:
    rating = NonNegative("rating")
    runtime = NonNegative("runtime")
    budget = NonNegative("budget")
    gross = NonNegative("gross")

    def __init__(self, title, rating, runtime, budget, gross):
        self.title = title
        self.rating = rating
        self.runtime = runtime
        self.budget = budget
        self.gross = gross

    def profit(self):
        return self.gross - self.budget

同じく動作確認をすると、無事動きました。

m = Movie("SWars", 1, 2, 3, 4)
try:
    m.gross = -10 
except ValueError as e:
    print(e)
# Negative value -10 not allowed for gross

try:
    Movie("SWars", 1, 2, -3, 4)
except ValueError as e:
    print(e)

# Negative value -3 not allowed for budget

おわりに

今回はとりあえずdesicriptorのカスタム応用例を一個説明しました。実際、djangoを触ったことのある方はこういう書き方を見たことはあるでしょう。descriptorはPythonにおいては非常に重要な存在です。それをぜひマスターしていただればと思います。