深入理解python虛擬機器:黑科技的幕後英雄——描述器

一無是處的研究僧發表於2023-05-07

深入理解python虛擬機器:黑科技的幕後英雄——描述器

在本篇文章當中主要給大家介紹一個我們在使用類的時候經常使用但是卻很少在意的黑科技——描述器,在本篇文章當中主要分析描述器的原理,以及介紹使用描述器實現屬性訪問控制和 orm 對映等等功能!在後面的文章當中我們將繼續去分析描述器的實現原理。

描述器的基本用法

描述器是一個實現了 __get____set____delete__ 中至少一個方法的 Python 類。這些方法分別用於在屬性被訪問、設定或刪除時呼叫。當一個描述器被定義為一個類的屬性時,它可以控制該屬性的訪問、修改和刪除。

下面是一個示例,演示瞭如何定義一個簡單的描述器:

class Descriptor:
    def __get__(self, instance, owner):
        print(f"Getting {self.__class__.__name__}")
        return instance.__dict__.get(self.attrname)

    def __set__(self, instance, value):
        print(f"Setting {self.__class__.__name__}")
        instance.__dict__[self.attrname] = value

    def __delete__(self, instance):
        print(f"Deleting {self.__class__.__name__}")
        del instance.__dict__[self.attrname]

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

在這個例子中,我們定義了一個名為 Descriptor 的描述器類,它有三個方法:__get____set____delete__。當我們在另一個類中使用這個描述器時,這些方法將被呼叫,以控制該類的屬性的訪問和修改。

要使用這個描述器,我們可以在另一個類中將其定義為一個類屬性:

class MyClass:
    x = Descriptor()

現在,我們可以建立一個 MyClass 物件並訪問其屬性:

>>> obj = MyClass()
>>> obj.x = 1
Setting Descriptor
>>> obj.x
Getting Descriptor
1
>>> del obj.x
Deleting Descriptor
>>> obj.x
Getting Descriptor

在這個例子中,我們首先建立了一個 MyClass 物件,並將其 x 屬性設定為 1。然後,我們再次訪問 x 屬性時,會呼叫 __get__ 方法並返回 1。最後,我們刪除了 x 屬性,並再次訪問它時,會呼叫 __get__ 方法並返回 None。從上面的輸出結果可以看到對應的方法都被呼叫了,這是符合上面對描述器的定義的。如果一個類物件不是描述器,那麼在使用對應的屬性的時候是不會呼叫__get____set____delete__三個方法的。比如下面的程式碼:

class NonDescriptor(object):
    pass


class MyClass():

    nd = NonDescriptor()


if __name__ == '__main__':
    a = MyClass()
    print(a.nd)

上面的程式碼輸出結果如下所示:

<__main__.NonDescriptor object at 0x1012cce20>

從上面程式的輸出結果可以知道,當使用一個非描述器的類屬性的時候是不會呼叫對應的方法的,而是直接得到對應的物件。

描述器的實現原理

描述器的實現原理可以用以下三個步驟來概括:

  • 當一個類的屬性被訪問時,Python 直譯器會檢查該屬性是否是一個描述器。如果是,它會呼叫描述器的 __get__ 方法,並將該類的例項作為第一個引數,該例項所屬的類作為第二個引數,並將屬性名稱作為第三個引數傳遞給 __get__ 方法。

  • 當一個類的屬性被設定時,Python 直譯器會檢查該屬性是否是一個描述器。如果是,它會呼叫描述器的 __set__ 方法,並將該類的例項作為第一個引數,設定的值作為第二個引數,並將屬性名稱作為第三個引數傳遞給 __set__ 方法。

  • 當一個類的屬性被刪除時,Python 直譯器會檢查該屬性是否是一個描述器。如果是,它會呼叫描述器的 __delete__ 方法,並將該類的例項作為第一個引數和屬性名稱作為第二個引數傳遞給 __delete__ 方法。

在描述器的實現中,通常還會使用 __set_name__ 方法來在描述器被繫結到類屬性時設定屬性名稱。這使得描述器可以在被多個屬性使用時,正確地識別每個屬性的名稱。

現在來仔細瞭解一下上面的幾個函式的引數,我們以下面的程式碼為例子進行說明:


class Descriptor(object):

    def __set_name__(self, obj_type, attr_name):
        print(f"__set_name__ : {obj_type } {attr_name = }")
        return "__set_name__"

    def __get__(self, obj, obj_type):
        print(f"__get__ : {obj = } { obj_type = }")
        return "__get__"

    def __set__(self, instance, value):
        print(f"__set__ : {instance = } {value = }")
        return "__set__"

    def __delete__(self, obj):
        print(f"__delete__ : {obj = }")
        return "__delete__"


class MyClass(object):

    des = Descriptor()


if __name__ == '__main__':
    a = MyClass()
    _ = MyClass.des
    _ = a.des
    a.des = "hello"
    del a.des

上面的程式碼輸入結果如下所示:

__set_name__ : <class '__main__.MyClass'> attr_name = 'des'
__get__ : obj = None  obj_type = <class '__main__.MyClass'>
__get__ : obj = <__main__.MyClass object at 0x1054abeb0>  obj_type = <class '__main__.MyClass'>
__set__ : instance = <__main__.MyClass object at 0x1054abeb0> value = 'hello'
__delete__ : obj = <__main__.MyClass object at 0x1054abeb0>
  • __set_name__ 這個函式一共有兩個引數傳入的引數第一個引數是使用描述器的類,第二個引數是使用這個描述器的類當中使用的屬性名字,在上面的例子當中就是 "des" 。
  • __get__,這個函式主要有兩個引數,一個是使用屬性的物件,另外一個是物件的型別,如果是直接使用類名使用屬性的話,obj 就是 None,比如上面的 MyClass.des 。
  • __set__,這個函式主要有兩個引數一個是物件,另外一個是需要設定的值。
  • __delete__,這函式有一個引數,就是傳入的物件,比如 del a.des 傳入的就是物件 a 。

