iT邦幫忙

2023 iThome 鐵人賽

DAY 13
0
Software Development

Python十翼:與未來的自己對話系列 第 13

[Day13] 四翼 - Descriptor:property vs Descriptor

  • 分享至 

  • xImage
  •  

property內部實作了descriptor protocol,所以可以視其為一種簡易版,單次使用的data descriptor。在我們需要少量descriptor特性時,property是一個非常方便的工具。

今天假設在剛開始實作一個class時,我們使用了property快速完成了一個prototype。當我們準備進一步往下,繼續實作更多細節的時候,發現使用了很多具有相同邏輯的property,所以決定自己實作一個descriptor,將這些邏輯包起來重複利用。

我們會練習三個小題,練習1是概念說明,練習2練習3是實戰例題。我們將使用方法6來實作,方便大家之後可以對照Descriptor HowTo Guide中的講解,因這兩個小題皆是受其啟發而來。

練習1

# 01a中我們定義了三個prop,他們的功能都是在返回其底層的self._xself._yself._y

# 01a
class MyClass:
    def __init__(self, x, y, z):
        self._x = x
        self._y = y
        self._z = z

    @property
    def x(self):
        return self._x

    @property
    def y(self):
        return self._y

    @property
    def z(self):
        return self._z


if __name__ == '__main__':
    my_inst = MyClass(1, 1, 1)
    print(my_inst.x)  # 1
    print(my_inst.y)  # 1
    print(my_inst.z)  # 1

    # my_inst.x = 2  # ValueError
    # my_inst.y = 2  # ValueError
    # my_inst.z = 2  # ValueError

第一次嘗試

首先,MyClass.__init__中的邏輯可以使用__set_name__來達成。

# 01b
class myprop:
    def __set_name__(self, owner_cls, name):
        self._name = f'_{name}'

接著propertygetter邏輯可以移至__get__

# 01b
class myprop:
    ...
    def __get__(self, instance, owner_cls):
        if instance is None:
            return self
        return getattr(instance, self._name)

乍看我們好像完成了改寫,我們的myprop可以這麼使用。

# 01b
...
class MyClass:
    x = myprop()
    y = myprop()
    z = myprop()

    def __init__(self, x, y, z):
        self._x = x
        self._y = y
        self._z = z

但是別忘了property是實作了descriptor protocoldata descriptor,代表它不是只有getteer,也有setterdeleter。當我們沒有給予property setterdeleter時,如果對其進行setdel,會raise AttributeError

而我們的myprop雖然可以正常get,卻也能setdel而不raise AttributeError,這不太符合我們希望的行為。事實上myprop因為只有實作__get__,所以是一個non-data descriptor

# 01b
...
if __name__ == '__main__':
    my_inst = MyClass(1, 1, 1)
    print(my_inst.x)  # 1 from __get__
    my_inst.x = 2
    print(my_inst.__dict__)  # {'_x': 1, '_y': 1, '_z': 1, 'x': 2}
    print(my_inst.x)  # 2 from my_inst.__dict__

當第一次呼叫my_inst.x時,使用的是myprop.__get__,但是當我們使用了my_inst.x = 2,這就相當於直接在my_inst.__dict__加入x一樣。當第二次呼叫my_inst.x時,因為instance.__dict__shadow myprop.__get__,所以我們變成從instance.__dict__取值了。

解決辦法是於myprop中實作__set____delete__,但卻直接raise AttributeError。這是一個非常容易出錯的盲點,只要能夠了解這邊,相信就能輕鬆穿梭在propertydescriptor之間。

  • 只實作__get__descriptornon-data descriptor,是有機會被instance.__dict__shadow的。
  • 有實作__get__加上__set____delete__兩種中至少一種的descriptordata descriptor,其必定會shadow instance.__dict__。即使我們只是於__set____delete__ raise AttributeError,它依然是data descriptor。這就像我們只給property getter,但沒有指定setterdeleterproperty依然是一個具有data descriptor行為的obj

第二次嘗試

# 01c中我們實作了__set____delete__,並模仿property的錯誤訊息,直接raise AttributeError。另外,為了讓myprop更像property,我們可以於__init__中選擇性接收doc來作為說明文件。

# 01c
class myprop:
    def __init__(self, doc=None):
        self.__doc__ = doc

    def __get__(self, instance, owner_cls):
        if instance is None:
            return self
        return getattr(instance, self._name)

    def __set__(self, instance, value):
        name = self._name[1:]
        cls_name = type(instance).__name__
        raise AttributeError(
            f"myprop '{name}' of '{cls_name}' object has no setter")

    def __delete__(self, instance):
        name = self._name[1:]
        cls_name = type(instance).__name__
        raise AttributeError(
            f"myprop '{name}' of '{cls_name}' object has no deleter")

    def __set_name__(self, owner_cls, name):
        self._name = f'_{name}'


class MyClass:
    x = myprop()
    y = myprop()
    z = myprop()

    def __init__(self, x, y, z):
        self._x = x
        self._y = y
        self._z = z

