淺談對屬性描述符__get__、__set__、__delete__的理解

畫個一樣的我發表於2023-04-12

1、屬性描述符的基礎介紹

1.1 何為屬性描述符?

屬性描述符是一種Python語言中的特殊物件,用於定義和控制類屬性的行為。屬性描述符可以透過定義__get__、__set__、__delete__方法來控制屬性的讀取、賦值和刪除操作。

透過使用屬性描述符,可以實現對屬性的訪問控制、型別檢查、計算屬性等高階功能。

如果一個物件定義了這些方法中的任何一個,它就是一個描述符。

看完上面的文字描述,是不是感覺一頭霧水,沒關係,接下來透過一個簡單的案例來講解屬性描述符的作用。

1.2 為什麼需要屬性描述符?

假設我們現在要做一個成績管理系統,在定義學生類時,我們可能這樣寫:

class Student(object):

    def __init__(self, name, age, cn_score, en_score):
        self.name = name
        self.age = age
        self.cn_score = cn_score
        self.en_score = en_score

    def __str__(self):
        return "Student: {},age:{},cn_score:{},en_score:{}".format(self.name, self.age, self.cn_score, self.en_score)


xiaoming = Student("xiaoming", 18, 70, 55)
print(xiaoming)
1.2.1 init函式中做引數校驗

因為python是動態語言型別,不像靜態語言那樣,可以給引數指定型別,所以在傳參時,無法得知引數是否正確。比如,當cn_score傳入的值為字串時,程式並不會報錯。這個時候,一般就會想到對傳入的引數做校驗,當傳入的引數不符合要求時,拋錯。

class Student(object):

    def __init__(self, name, age, cn_score, en_score):
        self.name = name
        if not isinstance(age, int):
            raise TypeError("age must be int")
        if age <= 0:
            raise ValueError("age must be greater than 0")
        self.age = age

        if not isinstance(cn_score, int):
            raise TypeError("cn_score must be int")
        if 0 <= cn_score <= 100:
            raise ValueError("cn_score must be between 0 and 100")
        self.cn_score = cn_score

        if not isinstance(en_score, int):
            raise TypeError("en_score must be int")
        if 0 <= en_score <= 100:
            raise ValueError("en_score must be between 0 and 100")
        self.en_score = en_score

    def __str__(self):
        return "Student: {},age:{},cn_score:{},en_score:{}".format(self.name, self.age, self.cn_score, self.en_score)


xiaoming = Student("xiaoming", -1, 70, 55)
print(xiaoming)

雖然上面的程式碼可以實現引數校驗,但是過多的邏輯判斷在初始化函式里面,會導致函式特別臃腫,當增加新的引數時,需要增加邏輯判斷,一方面重複程式碼增加,另外也不符合開閉原則

1.2.2 使用property做引數校驗

這個時候該怎麼處理呢,我們知道python的內建函式 property可用於裝飾方法,使方法之看起來像屬性一樣。我們可以藉助此函式來最佳化程式碼,最佳化後如下:

class Student(object):

    def __init__(self, name, age, cn_score, en_score):
        self.name = name
        self.age = age
        self.cn_score = cn_score
        self.en_score = en_score

    @property
    def age(self):
        return self.age

    @age.setter
    def age(self, value):
        if not isinstance(value, int):
            raise TypeError("age must be int")
        if value <= 0:
            raise ValueError("age must be greater than 0")
        self.age = value

    @property
    def cn_score(self):
        return self.cn_score

    @cn_score.setter
    def cn_score(self, value):
        if not isinstance(value, int):
            raise TypeError("cn_score must be int")
        if 0 <= value <= 100:
            raise ValueError("cn_score must be between 0 and 100")
        self.cn_score = value

    @property
    def en_score(self):
        return self.en_score

    @en_score.setter
    def en_score(self, value):
        if not isinstance(value, int):
            raise TypeError("en_score must be int")
        if 0 <= value <= 100:
            raise ValueError("en_score must be between 0 and 100")
        self.en_score = value

    def __str__(self):
        return "Student: {},age:{},cn_score:{},en_score:{}".format(self.name, self.age, self.cn_score, self.en_score)


xiaoming = Student("xiaoming", -1, 70, 55)
print(xiaoming)

現在程式碼看起來已經挺不錯的了,確實。但是想想平常開發中,我們使用Diango 的 ORM 時,定義model時,只需要定義 modle 的屬性,就可以使其完成引數的校驗,比如ip = models.CharField(max_length=20, db_index=True, verbose_name='IP')。這是怎麼做到的呢?

1.2.3 使用屬性描述符做引數校驗

其實,Django 是使用到了Python的屬性描述符 __get__、__set__。接下來,我們使用上面的兩個方法,來進行改造。程式碼如下:

