sankantsuのブログ

技術メモ・競プロなど

Python の descriptor

概要

Python の descriptor は,属性 (attribute) へのアクセスをフックに関数呼び出しを行う機構である. descriptor の機構は,メンバ変数の getter や setter を簡潔に定義できるようにした property などに使われている.

ここでは,

について説明を行う.

descriptor の基本

あるクラスに対して,特別なメソッド

  • __get__()
  • __set__()
  • __delete__()

のいずれかが定義されているとき, そのクラスのオブジェクトを descriptor という.

descriptor が特別な振る舞いをするのは,descriptor のインスタンスがクラス変数として使われたときである. このとき,descriptor へのアクセスが次のように変換される.

  • 値の読み出し -> __get__()
  • 値の書き込み -> __set__()
  • 値の削除 -> __delete__()

ここでは,__get__()__set__()を中心に説明する.

基本的な例

class D:
    def __get__(self,obj,objtype=None):
        print("Called D.__get__()")

class A:
    x = D()

a = A()
a.x

出力

Called D.__get__()

クラスD__get__()メソッドを実装しているので,値の読み出しに関して descriptor として働く. クラスAはクラス変数としてクラスDのオブジェクトxを持っている. したがって,クラスAのオブジェクトaを通して descriptor xにアクセスしようとすると,D.__get__() が呼ばれる.

__get__() に自動で渡される引数

__get__()が呼ばれる際,引数objには呼び出し元のオブジェクト(上の例ではa),objtype にはaの型(上の例ではclass A)がそれぞれ渡される. A.dscのようにクラスから直接 descriptor へアクセスした場合は,引数objNone が渡される.

__set__() メソッド

x が descriptor であり,__set__() メソッドを実装しているとき,a.x = ... のような xへの書き込みは,__set__() メソッドの呼び出しに置き換えられる.

def __set__(self, obj, value):
    ...

のように__set__()メソッドが定義されているとき,呼び出し時に引数objには呼び出し元のオブジェクト,引数valueには書き込もうとした値が自動的に渡される.

descriptor でインスタンス変数を操作する

class D:
    def __get__(self,obj,objtype=None):
        print(f"Accessing 'val' giving {obj._val}")
        return obj._val
    def __set__(self,obj,v):
        print(f"Update 'val' to {v}")
        obj._val = v

class A:
    val = D()
    def __init__(self,v):
        self.val = v

a = A(123)
a.val

b = A(456)
b.val

a.val

出力

>>> a = A(123)
Update 'val' to 123
>>> a.val
Accessing 'val' giving 123
123
>>> b = A(456)
Update 'val' to 456
>>> b.val
Accessing 'val' giving 456
456
>>> a.val
Accessing 'val' giving 123
123

クラスAはクラス変数として descriptor val をもっている. Aのオブジェクトの初期化時に descriptor valへのアクセスが起こり,オブジェクトのインスタンス変数 _val がセットされる.

val 自体はクラス変数であるが,__set__()の呼び出し時に呼び出し元のオブジェクトがobjとして渡されているため,個別のオブジェクトに関するインスタンス変数を操作することができているという点に注意しておく.

複数のインスタンス変数を操作する

上の例でdescriptor を通してインスタンス変数を操作できることを確認したが,この例ではインスタンス変数が_valという名前で決め打ちになっていた. そのため,1つのクラスに複数の descriptor を持ちたい場合,上の方法では各 descriptor ごとに別々のクラスを用意しなければならないという問題が生ずる.

これを解決する方法として,__set_name__() という特殊なメソッドを用いることができる. __set_name__()はクラスの構築時にクラス変数としてもっている descriptor ごとに呼ばれる.

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

という定義があったとき,owner にはその descriptor を保持するクラスが,name にはその descriptor がクラス内でもつクラス変数の名前が文字列として渡される. ソースコード中に記述したクラス変数名の文字列が直接文字列として渡されるという点が特徴的である.

class D:
    def __set_name__(self,owner,name):
        self.public_name = name
        self.private_name = "_" + name
    def __get__(self,obj,objtype=None):
        val = getattr(obj,self.private_name)
        print(f"Accesing {self.public_name!r} giving {val}")
        return val
    def __set__(self,obj,v):
        print(f"Update {self.public_name!r} to {v}")
        setattr(obj,self.private_name,v)

class A:
    x = D()
    y = D()
    def __init__(self,v1,v2):
        self.x = v1
        self.y = v2

a = A(123,456)
a.x
a.y

出力

>>> a = A(123,456)
Update 'x' to 123
Update 'y' to 456
>>> a.x
Accesing 'x' giving 123
123
>>> a.y
Accesing 'y' giving 456
456

__set_name__()メソッド内で public_nameprivate_name の2つの変数を定義し,public_name をログ表示用に,private_nameを実際のインスタンス変数操作用に使っている.

xyのそれぞれのクラス変数が__set_name__()メソッドを呼び出すので,変数名が動的に解決され,衝突することなく使うことができている.

参考

Descriptor HowTo Guide — Python 3.10.8 documentation

Descriptor についてまとまった公式のドキュメント

3. Data model — Python 3.10.8 documentation

リファレンス内の Data model の章に,descriptor の呼び出しなどに関する詳細な記述がある.