Pythonのdescriptorについて〜〜obj.dは一体どういう処理をしている?

はじめに

前回の続きとして、今回もPythonのクラスについて復習していきたいと思います。今回はdescriptorについて話します。(続編もあるはずです)

あくまでも個人の見解なので、間違いがあったらご容赦ください。

attributeについて

class attributeとinstance attribute

descriptorを説明する前に、まずattributeについて復習しましょう。

例えば、次の例を考えます。

class MyClass:
    a = "class attribute"
    def __init__(self):
        self.b = "instance attribute"

obj = MyClass()

print(obj.a)  # class attribute
print(obj.b)  # instance attribute
print(MyClass.a)  # class attribute
print(MyClass.b)  # AttributeError: type object 'MyClass' has no attribute 'b'

ここで、MyClassというクラスの中に、class attributeのaと_init_の中にinstance attributeのbが定義されている。このようなattributeは属性とも呼ばれます。
MyClassをインスタンス化して、objというインスタンスを作ります。
そうすると、上で書いてあるように、class attributeはインスタンスとクラスの両方からアクセスできますが、instance attributeはインスタンスからしかアクセスできません。

これは当たり前ですね。

では、ちょっと面白いものを見てみましょう。例えば、class attributeとinstance attributeの名前が同じものにすると、何が起きるでしょう?

class MyClass:
    a = "class attribute"
    def __init__(self):
        self.a = "instance attribute"

obj = MyClass()
print(obj.a)  # instance attribute
print(MyClass.a)  # class attribute

obj.aはinstance attributeにしかアクセスできません。class attributeにアクセスしたい場合、MyClass.aのようにクラスの名前を使う必要があります。

attributeのアクセスの背後

attributeのアクセスは実は__getattribute__というマジックメソッドが使われています。
ビルトイン関数dir()を使えば、__getattribute__の存在を確認することができます。

dir(MyClass)

# ['__class__',
#  '__delattr__',
#  '__dict__',
#  '__dir__',
#  '__doc__',
#  '__eq__',
#  '__format__',
#  '__ge__',
#  '__getattribute__',   # ここです
#  '__gt__',
#  '__hash__',
#  '__init__',
#  '__init_subclass__',
#  '__le__',
#  '__lt__',
#  '__module__',
#  '__ne__',
#  '__new__',
#  '__reduce__',
#  '__reduce_ex__',
#  '__repr__',
#  '__setattr__',
#  '__sizeof__',
#  '__str__',
#  '__subclasshook__',
#  '__weakref__',
#  'a']

ここで、もう一個注意していただきたいのは__dict__という辞書です。
この辞書はオブジェクトのattributeを保存しています。クラスのattributeはクラスの__dict__に保存されていて、インスタンスのattributeはインスタンスの__dict__に保存されています。

上の例を見てみると

print(obj.__dict__)
# {'a': 'instance attribute'}

print(MyClass.__dict__)

# {'__module__': '__main__', 'a': 'class attribute', '__init__': <function MyClass.__init__ at 0x108715048>, '__dict__': <attribute '__dict__' of 'MyClass' objects>, '__weakref__': <attribute '__weakref__' of 'MyClass' objects>, '__doc__': None}

なので、実はobj.aはobj.__dict__["a"]と同じ結果となります。MyClass.aもMyClass.__dict__["a"]と同じ結果となります。

obj.a == obj.__dict__["a"]  # True

MyClass.a == MyClass.__dict__["a"]  # True

しかし、その背後には__getattribute__が使われています。この__getattribute__は全てのクラスに存在します。その挙動を明確化するために、ちょっとオーバーライドしましょう。

class MyClass:
    a = "class attribute"
    def __init__(self):
        self.a = "instance attribute"

    def __getattribute__(self, name):
        print(f"Called __getattribute__({name})")
        try:
            return super().__getattribute__(name)
        except AttributeError:
            raise


obj = MyClass()
print(obj.a)
# Called __getattribute__(a)
# instance attribute

このように、全てのattributeにアクセスする前に、実はこの__getattribute__が呼び出されています。obj.aは__getattribute__を呼び出し、obj.__dict__の中から"a"の値を探して、それが見つかったらその値obj.__dict__["a"]を返します。この例ではobj.__dict__の中に"a"が存在するので、そのまま値を返せば良いわけです。