class Score:
    def __init__(self, score):
        self.score = score

    def __get__(self, instance, owner):
        return self.score

    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError("value must be int")
        if 0 <= value <= 100:
            self.score = value
        else:
            raise ValueError("value must be between 0 and 100")


class Age:

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

    def __get__(self, instance, owner):
        return self.age

    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError("age must be int")
        if value <= 0:
            raise ValueError("age must be greater than 0")
        self.age = value


class Student(object):

    age = Age(0)
    cn_score = Score(0)
    en_score = Score(0)

    def __init__(self, name, _age, _cn_score, _en_score):
        self.name = name
        # 透過這裡引數名稱的區別,我們可以更加明確的知道,是呼叫
        self.age = _age
        self.cn_score = _cn_score
        self.en_score = _en_score

    def __str__(self):
        return "Student: {},age:{},cn_score:{},en_score:{}".format(self.name, self.age, self.cn_score, self.en_score)


xiaoming = Student("xiaoming", -1, 70, 55)
print(xiaoming)

透過上面的定義,也能夠實現之前的功能,而且程式碼重用度更高,看起來也更加簡潔。

1.3 屬性描述符分類

常見的屬性描述符包括資料描述符和非資料描述符。

  • 資料描述符

是指同時定義了__get__、__set__方法的屬性描述符,它可以完全控制屬性的讀寫操作。

  • 非資料描述符

是指只定義了__get__方法的屬性描述符,它只能控制屬性的讀取操作,而不能控制屬性的賦值和刪除操作。

2、屬性描述符的詳細介紹

2.1 屬性描述符的呼叫時機

描述符本質就是一個新式類,在這個新式類中,至少實現了__get__、__set__、__delete__中的一個,這也被稱為描述符協議。

  • __get__():呼叫一個屬性時,觸發

  • __set__():為一個屬性賦值時,觸發

  • __delete__():採用del刪除屬性時,觸發

透過下面的例子將更加清晰的知道 屬性描述符的呼叫時機。

class Age:

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

    def __get__(self, instance, owner):
        print("coming __get__")
        return self.age

    def __set__(self, instance, value):
        print("coming __set__")
        if not isinstance(value, int):
            raise TypeError("age must be int")
        if value <= 0:
            raise ValueError("age must be greater than 0")
        self.age = value

    def __delete__(self, instance):
        print("coming __del__")
        del self.age


class Student(object):

    age = Age(0)

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


xiaoming = Student("xiaoming")
xiaoming.age = 9
print(xiaoming.age)
del xiaoming.age


#################
結果:
coming __set__
coming __get__
coming __del__

2.2 屬性的搜尋順序

這裡跟屬性描述符關係不是特別大,主要是看看屬性的搜尋順序。

預設的屬性訪問是從物件的字典中 get, set, 或者 delete 屬性。例如a.x的查詢順序是:

a.__getattribute__() -> a.__dict__['age'] -> type(a).__dict__['age'] -> type(a)的基類(不包括元類)-> a.__getattr__ -> 拋錯

如果查詢的值是物件定義的描述方法之一,python可能會呼叫描述符方法來過載預設行為,發生在這個查詢環節的哪裡取決於定義了哪些描述符方法。

1、非資料描述器,例項的屬性搜尋順序如下:

a.__getattribute__() -> a.__dict__['age'] -> a.__get__() -> type(a).__dict__['age'] -> type(a)的基類(不包括元類)-> a.__getattr__ -> 拋錯

class Age(object):
    def __get__(self, instance, owner):
        print("coming __get__")
        return "__get__"

    # def __set__(self, instance, value):
    #     print("coming __set__")
    #     self.age = value


class A2(object):

    age = 10
    def __init__(self):

        self.age = 1000


class A(object):

    age = Age()

    def __init__(self):
        super().__init__()

    # def __getattribute__(self, item):
    #     print("coming __getattribute__")
    #     return "xxx"
    #
    def __getattr__(self, item):
        print("coming __getattr__")
        return "__getattr__"


a = A()
print(a.age)

2、資料描述器,例項的屬性搜尋順序如下:

a.__getattribute__() -> a.__get__() -> a.__dict__['age'] -> type(a).__dict__['age'] -> type(a)的基類(不包括元類)-> a.__getattr__ -> 拋錯

class Age(object):
    def __get__(self, instance, owner):
        print("coming __get__")
        return "__get__"

    def __set__(self, instance, value):
        print("coming __set__")
        self.age = value


class A2(object):


    def __init__(self):

        self.age = 1000


class A(object):

    age = Age()

    def __init__(self):
        self.age = 100
        super().__init__()

    # def __getattribute__(self, item):
    #     print("coming __getattribute__")
    #     return "xxx"
    #
    def __getattr__(self, item):
        print("coming __getattr__")
        return "__getattr__"


a = A()
print(a.age)

參考連結:

【案例講解】Python為什麼要使用描述符?

[屬性描述符:__get__函式、__set__函式和__delete_函式](

相關文章