論python描述符的意義

python_sannian發表於2020-12-19

你也許經常會聽到「描述符」這個概念,但是由於大多數的程式設計師很少會使用到他,所以可能你並不太清楚瞭解它的原理。

但是如果你想自己的事業來說更上一層的話,對於python的使用更加熟練的話,我認為你還是應該對描述符的這個概念有一個清晰的瞭解,這對於你以後的發展有著巨大的幫助,也有利於你將來更深層次的python設計的理解。

儘管在開發的過程中,我們沒有直接的使用過描述符,但是它在底層的運用卻是十分頻繁的存在。例如下面的這些:

  • functionbound methodunbound method
  • 裝是器propertystaticmethodclassmethod
    這些是不是都很熟悉?
    其實這些都與描述符有著千絲萬縷的聯絡,這樣吧,我們通過下面的文章來探討一下描述符背後的工作原理吧。

什麼是描述符?

在我們瞭解什麼是描述符前,我們可以先找一個例子來看一下

class A:
    x = 10

print(A.x) # 10

這個例子很簡單,我們先在類A中定義一個類屬性x,然後得出它的值。
除了這種直接定義類屬性的方法外,我們還可以這樣去定義一個類屬性:

class Ten:
    def __get__(self, obj, objtype=None):
        return 10

class A:
    x = Ten()   # 屬性換成了一個類

print(A.x) # 10

我們可以發現,這回的類屬性x不是一個具體的值了,而是一個類Ten,通過這個Ten定義了一個__get__方法,返回具體的值。

因此可得出:在python中,我們可以把一個類的屬性,託管給一個類,而這樣的屬性就是一個描述符
簡而言之,描述符是一個繫結行為屬性

而這又有著什麼意思呢?
回想,我們在開發時,一般情況下,會將行為叫做什麼?行為即一個方法。

所以我們也可以將描述符理解為:物件的屬性並非一個具體的值,而是交給了一個方法去定義。

可以想像一下,如果我們用一個方法去定義一個屬性,這麼做有什麼好處?

有了方法,我們就可以在方法內實現自己的邏輯,最簡單的,我們可以根據不同的條件,在方法內給屬性賦予不同的值,就像下面這樣:

class Age:
    def __get__(self, obj, objtype=None):
        if obj.name == 'zhangsan':
            return 20
        elif obj.name == 'lisi':
            return 25
        else:
            return ValueError("unknow")

class Person:

    age = Age()

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

p1 = Person('zhangsan')
print(p1.age)   # 20

p2 = Person('lisi')
print(p2.age)   # 25

p3 = Person('wangwu')
print(p3.age)   # unknow

這個例子中,age 類屬性被另一個類託管了,在這個類的 __get__ 中,它會根據 Person 類的屬性 name,決定 age 是什麼值。

通過這樣一個例子,我們可以看到,通過描述符的使用,我們可以輕易地改變一個類屬性的定義方式。

描述符協議

瞭解了描述符的定義,現在我們把重點放到託管屬性的類上。

其實,一個類屬性想要託管給一個類,這個類內部實現的方法不能是隨便定義的,它必須遵守「描述符協議」,也就是要實現以下幾個方法:

  • __get__(self, obj, type=None) -> value
  • __set__(self, obj, value) -> None
  • __delete__(self, obj) -> None

只要是實現了以上幾個方法的其中一個,那麼這個類屬性就可以稱作描述符。

另外,描述符又可以分為「資料描述符」和「非資料描述符」:

  • 只定義了 __get___,叫做非資料描述符
  • 除了定義 __get__ 之外,還定義了 __set____delete__,叫做資料描述符

它們兩者有什麼區別,我會在下面詳述。

現在我們來看一個包含 __get____set__ 方法的描述符例子:

# coding: utf8

class Age:

    def __init__(self, value=20):
        self.value = value

    def __get__(self, obj, type=None):
        print('call __get__: obj: %s type: %s' % (obj, type))
        return self.value

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError("age must be greater than 0")
        print('call __set__: obj: %s value: %s' % (obj, value))
        self.value = value

class Person:

    age = Age()

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