練習2

# 02a中我們定義了三個prop,他們的功能都是:

  • 給定的值需先通過self._validate驗證,確認為int且大於0時,才會設定給self._xself._yself._y。如果沒有通過self.__validate驗證,會rasie ValueError
  • 返回其底層的self._xself._yself._y
# 02a
class MyClass:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

    @property
    def x(self):
        return self._x

    @x.setter
    def x(self, value):
        self._validate(value)
        self._x = value

    @property
    def y(self):
        return self._y

    @y.setter
    def y(self, value):
        self._validate(value)
        self._y = value

    @property
    def z(self):
        return self._z

    @z.setter
    def z(self, value):
        self._validate(value)
        self._z = value

    def _validate(self, value):
        if not isinstance(value, int) or value <= 0:
            raise ValueError(f'{value} is not a positive integer.')

首先,根據題意,我們可以給descriptor取一個適合的名字為PositiveIntPositiveInt__get____set_name__練習1相似。

# 02b
class PositiveInt:
    def __get__(self, instance, owner_cls):
        if instance is None:
            return self
        return getattr(instance, self._name)

    def __set_name__(self, owner_cls, name):
        self._name = f'_{name}'

PositiveInt__set___validate是本題練習的重點。我們將__set__分成驗證與實際設定兩部份。

# 02b
class PositiveInt:
    ...
    def __set__(self, instance, value):
        self._validate(value)
        setattr(instance, self._name, value)

    def _validate(self, value):
        if not isinstance(value, int) or value <= 0:
            raise ValueError(f'{value} is not a positive integer.')

藉助PositiveInt我們可以將# 02a改為# 02b,是不是清爽不少呢?

# 02b
...
class MyClass:
    x = PositiveInt()
    y = PositiveInt()
    z = PositiveInt()

    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

練習3

練習3練習2非常像,差別是這次我們想要建立NegativeInt,讓我們可以在class中使用任意數量的PositiveIntNegativeInt,如# 03a

# 03a PSEUDO CODE!!!
class MyClass:
    x = PositiveInt()
    y = PositiveInt()
    z = NegativeInt()

    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

由於NegativeIntPositiveInt不同之處,只有_validate function而已。或許您會想不如讓NegativeInt繼承PositiveInt,然後覆寫_validate function。此舉雖然可行,但是不太合理,這麼一來就像是暗示NegativeInt是一種PositiveInt

一個比較好的方法,是再往上一層建立一個BaseValidator,可以讓PositiveIntNegativeInt繼承。

剛好Python有內建的abc模組來幫助我們完成這件事:

  • abc.ABC不允許使用者直接使用BaseValidator生成instance,只能被其它Validator繼承。如果直接使用BaseValidator生成instanceraise TypeError
  • abc.abstractmethod可以提醒我們,繼承了BaseValidatorValidator必定要實作自己的_validate function,否則會於生成Validatorinstanceraise TypeError
# 03b
from abc import ABC, abstractmethod


class BaseValidator(ABC):
    def __get__(self, instance, owner_cls):
        if instance is None:
            return self
        return getattr(instance, self._name)

    def __set__(self, instance, value):
        self._validate(value)
        setattr(instance, self._name, value)

    def __set_name__(self, owner_cls, name):
        self._name = f'_{name}'

    @abstractmethod
    def _validate(self, value):
        pass

繼承了BaseValidator後,我們的PositiveIntNegativeInt只要實作_validate function就好,於MyClass中的使用方法也不需改變。整體的程式碼是不是看起來更有架構也更專業了呢?

# 03b
...
class PositiveInt(BaseValidator):
    def _validate(self, value):
        if not isinstance(value, int) or value <= 0:
            raise ValueError(f'{value} is not a positive integer.')


class NegativeInt(BaseValidator):
    def _validate(self, value):
        if not isinstance(value, int) or value >= 0:
            raise ValueError(f'{value} is not a negative integer.')


class MyClass:
    x = PositiveInt()
    y = PositiveInt()
    z = NegativeInt()

    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

為什麼沒有實作__delete__

property沒有實作deleter卻使用了類似del my_inst.x的指令,其報錯為AttributeError: property 'x' of 'MyClass' object has no deleter。當descriptor沒有實作__delete__卻使用了類似del my_inst.x的指令,其報錯為AttributeError: __delete__

我們於練習1實作__delete__是為了讓其報錯訊息更像property,如果是平常使用的話,大部份情況是不需要實作__delete__,這跟我們使用property的習慣是很像的。而且即使我們在沒有實作__delete__的情況下,又使用了del my_inst.x的指令,其報錯指令AttributeError: __delete__也是非常清楚,容易了解。

Code

本日程式碼傳送門


上一篇
[Day12] 四翼 - Descriptor:Descriptor存取設計(2)
下一篇
[Day14] 五翼 - Metaclasses:Instance Creation
系列文
Python十翼:與未來的自己對話30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言