描述器的應用場景

描述器在 Python 中有很多應用場景。以下是其中的一些示例:

實現屬性訪問控制

透過使用描述器,可以實現對類屬性的訪問控制,例如只讀屬性、只寫屬性、只讀/只寫屬性等。透過在 __get____set__ 方法中新增相應的訪問控制邏輯,可以限制對類屬性的訪問和修改。

class ReadOnly:
    def __init__(self, value):
        self._value = value
    
    def __get__(self, instance, owner):
        return self._value
    
    def __set__(self, instance, value):
        raise AttributeError("Read only attribute")
        
class MyClass:
    read_only_prop = ReadOnly(42)
    writeable_prop = None
    
my_obj = MyClass()
print(my_obj.read_only_prop)  # 42
my_obj.writeable_prop = "hello"
print(my_obj.writeable_prop)  # hello
my_obj.read_only_prop = 100  # raises AttributeError

在上面的例子中,ReadOnly 描述器只實現了 __get__ 方法,而 __set__ 方法則丟擲了 AttributeError 異常,從而實現了只讀屬性的訪問控制。

實現資料驗證和轉換

描述器還可以用於實現資料驗證和轉換邏輯。透過在 __set__ 方法中新增資料驗證和轉換邏輯,可以確保設定的值符合某些特定的要求。例如,可以使用描述器來確保設定的值是整數、在某個範圍內、符合某個正規表示式等。

class Bounded:
    def __init__(self, low, high):
        self._low = low
        self._high = high
    
    def __get__(self, instance, owner):
        return self._value
    
    def __set__(self, instance, value):
        if not self._low <= value <= self._high:
            raise ValueError(f"Value must be between {self._low} and {self._high}")
        self._value = value

class MyClass:
    bounded_prop = Bounded(0, 100)

my_obj = MyClass()
my_obj.bounded_prop = 50
print(my_obj.bounded_prop)  # 50
my_obj.bounded_prop = 200  # raises ValueError

在上面的例子中,Bounded 描述器在 __set__ 方法中進行了數值範圍的檢查,如果值不在指定範圍內,則丟擲了 ValueError 異常。

實現延遲載入和快取

描述器還可以用於實現延遲載入和快取邏輯。透過在 __get__ 方法中新增邏輯,可以實現屬性的延遲載入,即當屬性第一次被訪問時才進行載入。此外,還可以使用描述器來實現快取邏輯,以避免重複計算。

class LazyLoad:
    def __init__(self, func):
        self._func = func

    def __get__(self, instance, owner):
        if instance is None:
            return self
        value = self._func(instance)
        setattr(instance, self._func.__name__, value)
        return value


class MyClass:
    def __init__(self):
        self._expensive_data = None

    @LazyLoad
    def expensive_data(self):
        print("Calculating expensive data...")
        self._expensive_data = [i ** 2 for i in range(10)]
        return self._expensive_data


my_obj = MyClass()
print(my_obj.expensive_data)  # Calculating expensive data... 
print(my_obj.expensive_data)

上面的程式的輸出結果如下所示:

Calculating expensive data...
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

從上面的結果可以看到,只有在第一次使用屬性的時候才呼叫函式,後續再次呼叫函式將不會再呼叫函式而是直接返回快取的結果。

實現 ORM 對映

ORM 的主要作用是把資料庫中的關係資料轉化為物件導向的資料,讓開發者可以透過編寫物件導向的程式碼來運算元據庫。ORM 技術可以把物件導向的程式語言和關聯式資料庫之間的對映關係抽象出來,開發者可以不用寫 SQL 語句,而是直接使用物件導向的語法進行資料庫操作。

我們現在需要實現一個功能,user.name 直接從資料庫的 user 表當中查詢 name 等於 user.name 的資料,user.name = "xxx" 根據 user 的主鍵 id 進行更新資料。這個功能我們就可以使用描述器實現,因為只需要瞭解如何使用描述器的,因此在下面的程式碼當中並沒有連線資料庫:

conn = dict()


class Field:

    def __set_name__(self, owner, name):
        self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
        print(f"{self.fetch = }")
        self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'
        print(f"{self.store = }")

    def __get__(self, obj, objtype=None):
        return conn.execute(self.fetch, [obj.key]).fetchone()[0]

    def __set__(self, obj, value):
        conn.execute(self.store, [value, obj.key])
        conn.commit()


class User:
    table = 'User'                    # Table name
    key = 'id'                       # Primary key
    name = Field()
    age = Field()

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


if __name__ == '__main__':
    u = User("Bob")

上面的程式輸出結果如下所示:

self.fetch = 'SELECT name FROM User WHERE id=?;'
self.store = 'UPDATE User SET name=? WHERE id=?;'
self.fetch = 'SELECT age FROM User WHERE id=?;'
self.store = 'UPDATE User SET age=? WHERE id=?;

從上面的輸出結果我們可以看到針對 name 和 age 兩個欄位的查詢和更新語句確實生成了,當我們呼叫 u.name = xxx 或者 u.age = xxx 的時候就執行 __set__ 函式,就會連線資料庫進行相應的操作了。

總結

在本篇文章當中主要給大家介紹了什麼是描述器以及我們能夠使用描述器來實現什麼樣的功能,事實上 python 是一個比較隨意的語言,因此我們可以利用很多有意思的語法做出黑多黑科技。python 語言本身也利用描述器實現了很多有意思的功能,比如 property、staticmethod 等等,這些內容我們在後面的文章當中再進行分析。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。

相關文章