p1 = Person('zhangsan')
print(p1.age)
# call __get__: obj: <__main__.Person object at 0x1055509e8> type: <class '__main__.Person'>
# 20

print(Person.age)
# call __get__: obj: None type: <class '__main__.Person'>
# 20

p1.age = 25
# call __set__: obj: <__main__.Person object at 0x1055509e8> value: 25

print(p1.age)
# call __get__: obj: <__main__.Person object at 0x1055509e8> type: <class '__main__.Person'>
# 25

p1.age = -1
# ValueError: age must be greater than 0

在這例子中,類屬性 age 是一個描述符,它的值取決於 Age 類。

從輸出結果來看,當我們獲取或修改 age 屬性時,呼叫了 Age__get____set__ 方法:

  • 當呼叫 p1.age 時,__get__ 被呼叫,引數 objPerson 例項,typetype(Person)
  • 當呼叫 Person.age 時,__get__ 被呼叫,引數 objNonetypetype(Person)
  • 當呼叫 p1.age = 25時,__set__ 被呼叫,引數 objPerson 例項,value 是25
  • 當呼叫 p1.age = -1時,__set__ 沒有通過校驗,丟擲 ValueError

其中,呼叫 __set__ 傳入的引數,我們比較容易理解,但是對於 __get__ 方法,通過類或例項呼叫,傳入的引數是不同的,這是為什麼?

這就需要我們瞭解一下描述符的工作原理。

描述符的工作原理

要解釋描述符的工作原理,首先我們需要先從屬性的訪問說起。

在開發時,不知道你有沒有想過這樣一個問題:通常我們寫這樣的程式碼 a.b,其背後到底發生了什麼?

這裡的 ab 可能存在以下情況:

  1. a 可能是一個類,也可能是一個例項,我們這裡統稱為物件
  2. b 可能是一個屬性,也可能是一個方法,方法其實也可以看做是類的屬性

其實,無論是以上哪種情況,在 Python 中,都有一個統一的呼叫邏輯:

  1. 先呼叫 __getattribute__ 嘗試獲得結果
  2. 如果沒有結果,呼叫 __getattr__

用程式碼表示就是下面這樣:

def getattr_hook(obj, name):
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name) 

我們這裡需要重點關注一下 __getattribute__,因為它是所有屬性查詢的入口,它內部實現的屬性查詢順序是這樣的:

  1. 要查詢的屬性,在類中是否是一個描述符
  2. 如果是描述符,再檢查它是否是一個資料描述符
  3. 如果是資料描述符,則呼叫資料描述符的 __get__
  4. 如果不是資料描述符,則從 __dict__ 中查詢
  5. 如果 __dict__ 中查詢不到,再看它是否是一個非資料描述符
  6. 如果是非資料描述符,則呼叫非資料描述符的 __get__
  7. 如果也不是一個非資料描述符,則從類屬性中查詢
  8. 如果類中也沒有這個屬性,丟擲 AttributeError 異常

寫成程式碼就是下面這樣:

# 獲取一個物件的屬性
def __getattribute__(obj, name):
    null = object()
    # 物件的型別 也就是例項的類
    objtype = type(obj)
    # 從這個類中獲取指定屬性
    cls_var = getattr(objtype, name, null)
    # 如果這個類實現了描述符協議
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            # 優先從資料描述符中獲取屬性
            return descr_get(cls_var, obj, objtype)
    # 從例項中獲取屬性
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]
    # 從非資料描述符獲取屬性
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)
    # 從類中獲取屬性
    if cls_var is not null:
        return cls_var
    # 丟擲 AttributeError 會觸發呼叫 __getattr__
    raise AttributeError(name)

如果不好理解,你最好寫一個程式測試一下,觀察各種情況下的屬性的查詢順序。

到這裡我們可以看到,在一個物件中查詢一個屬性,都是先從 __getattribute__ 開始的。

__getattribute__ 中,它會檢查這個類屬性是否是一個描述符,如果是一個描述符,那麼就會呼叫它的 __get__ 方法。但具體的呼叫細節和傳入的引數是下面這樣的:

  • 如果 a 是一個例項,呼叫細節為:
