tft每日頭條

 > 生活

 > python技能詳解

python技能詳解

生活 更新时间:2024-07-22 16:17:58

描述符是 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

裡面的細節後面會講到,現在你隻需要注意以下幾點:

  • 它實現了 __get__ 用于取值,也實現了 __set__ 用于賦值。因此它是一個描述符類。
  • 在 __set__ 中對輸入值 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魔法方法漫遊指南

看完文章想吐槽?歡迎留言告訴我!

python技能詳解(Python魔法方法漫遊指南)1

python技能詳解(Python魔法方法漫遊指南)2

,

更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!

查看全部

相关生活资讯推荐

热门生活资讯推荐

网友关注

Copyright 2023-2024 - www.tftnews.com All Rights Reserved