本文分為如下部分
- 引言——用@property批量使用的例子來引出描述器的功能
- 描述器的基本理論及簡單實例
- 描述器的調用機制
- 描述器的細節
- 實例方法、靜態方法和類方法的描述器原理
- property裝飾器的原理
- 描述器的應用
- 參考資料
引言
前面python面向對象的文章中我們講到過,我們可以用@property裝飾器將方法包裝成屬性,這樣的屬性,相比於其他屬性有一個優點就是可以在對屬性賦值時,進行變量檢查,舉例代碼如下、
class A: def __init__(self, name, score): self.name = name # 普通屬性 self._score = score @property def score(self): return self._score @score.setter def score(self, value): print('setting score here') if isinstance(value, int): self._score = value else: print('please input an int') a = A('Bob',90) a.name # 'Bob' a.score # 90 a.name = 1 a.name # 1 ,名字本身不應該允許賦值為數字,但是這里無法控制其賦值 a.score = 83 a.score # 83,當賦值為數值型的時候,可以順利運行 a.score = 'bob' # please input an int a.score # 83,賦值為字符串時,score沒有被改變
當我們有很多這樣的屬性時,如果每一個都去使用@property,代碼就會過於冗余。如下
class A: def __init__(self, name, score, age): self.name = name # 普通屬性 self._score = score self._age = age @property def score(self): return self._score @score.setter def score(self, value): print('setting score here') if isinstance(value, int): self._score = value else: print('please input an int') @property def age(self): return self._age @age.setter def age(self, value): print('setting age here') if isinstance(value, int): self._age = value else: print('please input an int') a = A('Bob', 90, 20)
因為每一次檢驗的方法都是一樣的,所以最好有方法可以批量實現這件事,只寫一次if isinstance
。描述器就可以用來實現這件事。
為了能夠更清楚地理解描述器如何實現,我們先跳開這個話題,先講一講描述器的基本理論。
描述器基本理論及簡單實例
描述器功能強大,應用廣泛,它可以控制我們訪問屬性、方法的行為,是@property、super、靜態方法、類方法、甚至屬性、實例背后的實現機制,是一種比較底層的設計,因此理解起來也會有一些困難。
定義:從描述器的創建來說,一個類中定義了__get__
、__set__
、__delete__
中的一個或幾個,這個類的實例就可以叫做一個描述器。
為了能更真切地體會描述器是什么,我們先看一個最簡單的例子,這個例子不實現什么功能,只是使用了描述器
# 創建一個描述器的類,它的實例就是一個描述器 # 這個類要有__get__ __set__ 這樣的方法 # 這種類是當做工具使用的,不單獨使用 class M: def __init__(self, x=1): self.x = x def __get__(self, instance, owner): return self.x def __set__(self, instance, value): self.x = value # 調用描述器的類 class AA: m = M() # m就是一個描述器 aa = AA() aa.m # 1 aa.m = 2 aa.m # 2
我們分析一下上面這個例子
- 創建aa實例和普通類沒什么區別,我們從
aa.m
開始看 aa.m
是aa實例調用了m這個類屬性,然而這個類屬性不是普通的值,而是一個描述器,所以我們從訪問這個類屬性變成了訪問這個描述器- 如果調用時得到的是一個描述器,python內部就會自動觸發一套使用機制
- 訪問的話自動觸發描述器的
__get__
方法 - 修改設置的話就自動觸發描述器的
__set__
方法 - 這里就是
aa.m
觸發了__get__
方法,得到的是self.x
的值,在前面__init__
中定義的為1 aa.m = 2
則觸發了__set__
方法,賦的值2傳到value
參數之中,改變了self.x
的值,所以下一次aa.m
調用的值也改變了
進一步思考:當訪問一個屬性時,我們可以不直接給一個值,而是接一個描述器,讓訪問和修改設置時自動調用__get__
方法和__set__
方法。再在__get__
方法和__set__
方法中進行某種處理,就可以實現更改操作屬性行為的目的。這就是描述器做的事情。
相信有的讀者已經想到了,開頭引言部分的例子,就是用描述器這樣實現的。在講具體如何實現之前,我們要先了解更多關於描述器的調用機制
描述器的調用機制
aa.m
命令其實是查找m屬性的過程,程序會先到哪里找,沒有的話再到哪里找,這是有一個順序的,說明訪問順序時需要用到__dict__
方法。
先看下面的代碼了解一下__dict__
方法
class C: x = 1 def __init__(self, y): self.y = y def fun(self): print(self.y) c = C(2) # 實例有哪些屬性 print(c.__dict__) # {'y': 2} # 類有什么屬性 print(C.__dict__) # 里面有 x fun print(type(c).__dict__) # 和上一條一樣 print(vars(c)) # __dict__ 也可以用 vars 函數替代,功能完全相同 # 調用 c.fun() # 2 c.__dict__['y'] # 2 # type(c).__dict__['fun']() # 報錯,說明函數不是這么調用的
__dict__
方法返回的是一個字典,類和實例都可以調用,鍵就是類或實例所擁有的屬性、方法,可以用這個字典訪問屬性,但是方法就不能這樣直接訪問,原因我們之后再說。
下面我們來說一下,當我們調用aa.m
時的訪問順序
- 程序會先查找
aa.__dict__['m']
是否存在 - 不存在再到
type(aa).__dict__['m']
中查找 - 然后找
type(aa)
的父類 - 期間找到的是普通值就輸出,如果找到的是一個描述器,則調用
__get__
方法
下面我們來看一下__get__
方法的調用機制
class M: def __init__(self): self.x = 1 def __get__(self, instance, owner): return self.x def __set__(self, instance, value): self.x = value # 調用描述器的類 class AA: m = M() # m就是一個描述器 n = 2 def __init__(self, score): self.score = score aa = AA(3) print(aa.__dict__) # {'score': 3} print(aa.score) # 3, 在 aa.__dict__ 中尋找,找到了score直接返回 print(aa.__dict__['score']) # 3, 上面的調用機制實際上是這樣的 print(type(aa).__dict__) # 里面有n和m print(aa.n) # 2, 在aa.__dict__中找不到n,於是到type(aa).__dict__中找到了n,並返回其值 print(type(aa).__dict__['n']) # 2, 其實是上面一條的調用機制 print(aa.m) # 1, 在aa.__dict__中找不到n,於是到type(aa).__dict__中找到了m # m是一個描述器對象,於是調用__get__方法,將self.x的值返回,即1 print(type(aa).__dict__['m'].__get__(aa,AA)) # 1, 上面一條的調用方式是這樣的 # __get__的定義中,除了self,還有instance和owner,其實分別表示的就是描述器所在的實例和類,這里的細節我們后文會講 print('-'*20) print(AA.m) # 1, 也是一樣調用了描述器 print(AA.__dict__['m'].__get__(None, AA)) # 類相當於調用這個
此外還有特例,與描述器的種類有關
- 同時定義了
__get__
和__set__
方法的描述器稱為資料描述器 - 只定義了
__get__
的描述器稱為非資料描述器 - 二者的區別是:當屬性名和描述器名相同時,在訪問這個同名屬性時,如果是資料描述器就會先訪問描述器,如果是非資料描述器就會先訪問屬性 舉例如下
# 既有__get__又有__set__,是一個資料描述器 class M: def __init__(self): self.x = 1 def __get__(self, instance, owner): print('get m here') # 打印一些信息,看這個方法何時被調用 return self.x def __set__(self, instance, value): print('set m here') # 打印一些信息,看這個方法何時被調用 self.x = value + 1 # 這里設置一個+1來更清楚了解調用機制 # 只有__get__是一個非資料描述器 class N: def __init__(self): self.x = 1 def __get__(self, instance, owner): print('get n here') # 打印一些信息,看這個方法何時被調用 return self.x # 調用描述器的類 class AA: m = M() # m就是一個描述器 n = N() def __init__(self, m, n): self.m = m # 屬性m和描述器m名字相同,調用時發生一些沖突 self.n = n # 非資料描述器的情況,與m對比 aa = AA(2,5) print(aa.__dict__) # 只有n沒有m, 因為資料描述器同名時,不會訪問到屬性,會直接訪問描述器,所以屬性里就查不到m這個屬性了 print(AA.__dict__) # m和n都有 print(aa.n) # 5, 非資料描述器同名時調用的是屬性,為傳入的5 print(AA.n) # 1, 如果是類來訪問,就調用的是描述器,返回self.x的值 print(aa.m) # 3, 其實在aa=AA(2,5)創建實例時,進行了屬性賦值,其中相當於進行了aa.m=2 # 但是aa調用m時卻不是常規地調用屬性m,而是資料描述器m # 所以定義實例aa時,其實觸發了m的__set__方法,將2傳給value,self.x變成3 # aa.m調用時也訪問的是描述器,返回self.x即3的結果 # 其實看打印信息也能看出什么時候調用了__get__和__set__ aa.m = 6 # 另外對屬性賦值也是調用了m的__set__方法 print(aa.m) # 7,調用__get__方法 print('-'*20) # 在代碼中顯式調用__get__方法 print(AA.__dict__['n'].__get__(None, AA)) # 1 print(AA.__dict__['n'].__get__(aa, AA)) # 1
注:要想制作一個只讀的資料描述器,需要同時定義 __set__
和 __get__
,並在 __set__
中引發一個 AttributeError 異常。定義一個引發異常的 __set__
方法就足夠讓一個描述器成為資料描述器。
描述器的細節
本節分為如下兩個部分
- 調用描述器的原理
__get__
和__set__
方法中的參數解釋
1.首先是調用描述器的原理 當調用一個屬性,而屬性指向一個描述器時,為什么就會去調用這個描述器呢,其實這是由object.__getattribute__()
方法控制的,其中object
是新式類定義時默認繼承的類,即py2這么寫的class(object)
中的object
。新定義的一個類繼承了object類,也就繼承了__getattribute__
方法。當訪問一個屬性比如b.x
時,會自動調用這個方法 __getattribute__()
的定義如下
def __getattribute__(self, key): "Emulate type_getattro() in Objects/typeobject.c" v = object.__getattribute__(self, key) if hasattr(v, '__get__'): return v.__get__(None, self) return v
上面的定義顯示,如果b.x
是一個描述器對象,即能找到__get__
方法,則會調用這個get方法,否則就使用普通的屬性。 如果在一個類中重寫__getattribute__
,將會改變描述器的行為,甚至將描述器這一功能關閉。
2.__get__
和__set__
方法中的參數解釋 官網中標明了這三個方法需要傳入哪些參數,還有這些方法的返回結果是什么,如下所示
descr.__get__(self, obj, type=None) --> value descr.__set__(self, obj, value) --> None descr.__delete__(self, obj) --> None
我們要了解的就是self obj type value
分別是什么 看下面一個例子
class M: def __init__(self, name): self.name = name def __get__(self, obj, type): print('get第一個參數self: ', self.name) print('get第二個參數obj: ', obj.age) print('get第三個參數type: ', type.name) def __set__(self, obj, value): obj.__dict__[self.name] = value class A: name = 'Bob' m = M('age') def __init__(self, age): self.age = age a = A(20) # age是20 a.m # get第一個參數self: age # get第二個參數obj: 20 # get第三個參數type: Bob a.m = 30 a.age # 30
總結如下
- self是描述器類M中的實例
- obj是調用描述器的類a中的實例
- type是調用描述器的類A
- value是對這個屬性賦值時傳入的值,即上面的30
上面的代碼邏輯如下
a.m
訪問描述器,調用__get__
方法- 三次打印分別調用了
m.name a.age A.name
a.m = 30
調用了__set__
方法,令a(即obj)的屬性中的'age'(即M('age')這里傳入的self.name)為30
實例方法、靜態方法和類方法的描述器原理
本節說明訪問些方法其實都訪問的是描述器,並說明它們調用順序是怎樣的,以及類方法和靜態方法描述器的python定義。
class B: @classmethod def print_classname(cls): print('Bob') @staticmethod def print_staticname(): print('my name is bob') def print_name(self): print('this name') b = B() b.print_classname() # 調用類方法 b.print_staticname() # 調用靜態方法 b.print_name() # 調用實例方法 print(B.__dict__) # 里面有實例方法、靜態方法和類方法
# 但其實字典里的還不是可以直接調用的函數 print(B.__dict__['print_classname']) print(b.print_classname) # 和上不一樣 print(B.__dict__['print_staticname']) print(b.print_staticname) # 和上不一樣 print(B.__dict__['print_name']) print(b.print_name) # 和上不一樣 # <classmethod object at 0x0000024A92DA67B8> # <bound method B.print_classname of <class '__main__.B'>> # <staticmethod object at 0x0000024A92DA6860> # <function B.print_staticname at 0x0000024A92D889D8> # <function B.print_name at 0x0000024A92D88158> # <bound method B.print_name of <__main__.B object at 0x0000024A92DA6828>>
上面結果表明,實例直接調用時,類方法和實例方法都是bound method
,而靜態方法是function
。因為靜態方法本身就是定義在類里面的函數,所以不屬於方法范疇。
除此之外,由於實例直接調用后得到的結果可以直接接一個括號,當成函數來調用。而使用字典調用時,得到的結果和實例調用都不一樣,所以它們是不可以直接接括號當成函數使用的。
其實從顯示的結果我們可以看出,靜態方法和類方法用字典調用得到的其實分別是staticmethod和classmethod兩個類的對象,這兩個類其實是定義描述器的類,所以用字典訪問的兩個方法得到的都是描述器對象。它們需要用一個__get__
方法才可以在后面接括號當成函數調用。
而普通實例方法用字典調用得到的是一個function即函數,理論上是可以用括號直接調用的,但是調用時報錯說少了self參數,其實它也是描述器對象,用通過__get__
方法將self傳入來調用
三種方法本質上調用__get__
方法的情況展示如下
B.__dict__['print_classname'].__get__(None, B)() B.__dict__['print_staticname'].__get__(None, B)() B.__dict__['print_name'].__get__(b, B)() print(B.__dict__['print_classname'].__get__(None, B)) print(B.__dict__['print_staticname'].__get__(None, B)) print(B.__dict__['print_name']) print(B.__dict__['print_name'].__get__(None, B)) # 這是不傳入實例即self的情況,和直接從字典調用結果相同,在python2中是一個unbound method print(B.__dict__['print_name'].__get__(b, B)) # B.print_name() # 報錯,說少輸入一個self參數 # B.print_name(B()) # this name 輸入實例即不會報錯
所以說我們平常調用的方法都是本質上在調用描述器對象,訪問描述器時自動調用__get__
方法。
上面調用時注意到,前兩個__get__
的第一個參數都是None
,而實例方法是一個b
,這是因為實例方法需要具體的實例來調用而不能用類直接調用。在python2中,用類直接調用實例方法得到的是一個unbound method
,用實例調用才是一個bound method
,(在python3刪除了unbound method
的概念,改為function
),而類方法本身就可以被類調用,所以參數是None
時就是一個bound method
了。所以說__get__
的第一個參數使用b
可以理解成方法的bound
過程。
既然三種方法都是調用了描述器對象,那么這些對象都是各自類的實例,它們的類是如何定義的呢?python中這些類的定義是用底層的C語言實現的,為了理解其工作原理,這里展示一個用python語言實現classmethod裝飾器的方法,(來源),即構建能產生類方法對應描述器對象的類。
class myclassmethod(object): def __init__(self, method): self.method = method def __get__(self, instance, cls): return lambda *args, **kw: self.method(cls, *args, **kw) class Myclass: x = 3 @myclassmethod def method(cls, a): print(cls.x+a) m = Myclass() Myclass.method(a=2)
下面我們分析一下上述代碼
- 我們看到使用@myclassmethod裝飾器達到的效果和使用@classmethod裝飾器沒有什么區別
- 首先定義了myclassmethod類,里面使用了
__get__
方法,所以它的實例會是一個描述器對象 - 將
myclassmethod
當做裝飾器作用於method函數,根據裝飾器的知識,相當於這樣設置method=myclassmethod(method)
- 調用
Myclass.method()
時調用了改變后了的method
方法,即myclassmethod(method)(a)
myclassmethod(method)
這是myclassmethod
類的一個實例,即一個描述器,此處訪問於是調用__get__
方法,返回一個匿名函數__get__
中其實是將owner(cls)部分傳入method方法,因為methon在Myclass類中調用,這個owner也就是Myclass類。這一步其實是提前傳入了method的第一個參數cls,后面的參數a由myclassmethod(method)(a)
第二個括號調用- 仔細分析上面的定義與調用過程,我們會發現,我們常常說的類方法第一個參數要是cls,其實是不對的,第一個參數是任意都可以,它只是占第一個位置,用於接收類實例引用類屬性,隨便換成任意變量都可以,用cls只是約定俗成的。比如下面的代碼正常運行
class Myclass: x = 3 @classmethod def method(b, a): print(b.x+a) m = Myclass() Myclass.method(a=2) # 5
下面看一下staticmethod類的等價python定義(來源)
class mystaticmethod: def __init__(self, callable): self.f = callable def __get__(self, obj, type=None): return self.f class Myclass: x = 3 @mystaticmethod def method(a, b): print(a + b) m = Myclass() m.method(a=2, b=3)
注:從源碼角度來理解靜態方法和類方法
- 靜態方法相當於不自動傳入實例對象作為方法的第一個參數,類方法相當於將默認傳入的第一個參數由實例改為類
- 使用
@classmethod
后無論類調用還是實例調用,都會自動轉入類作為第一個參數,不用手動傳入就可以調用類屬性,而沒有@classmethod
的需要手動傳入類 - 既不用
@classmethod
也不用@staticmethod
則類調用時不會自動傳入參數,實例調用時自動傳入實例作為第一個參數 - 所以說加
@classmethod
是為了更方便調用類屬性,加@staticmethod
是為了防止自動傳入的實例的干擾 - 除此之外要說明一點:當屬性和方法重名時,調用會自動訪問屬性,是因為這些方法調用的描述器都是非資料描述器。而當我們使用
@property
裝飾器后,自動調用的就是新定義的get set
方法,是因為@property
裝飾器是資料描述器
property裝飾器的原理
到這里我們可以講一講開頭提出的問題了,即@property
裝飾器是如何使用描述器實現的,調用機制是怎樣的,如何通過描述器達到精簡多次使用@property
裝飾器的問題。
首先要明確,property有兩種調用形式,一種是用裝飾器,一種是用類似函數的形式,下面會用引言中的例子分別說明兩種形式的調用機制。
下面貼出property的等價python定義(來源於官網的中文翻譯)
class Property(object): "Emulate PyProperty_Type() in Objects/descrobject.c" def __init__(self, fget=None, fset=None, fdel=None, doc=None): self.fget = fget self.fset = fset self.fdel = fdel if doc is None and fget is not None: doc = fget.__doc__ self.__doc__ = doc def __get__(self, obj, objtype=None): if obj is None: return self 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") self.fset(obj, value) def __delete__(self, obj): if self.fdel is None: raise AttributeError("can't delete attribute") 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__)
從上面的定義中我們可以看出,定義時分為兩個部分,一個是__get__
等方法的定義,另一部分是getter
等方法的定義,同時注意到這個類要傳入fget
等三個函數作為屬性。getter
等方法的定義是為了讓它可以完美地使用裝飾器形式,我們先不看這一部分,先看看不是使用第一種即不使用裝飾器的形式的調用機制。
# 類似函數的形式 class A: def __init__(self, name, score): self.name = name # 普通屬性 self.score = score def getscore(self): return self._score def setscore(self, value): print('setting score here') if isinstance(value, int): self._score = value else: print('please input an int') score = property(getscore, setscore) a = A('Bob',90) a.name # 'Bob' a.score # 90 a.score = 'bob' # please input an int
分析上述調用score的過程
- 初始化時即開始訪問score,發現有兩個選項,一個是屬性,另一個是
property(getscore, setscore)
對象,因為后者中定義了__get__
與__set__
方法,因此是一個資料描述器,具有比屬性更高的優先級,所以這里就訪問了描述器 - 因為初始化時是對屬性進行設置,所以自動調用了描述器的
__set__
方法 __set__
中對fset
屬性進行檢查,這里即傳入的setscore
,不是None
,所以調用了fset
即setscore
方法,這就實現了設置屬性時使用自定義函數進行檢查的目的__get__
也是一樣,查詢score時,調用__get__
方法,觸發了getscore
方法
下面是另一種使用property的方法
# 裝飾器形式,即引言中的形式 class A: def __init__(self, name, score): self.name = name # 普通屬性 self.score = score @property def score(self): print('getting score here') return self._score @score.setter def score(self, value): print('setting score here') if isinstance(value, int): self._score = value else: print('please input an int') a = A('Bob',90) # a.name # 'Bob' # a.score # 90 # a.score = 'bob' # please input an int
下面進行分析
- 在第一種使用方法中,是將函數作為傳入property中,所以可以想到是否可以用裝飾器來封裝
- get部分很簡單,訪問
score
時,加上裝飾器變成訪問property(score)
這個描述器,這個score
也作為fget
參數傳入__get__
中指定調用時的操作 - 而set部分就不行了,於是有了
setter
等方法的定義 - 使用了
property
和setter
裝飾器的兩個方法的命名都還是score,一般同名的方法后面的會覆蓋前面的,所以調用時調用的是后面的setter
裝飾器處理過的score
,是以如果兩個裝飾器定義的位置調換,將無法進行屬性賦值操作。 - 而調用
setter
裝飾器的score
時,面臨一個問題,裝飾器score.setter
是什么呢?是score
的setter
方法,而score
是什么呢,不是下面定義的這個score
,因為那個score
只相當於參數傳入。自動向其他位置尋找有沒有現成的score
,發現了一個,是property
修飾過的score
,這是個描述器,根據property
的定義,里面確實有一個setter
方法,返回的是property
類傳入fset
后的結果,還是一個描述器,這個描述器傳入了fget
和fset
,這就是最新的score
了,以后實例只要調用或修改score
,使用的都是這個描述器 - 如果還有
del
則裝飾器中的score
找到的是setter
處理過的score
,最新的score
就會是三個函數都傳入的score
- 對最新的
score
的調用及賦值刪除都跟前面一樣了
property
的原理就講到這里,從它的定義我們可以知道它其實就是將我們設置的檢查等函數傳入get set
等方法中,讓我們可以自由對屬性進行操作。它是一個框架,讓我們可以方便傳入其他操作,當很多對象都要進行相同操作的話,重復就是難免的。如果想要避免重復,只有自己寫一個類似property
的框架,這個框架不是傳入我們希望的操作了,而是就把這些操作放在框架里面,這個框架因為只能實現一種操作而不具有普適性,但是卻能大大減少當前問題代碼重復問題
下面使用描述器定義了Checkint類之后,會發現A類簡潔了非常多
class Checkint: def __init__(self, name): self.name = name def __get__(self, instance, owner): if instance is None: return self else: return instance.__dict__[self.name] def __set__(self, instance, value): if isinstance(value, int): instance.__dict__[self.name] = value else: print('please input an integer') # 類似函數的形式 class A: score = Checkint('score') age = Checkint('age') def __init__(self, name, score, age): self.name = name # 普通屬性 self.score = score self.age = age a = A('Bob', 90, 30) a.name # 'Bob' a.score # 90 # a.score = 'bob' # please input an int # a.age='a' # please input an integer
描述器的應用
因為我本人也剛剛學描述器不久,對它的應用還不是非常了解,下面只列舉我現在能想到的它有什么用,以后如果想到其他的再補充
- 首先是上文提到的,它是實例方法、靜態方法、類方法、property的實現原理
- 當訪問屬性、賦值屬性、刪除屬性,出現冗余操作,或者苦思無法找到答案時,可以求助於描述器
- 具體使用1:緩存。比如調用一個類的方法要計算比較長的時間,這個結果還會被其他方法反復使用,我們不想每次使用和這個相關的函數都要把這個方法重新運行一遍,於是可以設計出第一次計算后將結果緩存下來,以后調用都使用存下來的結果。只要使用描述器在
__get__
方法中,在判斷語句下,obj.__dict__[self.name] = value
。這樣每次再調用這個方法都會從這個字典中取得值,而不是重新運行這個方法。(例子來源最后的那個例子)