Help us understand the problem. What is going on with this article?

Python を支える技術 ディスクリプタ編 #pyconjp

More than 5 years have passed since last update.

はじめに

この記事は、 2014年9月12-14日に開催された PyCon JP 2014 で発表した内容をまとめたものです。

ディスクリプタとは

ディスクリプタとは、以下のようなメソッドを定義したオブジェクトのことです。

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

Python では、特定の性質を持つオブジェクトが実装すべき一連のメソッドのことをプロトコルと呼びます (代表的なプロトコルとして イテレータプロトコル などがあります)。ディスクリプタはそのようなプロトコルの一種です。

プロパティや、メソッド(静的メソッド、クラスメソッド、インスタンスメソッド)、 super など、 Python の基本的な機能の背後にはこのディスクリプタが使われています。また、ディスクリプタは汎用的なプロトコルなので、ユーザが定義することも可能です。

ディスクリプタには大きく分けて2つの種類があります。

  • データディスクリプタ
    • __get____set__ の両方を定義しているディスクリプタ
  • 非データディスクリプタ
    • __get__ だけを定義しているディスクリプタ

データディスクリプタは通常の属性アクセスと同じような振る舞いをするもので、プロパティがその典型です。非データディスクリプタは典型的にはメソッド呼び出しで使われます。

データディスクリプタの中で __set__ が呼び出されたときに AttributeError が送出されるものは「読み取り専用のデータディスクリプタ」と呼ばれます。 fset が定義されない読み取り専用のプロパティは、非データディスクリプタではなく読み取り専用のデータディスクリプタという分類になります。

この分類は属性アクセスの際の優先順位に影響します。具体的に、優先順位は以下の順になっています。

  1. データディスクリプタ
  2. インスタンスの属性辞書
  3. 非データディスクリプタ

これについてはなぜそうなっているかあとで詳しく見ていきます。

プロパティとの違い

ここで、ディスクリプタとプロパティはどう違うのかという疑問を持つかもしれません。

まず用途の違いを挙げると、プロパティは通常クラス定義の中でデコレータとして使い、そのクラスのインスタンスの属性アクセスをカスタマイズするために使います。一方、ディスクリプタは特定のクラスとは独立に定義されるもので、他のクラスの属性アクセスをカスタマイズするために使います。

より本質的な関係として、プロパティはディスクリプタの一種です。つまり、ディスクリプタの方が応用範囲が広いということで、逆に言うとプロパティはディスクリプタのよくある使い方に特化されたものと言うことができます。

X.Y の裏で何が行われているか

ソースコードに X.Y と書いた場合、その単純な見た目に反して裏側で起きていることは複雑です。実際、 X がクラスかインスタンスか、 Y がプロパティかメソッドか通常の属性かによって、それぞれ何が起きるかが異なります。

インスタンス属性の場合

インスタンス属性の場合、インスタンスの属性辞書 __dict__ から指定されたキーに対応する値を参照するという意味になります。

class C(object):
    def __init__(self):
        self.x = 1

obj = C()
assert obj.x == obj.__dict__['x']

クラス属性の場合

クラス属性の場合、クラス経由でもインスタンス経由でも、クラスの属性辞書から値を参照するという意味になります。

class C(object):
    x = 1

assert C.x == C.__dict__['x']

obj = C()
assert obj.x == C.__dict__['x']

ここまでは話は簡単です。

プロパティの場合

プロパティの場合、クラスから参照した場合はプロパティ自身になり、インスタンスから参照した場合は関数の戻り値になります。

class C(object):
    @property
    def x(self):
        return 1

assert C.x == C.__dict__['x'].__get__(None, C)
# クラスから参照した場合はプロパティ自身
assert isinstance(C.x, property)

obj = C()
assert obj.x == C.__dict__['x'].__get__(obj, C)
# インスタンスから参照した場合は関数の戻り値
assert obj.x == 1

その裏では、クラスの属性辞書から値を参照して、そのオブジェクトの __get__ メソッドが呼ばれます。この部分でディスクリプタが使われています。このとき、クラス経由の場合は __get__ の第1引数が None 、インスタンス経由の場合はそのインスタンスになり、この違いによって得られる値が違ってきます。

メソッドの場合

メソッドの場合も、基本的にプロパティと同じです。裏でディスクリプタが呼び出されるため、クラス経由で参照した場合とインスタンス経由で参照した場合で異なる値が得られます。

