描述符是 Python 語言中一個強大的特性,它隐藏在編程語言的底層,為許多神奇的魔法提供了動力。
如果你認為它隻是個花裡胡哨、且不太能用到的高級主題,那麼本文将幫助你了解為什麼描述符是一個非常有意思、并且讓代碼變簡潔的優雅工具。
一個例子在探讨枯燥的理論前,讓我們從一個簡單的例子來了解描述符。
某日,假設你需要一個類,來記錄數學考試的分數。
這個需求非常簡單,你10秒鐘就寫好了代碼:
class Score:
def __init__(self, math):
self.math = math
但是稍後你就發現了問題:分數為負值是沒有意義的。
但顯然上面的代碼對輸入參數沒有任何檢查:
>>> score = Score(-90)
>>> score.math
-90
因此你修改代碼,使得初始化時檢查輸入值:
class Score:
def __init__(self, math):
if math < 0:
raise ValueError('math score must >= 0')
self.math = math
但這樣也沒解決問題,因為分數雖然在初始化時不能為負,但後續修改時還是可以輸入非法值:
>>> score = Score(90)
>>> score.math
90
>>> score.math = -100
>>> score.math
-100
幸運的是,有内置裝飾器 @property 可以解決此問題。
如果你以前沒用過 @property ,下面就是個例子:
class Score:
def __init__(self, math):
self.math = math
@property
def math(self):
# self.math 取值
return self._math
@math.setter
def math(self, value):
# self.math 賦值
if value < 0:
raise ValueError('math score must >= 0')
self._math = value
試驗下:
>>> score = Score(90)
>>> score.math
90
>>> score.math = 10
>>> score.math
10
>>> score.math = -10
Traceback (most recent call last):
File "...", line 20, in math
raise ValueError('math score must >= 0')
ValueError: math score must >= 0
簡單來說就是 @property 接管了對 math 屬性的直接訪問,而是将對應的取值賦值轉交給 @property 封裝的方法。
雖然 @property 已經表現得比較完美了,但是它最大的問題是不能重用。
如果要同時保存數學、英語、生物三門課程的成績,這個類就會變成這樣:
class Score:
def __init__(self, math, english, bio):
self.math = math
self.english = english
self.bio = bio
@property
def math(self):
return self._math
@math.setter
def math(self, value):
if value < 0:
raise ValueError('math score must >= 0')
self._math = value
@property
def english(self):
return self._english
@english.setter
def english(self, value):
if value < 0:
raise ValueError('english score must >= 0')
self._english = value
@property
def bio(self):
return self._bio
@bio.setter
def bio(self, value):
if value < 0:
raise ValueError('bio score must >= 0')
self._bio = value
雖然外部調用時依然簡潔,但掩蓋不了類内部的臃腫。
描述符就可以很好的解決上面的代碼重用問題。
描述符這個詞聽起來很玄乎,其實就是實現了魔法方法 __get__ 、 __set__ 、 __delete__ 的類(根據需求,可以隻實現其中一部分方法,不一定三個都實現)。一但實現了描述符協議,那麼這個類就具有非常強大的特性了。
比如上面這個檢查非負的需求,寫成描述符類就是這樣:
class NonNegative:
"""檢查輸入值不能為負"""
def __init__(self, name):
self.name = name
def __get__(self, instance, owner=None):
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f'{self.name} score must >= 0')
instance.__dict__[self.name] = value
裡面的細節後面會講到,現在你隻需要注意以下幾點:
像這樣來使用描述符:
class Score:
math = NonNegative('math')
english = NonNegative('english')
bio = NonNegative('bio')
def __init__(self, math, english, bio):
self.math = math
self.english = english
self.bio = bio
現在,math 、english 、 bio 三個屬性均被描述符接管。也就是說,對它們進行點符的訪問實際上會執行描述符類中對應的 __get__ 、 __set__ 方法。
試試其功能,與 @property 是類似的:
>>> score.math = 10
>>> score.math
10
>>> score.math = -10
Traceback (most recent call last):
File "...", line 1, in <module>
score.math = -10
ValueError: math score must >= 0
功能雖然相同,但是 Score 類的定義明顯清爽了不少。
描述符類型在開始讨論本節之前,讓我們先回顧一點基礎知識。
Python 的類具有一個特殊的字典叫 __dict__ ,它被稱作命名空間,說白了就是一個存放對象所有屬性的字典。
對屬性的引用被解釋器轉換為對該字典的查找,比如 a.x 相當于 a.__dict__['x'] 。看下面的例子:
class Foo:
def __init__(self):
self.a = 10
self.b = 20
foo = Foo()
print(foo.__dict__)
# {'a': 10, 'b': 20}
foo.__dict__['c'] = 30
print(foo.__dict__)
# {'a': 10, 'b': 20, 'c': 30}
print(foo.c)
# 30
可以看到在程序運行期間,你可以動态的向 __dict__ 中插入新的值,使得對象具有新的屬性。
了解完這個,我們再回到描述符的話題。
描述符可以用一句話概括:描述符是可重用的屬性,它把函數調用僞裝成對屬性的訪問。
描述符可以隻實現 __get__ 方法:
class Ten: """非數據描述符""" def __get__(self, instance, owner=None): print(self) print(instance) print(owner) return 10 class Foo: """應用了描述符的類""" ten = Ten() foo = Foo() print(foo.ten) # 輸出: # <__main__.Ten object at 0x0000023B4B074EB0> # <__main__.Foo object at 0x0000023B4B074940> # <class '__main__.Foo'> # 10
__get__ 方法中有三個參數:
- self :描述符實例
- instance :描述符所附加的對象的實例
- owner :描述符所附加的對象的類型
這種隻實現 __get__ 方法的叫做非數據描述符。
如果描述符定義了 __set__ 或者 __delete__ ,則被叫做數據描述符。比如:
class Five: """數據描述符""" def __get__(self, instance, owner=None): return 5 def __set__(self, instance, value): raise AttributeError('Cannot change this value')
__set__ 方法中也有三個參數:
- self :描述符實例
- instance :描述符所附加的對象的實例
- value :當前準備賦的值
數據描述符和非數據描述符不僅僅是名字上的區别,更重要的是在查找鍊上的位置不同。
當訪問對象的某個屬性時,其查找鍊簡單來說就是:
- 首先在對應的數據描述符中查找此屬性。
- 如果失敗,則在對象的 __dict__ 中查找此屬性。
- 如果失敗,則在非數據描述符中查找此屬性。
- 如果失敗,再去别的地方查找。(本文就不展開了)
問題來了:根據以上查找規則,上面定義的兩個描述符 Ten 和 Five ,哪個能作為隻讀屬性?
答案是 Five 。
由于 Ten 沒有設置 __set__ 方法,因此對屬性的賦值和取值會被對象的 __dict__ 的屬性所覆蓋:
class Ten: def __get__(self, instance, owner=None): print('calling __get__') return 10 class Foo: ten = Ten() foo = Foo() print(foo.ten) # calling __get__ # 10 foo.ten = 20 print(foo.ten) # 20
但是由于數據描述符的查找要早于對象的 __dict__ ,因此攔截了對屬性的訪問:
共享陷阱
class Five: def __get__(self, instance, owner=None): print('calling __get__') return 5 def __set__(self, instance, value): raise AttributeError('Cannot change this value') class Bar: five = Five() bar = Bar() print(bar.five) # calling __get__ # 5 bar.five = 20 # Traceback (most recent call last): # File "...", line 23, in __set__ # raise AttributeError('Cannot change this value') # AttributeError: Cannot change this value
描述符有一個非常迷惑人的特性:在同一個類中每個描述符僅實例化一次,也就是說所有實例共享該描述符實例。
看下面這個例子就明白了:
class NonNegative: """檢查輸入值不能為負""" def __get__(self, instance, owner=None): return self.value def __set__(self, instance, value): if value < 0: raise ValueError(f'{self.name} score must >= 0') # 數據被綁定在描述符實例上 # 由于描述符實例是共享的 # 因此數據也隻有一份被共享 self.value = value class Score: math = NonNegative() def __init__(self, math): self.math = math score_1 = Score(10) score_2 = Score(20) # 所有對象共享同一個描述符實例 print(score_1.math, score_2.math) # 輸出: 20 20 score_1.math = 30 print(score_1.math, score_2.math) # 輸出: 30 30
修改某個實例的值後,所有實例跟着一起改變了。這通常不是你想要的結果。
要破除這種共享狀态,比較好的解決方式是将數據綁定到使用描述符的對象實例上,就像本文開頭的例子所做的那樣:
class NonNegative: """檢查輸入值不能為負""" def __init__(self, name): self.name = name def __get__(self, instance, owner=None): return instance.__dict__.get(self.name) def __set__(self, instance, value): if value < 0: raise ValueError(f'{self.name} score must >= 0') # 數據被綁定在描述符附加的對象上 # 因此保持了對象之間的數據隔離 instance.__dict__[self.name] = value class Score: math = NonNegative('math') def __init__(self, math): self.math = math
唯一有些不爽的是,為了給數據屬性規定一個名字,在定義描述符的時候 NonNegative('math') 還得傳遞 math 這個名字進去,有點多此一舉。
幸好 Python 3.6 為描述符引入了 __set_name__ 方法,現在你可以這樣:
應用場景
class NonNegative: # 注意這裡 # __init__ 也沒有了 def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner=None): return instance.__dict__.get(self.name) def __set__(self, instance, value): if value < 0: raise ValueError(f'{self.name} score must >= 0') instance.__dict__[self.name] = value class Score: # NonNegative() 不需要帶參數以規定屬性名了 math = NonNegative() def __init__(self, math): self.math = math
上面關于賦值檢查的 NonNegative 已經展示描述符的其中一種用途了:托管屬性并複用代碼,保持簡潔。
接下來看看另外一些描述符的典型應用場景。
緩存假設你有一個耗時很長的操作,需要緩存其計算結果以便後續直接使用(而不是每次都傻乎乎的重新計算)。
描述符就可以實現這個緩存功能:
class Cache: """緩存描述符""" def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, instance, owner=None): instance.__dict__[self.name] = self.func(instance) return instance.__dict__[self.name] from time import sleep class Foo: @Cache def bar(self): sleep(5) return 'Just sleep 5 sec...' foo = Foo() # 第一次執行耗時約5秒 print(foo.bar) # 第二次執行瞬間返回 print(foo.bar)
讓我們花點時間看看到底發生了什麼。
這個緩存功能得以實現的原因,還是在于 Cache 是個非數據描述符,還記得嗎?非數據描述符的查找順序要晚于 __dict__ ,因此使得附加描述符的對象有機會在 __dict__ 中寫入數據,從而覆蓋掉描述符中的耗時運算。
如果給 Cache 增加 __set__ 方法,還能實現緩存能力嗎?歡迎自行嘗試,并在評論區告訴我。
其次,這裡以裝飾器的形式應用了描述符。讀過我的舊文裝飾器入門的讀者都知道,裝飾器就是語法糖。
上面這個裝飾器:
@Cache def bar(self): ...
等效于下面這句:
bar = Cache(bar)
因此完成了描述符的定義(同時将方法轉化成了屬性),并且将原函數 bar 傳遞給了描述符的參數 func 。
驗證器讓我們看看官方文檔給出的例子,如何用描述符實現一個規範的驗證器。
首先定義一個僅具有基礎功能的驗證器抽象基類:
from abc import ABC, abstractmethod class Validator(ABC): """驗證器抽象基類""" def __set_name__(self, owner, name): self.private_name = '_' name def __get__(self, instance, owner=None): return getattr(instance, self.private_name) def __set__(self, instance, value): self.validate(value) setattr(instance, self.private_name, value) @abstractmethod def validate(self, value): pass
Validator 描述符類定義了 validate 方法,用于子類覆寫以執行具體的驗證邏輯。__get__ 和 __set__ 表明這是類是數據描述符。
寫好這個基類,接下來就可以寫實際用到的驗證器子類了。
比如寫兩個子類:
class OneOf(Validator): """字符串單選驗證器""" def __init__(self, *options): self.options = set(options) def validate(self, value): if value not in self.options: raise ValueError(f'Expected {value!r} to be one of {self.options!r}') class Number(Validator): """數值類型驗證器""" def validate(self, value): if not isinstance(value, (int, float)): raise TypeError(f'Expected {value!r} to be an int or float')
OneOf 用于确保輸入值為固定的某種類型。Number 用于确保輸入值必須為數值型。它們均以 Validator 為父類,并實現了 validate 方法。
像這樣使用它們:
class Component: kind = OneOf('wood', 'metal', 'plastic') quantity = Number() def __init__(self, kind, quantity): self.kind = kind self.quantity = quantity
實際操作試試效果:
>>> Component('abc', 100) # 失敗,'abc' 不在選擇範圍中 ValueError: Expected 'abc' to be one of {'metal', 'plastic', 'wood'} >>> Component('wood', 'notNum') # 失敗,'notNum' 不是數值型 TypeError: Expected 'notNum' to be an int or float >>> Component('wood', 100) # 成功,參數均合法 Out[25]: <__main__.Component at 0x13df8059640>
再試試賦值:
>>> c = Component('wood', 100) >>> c.kind = 'abc' ValueError: Expected 'abc' to be one of {'metal', 'plastic', 'wood'} >>> c.kind 'wood' >>> c.kind = 'metal' >>> c.kind 'metal' >>> c.quantity = 'haha' TypeError: Expected 'haha' to be an int or float >>> c.quantity = 20 >>> c.quantity 20
很順利的實現了驗證器的功能。
學過 Django 的同學看着眼熟不,是不是有點 Django 中的驗證器和字段的意思了?除此之外,很多底層的功能都可以用描述符進行純 Python 的實現,比如屬性、方法、靜态方法、類方法等等。
完整例子見文檔描述符指南。
總結通過本文,你應該已經感受到描述符的強大功能,并且大緻明白應該在哪些場合運用它了:
- 描述符就是可複用的屬性,它将函數調用僞裝成對屬性的訪問。
- 數據描述符和非數據描述符,在查找鍊中位于不同的優先級。
- 描述符在屬性托管、緩存和驗證器等場景下應用較為常見。
沒騙你吧,描述符絕對是個很有意思的特性,也不是炫技用的花拳繡腿。合理運用,可以讓你的代碼簡潔而優雅。
參考
- implementing-descriptors
- descriptor
- why-use-python-descriptors
本系列文章開源發布于 Github,傳送門:Python魔法方法漫遊指南
看完文章想吐槽?歡迎留言告訴我!
,
更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!