ゆくゆくは有へと

おかゆ/彼ノ∅有生 の雑記

ディスクリプタを個人的にまとめる

参考:

ここからどこへ進むか - Dive Into Python 3 日本語版 の項目を上から調べています。
デコレータ、プロパティはまあすんなり理解できたものの、ディスクリプタがいまいちわからんなーという思いでした。

頭うんうん捻りながらなんとなくその表層がなぞれたような気がするのでメモがてら。
(最終的にはメタクラスも読みたいけど精神力がもたなさそうだ)

定義上の定義から

一般に、デスクリプタは “束縛動作 (binding behavior)” をもつオブジェクト属性で、その属性アクセスが、デスクリプタプロトコルメソッドによってオーバーライドされたものです。このメソッドは、 get(), set(), および delete() です。これらのメソッドのいずれかが、オブジェクトに定義されていれば、それはデスクリプタと呼ばれます。

とりあえず、

その属性アクセスが、デスクリプタプロトコルメソッドによってオーバーライドされたもの

のこと、ですが、全く分からん。もう少し続きを読むと、

見つかった値が、デスクリプタメソッドのいずれかを定義しているオブジェクトなら、Python はそのデフォルトの振る舞いをオーバーライドし、代わりにデスクリプタメソッドを呼び出します。これがどの連鎖順位で行われるかは、どのデスクリプタメソッドが定義されているかに依ります。

ふだん何気なく使っている(使いすぎている)属性アクセスの構文の意味論を、(アトリビュートが参照するオブジェクトの)クラスごとに決めれるということだと思います。

「X . Y」の構文が処理される際に、もし Y がデスクリプタなら、この構文は Y が属するクラスで定義されている __get__ を呼びます。Yがデスクリプタでない(つまり、デスクリプタメソッドをもってない)場合は、通常通り、X.dict["Y"] が参照されます。*1

ここの、Yがデスクリプタかどうかの判断は、実際はまず初めに呼び出される type.getattribute や object.getattribute によってなされているようです。この中身はとりあえずブラックボックスにしておきます。

__get__関数の引数は (self, obj, type=None)であり、一般的に、X がインスタンスかクラスかによって、この引数に渡される値が変わってきます。まさに先ほどブラックボックスにした部分でこの辺りの処理がなされます。

  • X がクラスなら、「X . Y」は X.__dict__["Y"].__get__(None, X) に変換される
  • X がインスタンスなら、「X . Y」は type(X).__dict__["Y"].__get__(X, type(X))に変換される

__get__メソッドの引数が、クラスでは (None, X) に対し、インスタンスでは (X, type(X)) となっていることに注目。つまり、インスタンスからデスクリプタにアクセスしようとしたときのみ、getメソッドの第一引数が代入されます。 結局、この差異が後続の挙動に関わってきます(逆にいえば、この差異を用いて、クラス/インスタンスのどちらからアクセスがあったかを見極められます)。たとえば、(みんなだいすき)プロパティだと、クラスからアクセスする(つまり getメソッドの第一変数が None)ときはそのままプロパティオブジェクトを返しますが、インスタンスからアクセスすると、propertyの第一変数に入れたメソッドが立ち上がるように設計されています。

この変換から分かる通り、Y はクラスのアトリビュートとして定義しておく必要があります。言い換えるなら、あるクラスのデスクリプタなアトリビュートは、そのクラスから生成されるインスタンスすべてにおいて、各アトリビュートはその種のデスクリプタとなります。*2

関数もディスクリプタ

クラス定義内で、メソッドは関数として保存します。唯一ふだん使う関数と違うのは第一引数が self かどうかというところです。この違いは、関数も実のところディスクリプタであることに由来します。pure Python で関数のクラスを書くと次のようになるそうです:

class Function(object):
    . . .
    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c"
        return types.MethodType(self, obj, objtype)

※「デスクリプタHOWTO」では上記のようになっていましたが、python3だとどうやら、types.MethodType(self, instance)の2つのみを引数にとるようです。挙動を見た感じ、素人考えですが、おおよそpython3では次のようになっていると考えたらいいと思います。

class Function(object):
    . . .
    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c, revised by Okayu"
        if obj is None:
            return self
        return types.MethodType(self, obj)

キモっぽい types.MethodType ですが、これは第一引数にメソッドとして登録したい関数を、第二引数にインスタンスを渡すと相応の"メソッド"(厳密には後述の bound method のこと)を返してくれるものだそうです。第二引数が None でない場合、このメソッドは具体的に指定したインスタンスのみ登録されます。また、クラス経由で関数にアクセスすると、関数自身(つまり、self部分が束縛されていない"生"もの)が返ってきます。

上の擬似コードでは self は(クラス内に定義された)関数、objインスタンスです。結局、__get__bound methodを返します。bound method は、言ってしまえば、クラス内で定義された関数の第一引数にインスタンスをぶちこんだ形の関数のことです(第一引数にインスタンスを部分適用した状態の関数と言ったほうが正確かもしれない)。引数の観点からみれば、たとえば、