class C(object):
    def x(self):
        return 1

assert C.x == C.__dict__['x'].__get__(None, C)

obj = C()
assert obj.x == C.__dict__['x'].__get__(obj, C)

assert C.x != obj.x

ディスクリプタと __getattribute__ との関係

__getattribute__ をオーバーライドすると、クラスの全部の属性アクセスをカスタマイズできます。一方、ディスクリプタを使うと特定の属性アクセスをカスタマイズできるという点が違います。

さらに、組み込み型の __getattribute__ の実装の中でディスクリプタを考慮した処理が行われていて、その結果としてディスクリプタが意図した通りに動くことになります。こちらの方が本質的な関係です。

__getattribute__ を実装する代表的なクラスは objecttype それに super です。ここでは objecttype を比較していきます。

Python の ソースコード の中で object 型に対応した PyBaseObject_Type 構造体が定義されていて、この中で tp_getattro というスロットに PyObject_GenericGetAttr という関数が指定されているので、 object.__getattribute__ はこの関数を呼び出します。

この関数の定義は Objects/object.c にありますが、これを Python の疑似コードで表すと以下のようになります:

def object_getattribute(self, key):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    tp = type(self)
    attr = PyType_Lookup(tp, key)
    if attr:
        if hasattr(attr, '__get__') and hasattr(attr, '__set__'):
            # data descriptor
            return attr.__get__(self, tp)
    if key in self.__dict__:
        return self.__dict__[key]
    if attr:
        if hasattr(attr, '__get__'):
            return attr.__get__(self, tp)
        return attr
    raise AttributeError

大きく3つのブロックがあり、それぞれ 1) データディスクリプタ呼び出し、 2) インスタンス自身の属性辞書参照、 3) 非データディスクリプタ呼び出しまたはクラスの属性辞書参照 を行っています。

まず最初に、オブジェクトのクラスを取得して、そのクラスの属性を検索します。ここで PyType_Lookup は、あるクラスとその親クラスを順に辿って属性辞書から指定されたキーに対応する値を返す関数だと思ってください。ここで属性が見つかって、それがデータディスクリプタだったらその __get__ が呼ばれます。データディスクリプタが見つからなかった場合、インスタンスの属性辞書が参照されて値があればそれが返ります。最後にもう一度クラス属性があるかチェックして、それがディスクリプタなら __get__ が呼ばれ、ディスクリプタでなければその値自体が返ります。何も値が見つからなければ AttributeError が送出されます。