しかし、最初の例を思い出してください。aがclass attributeの場合はどうなるの

class MyClass:
    a = "class attribute"
    def __init__(self):
        self.b = "instance attribute"

    def __getattribute__(self, name):
        print(f"Called __getattribute__({name})")
        try:
            return super().__getattribute__(name)
        except AttributeError:
            raise


obj = MyClass()
print(obj.a)

# Called __getattribute__(a)
# class attribute

ここでは、実は同じく__getattribute__が呼び出されていて、まずobj.__dict__の中を探して、見つからないので、クラスの__dict__に戻してもう一度探します。
つまり、type(obj).__dict__(これはMyClass.__dict__と同じ意味です)の中から"a"の存在をチェックします。この例では、クラスの__dict__に"a"が存在しますので、その値を返します。

もしなかったら、次の例のように、例外をおこします。

print(obj.c)


# Called __getattribute__(c)
# #######################
# #######################
# #######################
# AttributeError: 'MyClass' object has no attribute 'c'

instance attributeとclass attributeのまとめ

ちょっとまとめると、obj.aはまずinstanceの__dict__をチェックして、発見したらその値(instance attribute)を返します。なかったら、classの__dict__をチェックして、発見したらその値(class attribute)を返します。もし、このクラスが何か他のクラスを継承していれば、スーパークラスの__dict__をもチェックします。これでもなかったら、例外処理に入ります。(実は__getattr__にはいりますが、定義する必要があります。)

descriptor

descriptorは__get__、__set__、__delete__メソッドを提供したオブジェクトのことです。(すべて必須というわけではありません)。
property、classmethod、staticmethod、superや通常のインスタンスメソッドは全てdescriptorがあるからこそ定義できます。(これらとdescriptorの詳細な内部構造はここで詳しく説明しません。次の記事で解説する予定です。)

通常は以下のような構造になっています。

class Descriptor():
    def __get__(self, obj, type=None):
        pass
    def __set__(self, obj, value):
        pass
    def __delete__(self, obj):
        pass

特に、以下のような分類があります。これを覚えておきましょう。
Data Descriptor:
__get__と__set__の両方を定義しているdescriptor
Non-data Descriptor:(メソッドの典型的な実現方法)
__get__だけを定義しているdescriptor

次は例を見てみましょう。

class DataDiscriptor(object):
    """
     data discriptor

    """

    def __init__(self, v=None):
        print("Called __init__() in descriptor")
        self._v = v

    def __get__(self, instance, type):
        print("Called __get__() in descriptor")
        return self._v

    def __set__(self, instance, value):
        print("Called __set__() in descriptor")
        self._v = value

    def __delete__(self, instance):
        print("Called __delete__() in descriptor")
        del self._v

class MyClass:
    d = DataDiscriptor("descriptor attribute")

ここで、 MyClassを定義する時、dはDataDiscriptorのインスタンスとして初期化されます。
なので、上記のコードを実行すると、次のような結果が表示されます。

Called __init__() in descriptor

MyClassのobjを作ります。obj.dの値をアクセスまたは修正する時、それぞれ__get__と__set__が呼び出されます。

obj = MyClass()
print(obj.d)

# Called __get__() in descriptor
# descriptor attribute
print(MyClass.d)

# Called __get__() in descriptor
# descriptor attribute
obj.d = "new descriptor attribute"

# Called __set__() in descriptor

print(obj.d)
# Called __get__() in descriptor
# new descriptor attribute

しかし、このdはclass attributeではなく、DataDiscriptor objectとなります。確認してみましょう。

print(MyClass.__dict__)

#{'__module__': '__main__', 'd': <__main__.DataDiscriptor object at 0x110b34470>, '__dict__': <attribute '__dict__' of 'MyClass' objects>, '__weakref__': <attribute '__weakref__' of 'MyClass' objects>, '__doc__': None}

なので、obj.dという短い文は、このdはclass attributeか、instance attributeか、descriptor objectかのどちらかによって、振る舞いが全然違ってきます。

ここで、問題です。
obj.dは実際どういう順序で何をチェックしているのでしょう?

descriptorまで考慮する時のアクセス順序

前半の部分でclass attributeとinstance attributeのアクセス順序について議論しました。これらに加えて、descriptorまで考慮すると、どうなるでしょう。

