你也許經常會聽到「描述符」這個概念,但是由於大多數的程式設計師很少會使用到他,所以可能你並不太清楚瞭解它的原理。
但是如果你想自己的事業來說更上一層的話,對於python的使用更加熟練的話,我認為你還是應該對描述符
的這個概念有一個清晰的瞭解,這對於你以後的發展有著巨大的幫助,也有利於你將來更深層次的python設計的理解。
儘管在開發的過程中,我們沒有直接的使用過描述符,但是它在底層的運用卻是十分頻繁的存在。例如下面的這些:
function
、bound method
、unbound method
- 裝是器
property
、staticmethod
、classmethod
這些是不是都很熟悉?
其實這些都與描述符有著千絲萬縷的聯絡,這樣吧,我們通過下面的文章來探討一下描述符背後的工作原理吧。
什麼是描述符?
在我們瞭解什麼是描述符前,我們可以先找一個例子來看一下
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__
被呼叫,引數obj
是Person
例項,type
是type(Person)
- 當呼叫
Person.age
時,__get__
被呼叫,引數obj
是None
,type
是type(Person)
- 當呼叫
p1.age = 25
時,__set__
被呼叫,引數obj
是Person
例項,value
是25 - 當呼叫
p1.age = -1
時,__set__
沒有通過校驗,丟擲ValueError
其中,呼叫 __set__
傳入的引數,我們比較容易理解,但是對於 __get__
方法,通過類或例項呼叫,傳入的引數是不同的,這是為什麼?
這就需要我們瞭解一下描述符的工作原理。
描述符的工作原理
要解釋描述符的工作原理,首先我們需要先從屬性的訪問說起。
在開發時,不知道你有沒有想過這樣一個問題:通常我們寫這樣的程式碼 a.b
,其背後到底發生了什麼?
這裡的 a
和 b
可能存在以下情況:
a
可能是一個類,也可能是一個例項,我們這裡統稱為物件b
可能是一個屬性,也可能是一個方法,方法其實也可以看做是類的屬性
其實,無論是以上哪種情況,在 Python 中,都有一個統一的呼叫邏輯:
- 先呼叫
__getattribute__
嘗試獲得結果 - 如果沒有結果,呼叫
__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__
,因為它是所有屬性查詢的入口,它內部實現的屬性查詢順序是這樣的:
- 要查詢的屬性,在類中是否是一個描述符
- 如果是描述符,再檢查它是否是一個資料描述符
- 如果是資料描述符,則呼叫資料描述符的
__get__
- 如果不是資料描述符,則從
__dict__
中查詢 - 如果
__dict__
中查詢不到,再看它是否是一個非資料描述符 - 如果是非資料描述符,則呼叫非資料描述符的
__get__
- 如果也不是一個非資料描述符,則從類屬性中查詢
- 如果類中也沒有這個屬性,丟擲
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__', ...]
複製程式碼
看到了嗎?A
的 foo
方法其實實現了 __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
例項進行初始化時,就可以校驗這些屬性是否符合預定義的規則了。
我們再來看一下,在開發時經常看到的 function
、unbound method
、bound 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 method
和 unbound method
的區別就在於呼叫方的型別是什麼,如果是一個例項,那麼這個 function
就是一個 bound method
,否則它是一個 unbound method
。
property/staticmethod/classmethod
我們再來看 property
、staticmethod
、classmethod
。
這些裝飾器的實現,預設是 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__
中的屬性查詢順序,例項屬性優先訪問。
最後我們分析了 function
和 method
的區別,以及使用 Python 描述符也可以實現 property
、staticmethod
、classmethod
裝飾器。
Python 描述符提供了強大的屬性訪問控制功能,我們可以在需要對屬性進行復雜控制的場景中去使用它。
本作品採用《CC 協議》,轉載必須註明作者和本文連結