type.__getattribute__ も同様に Objects/typeobject.c の中の
PyType_Type 構造体で定義されています (http://hg.python.org/cpython/file/v3.4.1/Objects/typeobject.c#l3122 )。

これを Python の疑似コードであらわすと、以下のようになります:

def type_getattribute(cls, key):
    "Emulate type_getattro() in Objects/typeobject.c"
    meta = type(cls)
    metaattr = PyType_Lookup(meta, key)
    if metaattr:
        if hasattr(metaattr, '__get__') and hasattr(metaattr, '__set__'):
            # data descriptor
            return metaattr.__get__(cls, meta)
    attr = PyType_Lookup(cls, key)
    if attr:
        if hasattr(attr, '__get__'):
            return attr.__get__(None, cls)
        return attr
    if metaattr:
        if hasattr(metaattr, '__get__'):
            return metaattr.__get__(cls, meta)
        return metaattr
    raise AttributeError

前半と後半は object の場合と同じような処理をしているので割愛するとして (インスタンスに対するクラスがクラスに対するメタクラスに対応していることに注意してください)、真ん中のブロックが object の場合と異なっています。 object の場合は単に属性辞書の参照だけだったのが、クラスの場合は親クラスを辿って属性辞書を参照して、さらにそれがディスクリプタだったらディスクリプタの __get__ を呼び出しています。

ここまで見てきたことをまとめると、

  • ディスクリプタは常にクラスの属性辞書から取得される
  • ディスクリプタは常にクラスを伴って呼ばれる (object の場合は type(self) が、 type の場合は cls__get__ の引数になっています)
  • データディスクリプタはインスタンスの属性辞書よりも優先される

たとえばこんなコードがあったときに、 __dict__ に値を直接突っ込んでも、プロパティの方が優先されています。

class C(object):
    @property
    def x(self):
        return 0

>>> o = C()
>>> o.__dict__['x'] = 1
>>> o.x
0

ディスクリプタの具体例

ここからは具体的なディスクリプタの例をいくつか見ていきます。

プロパティ

ディスクリプタプロトコルに従って、プロパティを以下のような Pure Python のコードとして定義することができます。

class Property(object):
    "Emulate PyProperty_Type() in Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel

    def __get__(self, obj, klass=None):
        if obj is None:
            # via class
            return self
        if self.fget is not None:
            return self.fget(obj)
        raise AttributeError

    def __set__(self, obj, value):
        if self.fset is not None:
            self.fset(obj, value)
        raise AttributeError

    def __delete__(self, obj):
        if self.fdel is not None:
            self.fdel(obj)
        raise AttributeError

__get__ の中で、 objNone だったら、つまりクラス経由で呼び出されたら自分自身を返します。コンストラクタで渡された fgetNone でなければ fget を呼び出して、 None だったら AttributeError を投げています。

静的メソッド

staticmethod の疑似コードは以下の通りです。

class StaticMethod(object):
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"

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

    def __get__(self, obj, klass=None):
        return self.f

これは簡単で、 __get__ が呼ばれたときに常に関数自体を返しています。このため、 staticmethod はクラス経由で呼んでもインスタンス経由で呼んでも、元の関数と同じ振る舞いをします。

クラスメソッド

classmethod の疑似コードは以下の通りです。

class ClassMethod(object):
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"

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

    def __get__(self, obj, klass=None):
        if klass is None:
            klass = type(obj)
        return types.MethodType(self.f, klass)

__get__ が呼ばれると、関数とクラスから MethodType オブジェクトを作って返しています。実際には直後にこのオブジェクトの __call__ が呼ばれることになります。

インスタンスメソッド

インスタンスメソッドは、実はその実体は関数です。たとえばこんなクラスと関数があったときに、

class C(object):
    pass

def f(self, x):
    return x

関数 f__get__ を呼び出すと MethodType オブジェクトが返ってきます。これを呼ぶと、あたかもインスタンスメソッドの呼び出しを行ったのと同じ結果が返ってきます。この場合、 f はクラス C と何の関係もない関数ですが、結果的にメソッドとして呼ばれていることになります。

obj = C()
# obj.f(1) をエミュレートする
meth = f.__get__(obj, C)
assert isinstance(meth, types.MethodType)
assert meth(1) == 1

関数はディスクリプタであるということを、もっと極端に表したのがこの例になります。

>>> def f(x, y): return x + y
...
>>> f
<function f at 0x10e51b1b8>
>>> f.__get__(1)
<bound method int.f of 1>
>>> f.__get__(1)(2)
3

ここで定義している関数 f はメソッドでも何でもない単なる2引数の関数ですが、その __get__ を呼び出すと、 bound method が返ってきます。それに引数を渡して呼び出すと、関数呼び出しが行われるということが確認できます。このように、すべての関数はディスクリプタであって、クラス経由で呼んだ場合にディスクリプタの働きでそれがメソッドとして振る舞う、ということが分かります。

インスタンスメソッド、つまりディスクリプタとしての関数を疑似コードで表すとこんな感じになります。

class Function(object):
    "Emulate PyFunction_Type() in Objects/funcobject.c"

    def __get__(self, obj, klass=None):
        if obj is None:
            return self
        return types.MethodType(self, obj)

クラス経由で呼ばれたときは自分自身を返して、インスタンス経由で呼ばれたときは関数とインスタンスから MethodType オブジェクトを作って返しています。

MethodType.__call__ の疑似コードは以下の通りです。やっていることは単純で、 __self____func__ を取り出して、関数の第一引数に self を追加して関数を呼んでいるだけです。

def method_call(meth, *args, **kw):
    "Emulate method_call() in Objects/classobject.c"
    self = meth.__self__
    func = meth.__func__
    return func(self, *args, **kw)

ここまでの話をまとめると、

obj.func(x)

というメソッド呼び出しは、以下のような処理と等価です。

func = type(obj).__dict__['func']
meth = func.__get__(obj, type(obj))
meth.__call__(x)

これは、最終的に以下のような関数呼び出しと等価になります。

func(obj, x)

ここでちょっと話はそれますが、 Python でメソッドの第1引数がなぜ self なのかということについて考えたいと思います。その理由は、これまでの話を踏まえると以下のように説明できます。 Python ではインスタンスメソッドの実体は関数であって、インスタンスメソッドの呼び出しがディスクリプタの働きによって最終的に単なる関数呼び出しに変換されます。単なる関数なので、 self に相当するものを渡す場合に引数として渡すのが自然です。もし第1引数の self を省略できるとしたら、関数呼び出しとメソッド呼び出しで異なる規約にしなければならず、言語仕様が複雑になります。関数とメソッドを別扱いするのではなく、ディスクリプタを使ってメソッド呼び出しを関数呼び出しに変換する Python の仕組みは、非常に巧妙だと思います。

Python 3 の場合、クラス経由でインスタンスメソッドを参照した場合は関数自体が返ってきますが、 Python 2 の場合は unbound method というものが返ってきます。関数自体を参照するには __func__ という属性を参照する必要があります。この書き方は Python 3 だとエラーになるので、もしこういうコードがある場合 Python 3 に移植する際に注意してください。 Python 3 ではそもそも unbound method という概念がなくなりました。

class C(object):
      def f(self):
          pass
$ python3
>>> C.f  # == C.__dict__['f']
<function C.f at 0x10356ab00>

$ python2
>>> C.f  # != C.__dict__['f']
<unbound method C.f>
>>> C.f.__func__  # == C.__dict__['f']
<function f at 0x10e02d050>

super

ディスクリプタが使われている別の例として super があります。以下の例を見てください。

class C(object):
    def x(self):
        pass

class D(C):
    def x(self):
        pass

class E(D):
    pass

obj = E()
assert super(D, obj).x == C.__dict__['x'].__get__(obj, D)

この例では、 super(D, obj).x はクラス C の属性辞書から x に対応する値を取得してその __get__objD を引数にして呼び出すという意味になります。ここでポイントは、属性を取得するクラスが D ではなく C だということです。その鍵は super クラスの __getattribute__ の実装にあります。

super.__getattribute__ を疑似コードで表すと以下のようになります。

def super_getattribute(su, key):
    "Emulate super_getattro() in Objects/typeobject.c"
    starttype = su.__self_class__
    mro = iter(starttype.__mro__)
    for cls in mro:
        if cls is su.__self_class__:
            break
    # Note: mro is an iterator, so the second loop
    # picks up where the first one left off!
    for cls in mro:
        if key in cls.__dict__:
            attr = cls.__dict__[key]
            if hasattr(attr, '__get__'):
                return attr.__get__(su.__self__, starttype)
            return attr
    raise AttributeError

最初に指定されたクラスの mro 継承ツリーから、そのクラスの「次」(あるいは継承ツリーの「上」)のクラスを検索します。そしてそこを起点として継承ツリーを辿りながら属性辞書を参照して、見つかった属性がディスクリプタだったらディスクリプタ呼び出しを行っています。これが super のからくりです。

super はディスクリプタでもあります。ただし、現在の Python ではこのことはあまり有効に使われていないようです。 Python のソースコードの中で唯一、テストの中でこんなコードを見つけました: http://hg.python.org/cpython/file/v3.4.1/Lib/test/test_descr.py#l2308

調べてみると PEP 367self.__super__.foo() という仕様が提案されていて、もしかしたらこれと関係するのかもしれません。ちなみにこの PEP は最終的に PEP 3135 として Python 3 で採用されましたが、その際は super() の引数が省略できるという形になって、この書き方は採用されませんでした。

reify

最後にユーザ定義のディスクリプタの例を挙げます。

http://docs.pylonsproject.org/docs/pyramid/en/latest/_modules/pyramid/decorator.html#reify

これはウェブフレームワーク Pyramid の中にある reify のコードです。 reify は、キャッシュ付きプロパティのようなもので、似たような機能は他のフレームワークにも存在しますが、 Pyramid の実装はディスクリプタを使った非常にスマートなものになっています。ポイントは __get__ メソッドの中で setattr を実行している部分です。ここで関数呼び出しで得られた値をインスタンスの属性辞書に設定していて、これによって次回からはディスクリプタ呼び出しが発生しなくなります。なぜなら reify は非データディスクリプタなので、インスタンスの属性辞書の方が優先されるからです。

まとめ

  • ディスクリプタは、属性参照をカスタマイズするためのプロトコルです。
  • ディスクリプタにはデータディスクリプタと非データディスクリプタがあって、優先順位が異なります。
  • ディスクリプタは、プロパティやメソッドなどで使われている他、オリジナルのディスクリプタを定義することもできる汎用的なプロトコルです。
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away