class C:
  def f (self, a, b, c):
    pass

x = C()

として、x.f は、xインスタンスfがデスクリプタの「X . Y」形式なので、

type(x).__dict__["f"].__get__(x, type(x))

と等価です。これは、lambda a,b,c: C.f(x, a, b, c) と等価です。つまりさっき言った、関数 f の第一引数にインスタンス x をぶちこんだ形です。上でも述べましたが、クラス経由で関数にアクセスすると関数がそのまま返ってくるので、C.fは(クラス内での)f と等価です。なお、python 2 ではこの "生"の関数のことを unbound method と呼んでたそうですが、python3 ではそうは言わないようです。

クラス内で定義された関数を、外でインスタンスメソッドとして使う時にselfの部分が消える(ようにみえる)のは、上記の__get__による束縛のおかげなんですね。

デスクリプタの種類

ふつう冒頭あたりでこれについて触れられているのですが、冒頭で言われてもなんのこっちゃなのでここに持ってきました。

まず、2種類にわけられます:

  • データデスクリプタ:get(), set()の両方を定義している
  • 非データデスクリプタ:get() だけ定義している

データデスクリプタは通常の属性アクセスと同じ振る舞い(参照とか代入とか)をするものです。
データデスクリプタの亜種として、set() に AttributionError を投げさせるやつを読み込み専用のデータデスクリプタというそうです。*3

さらに、この2つと属性辞書では、アクセスの順番があり、

データディスクリプタインスタンスの属性辞書 > 非データディスクリプタ

と優先的に見られるそうです。これは上でみた__getattributive__の中身から決まっているそうです。コイツの中身はブラックボックスで済ませようとさっき言ったのでこれ以上突っ込みませんが、そういうことです(どういうことやねん)。

入門書(のちょっと発展くらい)だと、属性アクセスは属性辞書へのアクセスだ~みたいなこと書いてありますが、実はその前後にディスクリプタが隠れてたんだねってことですね。

非データデスクリプタ

関数は get だけ定義されているので非データデスクリプタです。
他にも、静的メソッド(staticmethod)やクラスメソッド(classmethod)も非データデスクリプタです。

staticmethod と classmethod はふつうデコレータで使いますね!それぞれ擬似コードをみていきます。

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

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

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

これがstaticmethod()擬似コードです。obj, objtype の如何によらず、関数を返します。言い換えれば、クラス内に定義された「ふっつーに使える関数」になります。クラス・インスタンスどちらからアクセスしても、関数がそのまんま返ります。

次に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)
          def newfunc(*args):
               return self.f(klass, *args)
          return newfunc

"klass が None なら..." みたいなとこは置いといて(要はいずれにせよ、ふつうに使えば klass == type(obj) ってことでしょう)。

くるんだ関数の第一引数に klass をぶちこんだような関数を返します。ここでは、obj がガン無視されてます。これも要は第一引数に klass を部分適用したような関数を返すということで、上でみた bound method のクラス版になっています。

「HOWTO」では、新しいコンストラクタ使うのに使えるでって書いてありました。

まとめ

デフォルトの __getattribute__アトリビュートがデスクリプタかどうかを仕分け、その後の挙動を__get__()に任せます。
__get__()には「そのデスクリプタがクラス経由で呼ばれたかインスタンス経由で呼ばれたか」を区別するに十分な差異をもつ引数たちが渡されます(仰々しく言いましたが、要はインスタンスが入るべき引数がNoneか否かということです)。この差異を用いることで大抵のデスクリプタはクラスから呼ばれたときの挙動、インスタンスから呼ばれたときの挙動を違わせています。もちろん、あえて同じ挙動をさせるようなデスクリプタもあります(staticmethodがそうでした)。

メソッドは究極、関数であって、その振る舞いの違いを、関数をデスクリプタ化させることで実現しているのは面白いなと思いました(小並感)。

おまけ

関数のデスクリプタのところで、

type(x).__dict__["f"].__get__(x, type(x))

というのが出てきました。が、よくよく考えると、この.__get__(x, type(x))自体も bound method ですね。

type(x).__dict__["f"] はクラス内の関数 f のことで、f が get メソッドを呼び出すこの構図は、

type(f).__dict__["__get__"].__get__(f, type(f))

になりますよね。あれ?無限ループ?

*1:少々語弊があって、後述する非データデスクリプタと属性辞書参照だと、属性辞書参照のほうが優先度が高いです。なので、デスクリプタであっても、属性辞書で該当してしまうと、デスクリプタメソッドは呼び出されないという事態は起きます。

*2:あれ?じゃあインスタンス間で共有することになるのかしら?プロパティもデスクリプタのひとつだけど、あれは迂遠して各インスタンスごとのデスクリプタなアトリビュートを実現してそう。よくよく考えれば、一般的なデスクリプタではインスタンス変数が出てくる場面がないものね。

*3:プロパティのfset = None なやつはこの読み込み専用データデスクリプタになるらしいです。