結論からいうと、obj.dは次のような順序でチェックしています。
1.__getattribute__、無条件で呼び出す
2.data descriptorであれば、__get__を呼び出す
3.インスタンスの__dict__(obj.__dict__)をチェックする(descriptorと名前が一緒だったら、インスタンス__dict__中の同名なものが消える)
4.non-data descriptorであれば、__get__を呼び出す
5.クラスの__dict__(MyClass.__dict__)をチェックする
6.クラス(MyClass)のスーパークラスの__dict__をチェックする(多重継承の場合は多重チェック)
7.__getattr__メソッド

ここで、上記の3について検証してみましょう。

class DataDiscriptor(object):
    """
     data discriptor

    """

    def __init__(self, v=None):
        self._v = v

    def __get__(self, instance, type):
        return self._v

    def __set__(self, instance, value):
        self._v = value

    def __delete__(self, instance):
        del self._v

class MyClass:
    d = DataDiscriptor("descriptor attribute")
    def __init__(self):
        self.d = "xxx"
obj = MyClass()

print(obj.__dict__)
# {}

print(MyClass.__dict__)
#{'__module__': '__main__', 'd': <__main__.DataDiscriptor object at 0x10a939550>, '__init__': <function MyClass.__init__ at 0x10a929840>, '__dict__': <attribute '__dict__' of 'MyClass' objects>, '__weakref__': <attribute '__weakref__' of 'MyClass' objects>, '__doc__': None}

ここで問題です。次のコードはどうしてでしょう?

print(obj.d)

# xxx

このobj.__dict__の中にdが存在しないので、obj.dがxxxになっています。これは上の順序関係と違うじゃん!

実は、MyClassを定義する時、まずdはdescriptorのインスタンスとして初期化されて、ここでDataDiscriptorの__init__が呼び出されます。
次に、objを作る時、MyClassの__init__が呼び出されて、self.bをアクセス(ここではdescriptorの__get__が呼び出されます)して、このbを"xxx"となるように書き換え(ここではdescriptorの__set__が呼び出されます)をしています。

ここはコードを書いて検証してみましょう。

class DataDiscriptor(object):
    """
     data discriptor

    """

    def __init__(self, v=None):
        print("Called __init__() in descriptor")
        self._v = v

    def __get__(self, instance, type):
        print("Called __get__() in descriptor")
        return self._v

    def __set__(self, instance, value):
        print("Called __set__() in descriptor")
        self._v = value

    def __delete__(self, instance):
        print("Called __delete__() in descriptor")
        del self._v

class MyClass:
    d = DataDiscriptor("descriptor attribute")
    def __init__(self):
        self.d = "xxx"

結果:

Called __init__() in descriptor

次に、

obj = MyClass()

結果:

Called __set__() in descriptor

予想通りの動きになっています。

もし、dの後に同名のclass attributeを定義したら、上記と違って、このclass attributeは同名なdescriptorを完全に上書きして、クラスの__dict__の中から、'd': <__main__.DataDiscriptor object at 0x10a939550>を'd': class attributeのように上書きします。
しかし、このようなやり方をする人は流石にいないでしょう。

終わりに

今回の記事の最終目的はobj.dのような文は一体どういう処理をしているかを説明するものです。まだまだ不十分なところがありますが(そもそも__getattribute__、__getattr__などはなに?)、これから、ゆっくりとまとめていきたいつもりです。
結論をもう一度いうと、obj.dは次のような順序でチェックしています。
1.__getattribute__、無条件で呼び出す
2.data descriptorであれば、__get__を呼び出す
3.インスタンスの__dict__(obj.__dict__)をチェックする(descriptorと名前が一緒だったら、インスタンス__dict__中の同名なものが消える)
4.non-data descriptorであれば、__get__を呼び出す
5.クラスの__dict__(MyClass.__dict__)をチェックする
6.クラス(MyClass)のスーパークラスの__dict__をチェックする(多重継承の場合は多重チェック)
7.__getattr__メソッド

次回はdescriptorの内部の詳細とproperty、classmethod、staticmethod、superや通常のインスタンスメソッドなどがどういう風に実現されているのかを解説する予定です。

参考記事

https://qiita.com/knzm/items/a8a0fead6e1706663c22

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.