描述器

May 11, 2022

能被稱為描述器(Descriptor)的物件,必須擁有 __get__ 方法,以及選擇性的 __set____delete__ 方法,這三個方法的簽署如下:

def __get__(self, instance, instance_type)
def __set__(self, instance, value)
def __delete__(self, instance)

描述器的運作

Python 的描述器,是用來描述屬性的取得、設定、刪除該如何處理的物件,當描述器成為某類別的屬性成員,對於類別屬性或者其實例屬性的取得、設定或刪除,會交由描述器來決定處理方式(除了那些內建屬性,如 __class__ 等屬性之外)。例如:

class Descriptor:
    def __get__(self, instance, instance_type):
        print(self, instance, instance_type, end = '\n\n')

    def __set__(self, instance, value):
        print(self, instance, value, end = '\n\n')

    def __delete__(self, instance):
        print(self, instance, end = '\n\n')

class Some:
    x = Descriptor()

s = Some()
s.x
s.x = 10
del s.x

Some.x

在參數的部份,instance 是描述器所在類別的實例,instance_type 則是描述器所在類別(有時也會命名為 owner),Descriptor 指定給 Some 類別的 x 屬性時,對於 Some 實例 s 的屬性取值、指定或刪除,分別相當於進行以下的動作:

Some.__dict__['x'].__get__(s, Some)
Some.__dict__['x'].__set__(s, 10)
Some.__dict__['x'].__delete__(s)

對於 Some.x 這個取值動作,相當於:

Some.__dict__['x'].__get__(None, Some)

因此,上面這個範例的執行結果會是:

<__main__.Descriptor object at 0x0000021CA9A01FD0> <__main__.Some object at 0x0000021CA9A01FA0> <class '__main__.Some'>

<__main__.Descriptor object at 0x0000021CA9A01FD0> <__main__.Some object at 0x0000021CA9A01FA0> 10

<__main__.Descriptor object at 0x0000021CA9A01FD0> <__main__.Some object at 0x0000021CA9A01FA0>

<__main__.Descriptor object at 0x0000021CA9A01FD0> None <class '__main__.Some'>

這就是描述器與〈__getattribute__、__getattr__、__setattr__、__delattr__〉談到的那些特殊方法不同之處,描述器可以取得更多的資訊,而且描述器本身是個物件,本身被存取時呼叫對應的協定,而不是存取其所在類別實例的各個屬性時被呼叫。

描述器的最基本協定是具備 __get__ 方法,若還具有 __set____delete__ 方法或兩者兼具,可稱為資料描述器(Data descriptor)。

僅有 __get__ 方法的描述器,稱為非資料描述器(Non-data descriptor)。對於非資料描述器,若實例上有對應的屬性,描述器就不會有動作。例如:

>>> class Desc:
...     def __get__(self, instance, instance_type):
...         print('instance', instance, 'instance_type', instance_type)
...
>>> class X:
...     x = Desc()
...
>>> x = X()
>>> x.x
instance <__main__.X object at 0x000002708FEDED30> instance_type <class '__main__.X'>
>>> x.x = 10
>>> x.x
10
>>> del x.x
>>> x.x
instance <__main__.X object at 0x000002708FEDED30> instance_type <class '__main__.X'>
>>>

在上面的示範中,一旦 X 的實例被指定 x 屬性值,就看不到描述器有動作了。簡單來說,資料描述器可以攔截對實例的屬性取得、設定與刪除行為;非資料描述器,是用來攔截透過實例取得類別屬性時的行為。

模擬 @property

在〈屬性與方法〉看過 @property,是用來將對實例的屬性存取,轉為呼叫 @property 標註之函式,可想而知的,這是一種資料描述器的行為,這邊來自行模擬類似的功能:

class PropDescriptor:
    def __init__(self, getter, setter):
        self.getter = getter
        self.setter = setter

    def __get__(self, instance, instance_type):
        return self.getter(instance)

    def __set__(self, instance, value):
        self.setter(instance, value)

def prop(getter, setter):
    return PropDescriptor(getter, setter)

class Ball:
    def __init__(self, radius):
        if radius <= 0:
            raise ValueError('必須是正數')
        self.__radius = radius

    def get_radius(self):
        return self.__radius

    def set_radius(self, radius):
        self.__radius = radius

    radius = prop(get_radius, set_radius)

ball = Ball(10)
print(ball.radius) # 顯示 10
ball.radius = 5
print(ball.radius) # 顯示 5

get_radiusset_radius 傳入 prop,它會傳回一個描述器,這個描述器被指定為 Ball 類別的 radius,因此,對於實例的 radius 屬性存取,都會透過描述器處理,也就是呼叫傳入的 get_radiusset_radius 方法來處理。

__set_name__ 方法

Python 3.6 以後,描述器可以定義 __set__name__ 方法,這個方法會在其所在類別定義完成後執行,傳入描述所在類別及參考描述器的名稱,這就讓你有機會額外地對類別或名稱做些處理,例如,若要達成方才範例的功能,也可以採用另一種方式實作:

class PropDescriptor:
    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, instance, instance_type):
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        setattr(instance, self.private_name, value)

class Ball:
    radius = PropDescriptor()
    
    def __init__(self, radius):
        if radius <= 0:
            raise ValueError('必須是正數')
        self.radius = radius

ball = Ball(10)
print(ball.radius) # 顯示 10
ball.radius = 5
print(ball.radius) # 顯示 5

分享到 LinkedIn 分享到 Facebook 分享到 Twitter