type(a).__dict__['b'].__get__(a, type(a))
複製程式碼
  • 如果 a 是一個,呼叫細節為:
a.__dict__['b'].__get__(None, a)
複製程式碼

所以我們就能看到上面例子輸出的結果。

資料描述符和非資料描述符

瞭解了描述符的工作原理,我們繼續來看資料描述符和非資料描述符的區別。

從定義上來看,它們的區別是:

  • 只定義了 __get___,叫做非資料描述符
  • 除了定義 __get__ 之外,還定義了 __set____delete__,叫做資料描述符

此外,我們從上面描述符呼叫的順序可以看到,在物件中查詢屬性時,資料描述符要優先於非資料描述符呼叫。

在之前的例子中,我們定義了 __get____set__,所以那些類屬性都是資料描述符

我們再來看一個非資料描述符的例子:

class A:

    def __init__(self):
        self.foo = 'abc'

    def foo(self):
        return 'xyz'

print(A().foo)  # 輸出什麼?
複製程式碼

這段程式碼,我們定義了一個相同名字的屬性和方法 foo,如果現在執行 A().foo,你覺得會輸出什麼結果?

答案是 abc

為什麼列印的是例項屬性 foo 的值,而不是方法 foo 呢?

這就和非資料描述符有關係了。

我們執行 dir(A.foo),觀察結果:

print(dir(A.foo))
# [... '__get__', '__getattribute__', ...]
複製程式碼

看到了嗎?Afoo 方法其實實現了 __get__,我們在上面的分析已經得知:只定義 __get__ 方法的物件,它其實是一個非資料描述符,也就是說,我們在類中定義的方法,其實本身就是一個非資料描述符。

所以,在一個類中,如果存在相同名字的屬性和方法,按照上面所講的 __getattribute__ 中查詢屬性的順序,這個屬性就會優先從例項中獲取,如果例項中不存在,才會從非資料描述符中獲取,所以在這裡優先查詢的是例項屬性 foo 的值。

到這裡我們可以總結一下關於描述符的相關知識點:

  • 描述符必須是一個類屬性
  • __getattribute__ 是查詢一個屬性(方法)的入口
  • __getattribute__ 定義了一個屬性(方法)的查詢順序:資料描述符、例項屬性、非資料描述符、類屬性
  • 如果我們重寫了 __getattribute__ 方法,會阻止描述符的呼叫
  • 所有方法其實都是一個非資料描述符,因為它定義了 __get__

描述符的使用場景

瞭解了描述符的工作原理,那描述符一般用在哪些業務場景中呢?

在這裡我用描述符實現了一個屬性校驗器,你可以參考這個例子,在類似的場景中去使用它。

首先我們定義一個校驗基類 Validator,在 __set__ 方法中先呼叫 validate 方法校驗屬性是否符合要求,然後再對屬性進行賦值。

class Validator:

    def __init__(self):
        self.data = {}

    def __get__(self, obj, objtype=None):
        return self.data[obj]

    def __set__(self, obj, value):
        # 校驗通過後再賦值
        self.validate(value)
        self.data[obj] = value

    def validate(self, value):
        pass    
複製程式碼

接下來,我們定義兩個校驗類,繼承 Validator,然後實現自己的校驗邏輯。


class Number(Validator):

    def __init__(self, minvalue=None, maxvalue=None):
        super(Number, self).__init__()
        self.minvalue = minvalue
        self.maxvalue = maxvalue

    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f'Expected {value!r} to be an int or float')
        if self.minvalue is not None and value < self.minvalue:
            raise ValueError(
                f'Expected {value!r} to be at least {self.minvalue!r}'
            )
        if self.maxvalue is not None and value > self.maxvalue:
            raise ValueError(
                f'Expected {value!r} to be no more than {self.maxvalue!r}'
            )

class String(Validator):

    def __init__(self, minsize=None, maxsize=None):
        super(String, self).__init__()
        self.minsize = minsize
        self.maxsize = maxsize

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be an str')
        if self.minsize is not None and len(value) < self.minsize:
            raise ValueError(
                f'Expected {value!r} to be no smaller than {self.minsize!r}'
            )
        if self.maxsize is not None and len(value) > self.maxsize:
            raise ValueError(
                f'Expected {value!r} to be no bigger than {self.maxsize!r}'
            )
複製程式碼

最後,我們使用這個校驗類:

class Person:

    # 定義屬性的校驗規則 內部用描述符實現
    name = String(minsize=3, maxsize=10)
    age = Number(minvalue=1, maxvalue=120)

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

# 屬性符合規則
p1 = Person('zhangsan', 20)
print(p1.name, p1.age)

# 屬性不符合規則
p2 = person('a', 20)
# ValueError: Expected 'a' to be no smaller than 3
p3 = Person('zhangsan', -1)
# ValueError: Expected -1 to be at least 1
複製程式碼

現在,當我們對 Person 例項進行初始化時,就可以校驗這些屬性是否符合預定義的規則了。

我們再來看一下,在開發時經常看到的 functionunbound methodbound method 它們之間到底有什麼區別?

來看下面這段程式碼:

class A:

    def foo(self):
        return 'xyz'

print(A.__dict__['foo']) # <function foo at 0x10a790d70>
print(A.foo)     # <unbound method A.foo>
print(A().foo)   # <bound method A.foo of <__main__.A object at 0x10a793050>>
複製程式碼

從結果我們可以看出它們的區別:

  • function 準確來說就是一個函式,並且它實現了 __get__ 方法,因此每一個 function 都是一個非資料描述符,而在類中會把 function 放到 __dict__ 中儲存
  • function 被例項呼叫時,它是一個 bound method
  • function 被類呼叫時, 它是一個 unbound method

function 是一個非資料描述符,我們之前已經講到了。

bound methodunbound method 的區別就在於呼叫方的型別是什麼,如果是一個例項,那麼這個 function 就是一個 bound method,否則它是一個 unbound method

property/staticmethod/classmethod

我們再來看 propertystaticmethodclassmethod

這些裝飾器的實現,預設是 C 來實現的。

其實,我們也可以直接利用 Python 描述符的特性來實現這些裝飾器,

property 的 Python 版實現:

class property:

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self.fget
        if self.fget is None:
            raise AttributeError(), "unreadable attribute"
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError, "can't set attribute"
        return self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError, "can't delete attribute"
        return self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)
複製程式碼

staticmethod 的 Python 版實現:

class staticmethod:

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

    def __get__(self, obj, objtype=None):
        return self.func
複製程式碼

classmethod 的 Python 版實現:

class classmethod:

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

    def __get__(self, obj, klass=None):
        if klass is None:
            klass = type(obj)
        def newfunc(*args):
            return self.func(klass, *args)
        return newfunc
複製程式碼

除此之外,你還可以實現其他功能強大的裝飾器。

由此可見,通過描述符我們可以實現強大而靈活的屬性管理功能,對於一些要求屬性控制比較複雜的場景,我們可以選擇用描述符來實現。

總結

這篇文章我們主要講了 Python 描述符的工作原理。

首先,我們從一個簡單的例子瞭解到,一個類屬性是可以託管給另外一個類的,這個類如果實現了描述符協議方法,那麼這個類屬性就是一個描述符。此外,描述符又可以分為資料描述符和非資料描述符。

之後我們又分析了獲取一個屬性的過程,一切的入口都在 __getattribute__ 中,這個方法定義了尋找屬性的順序,其中例項屬性優先於資料描述符呼叫,資料描述符要優先於非資料描述符呼叫。

另外我們又瞭解到,方法其實就是一個非資料描述符,如果我們在類中定義了相同名字的例項屬性和方法,按照 __getattribute__ 中的屬性查詢順序,例項屬性優先訪問。

最後我們分析了 functionmethod 的區別,以及使用 Python 描述符也可以實現 propertystaticmethodclassmethod 裝飾器。

Python 描述符提供了強大的屬性訪問控制功能,我們可以在需要對屬性進行復雜控制的場景中去使用它。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章