【Python筆記】裝飾器語法糖(@staticmethod/@classmethod/@property)原理剖析及使用場景說明

在閱讀一些開源Python庫的源碼時,經常會看到在某個類的成員函數前,有類似於@staticmethod或@classmethod或@property的語法糖。本質上,它們都是函數裝飾器,只不過通常被用來修飾類成員函數而已。

本筆記旨在說明這些語法糖的用途,關於普通函數裝飾器語法的解釋,可以參考這篇筆記

在解釋這些裝飾器函數前,先來分析下普通成員函數。

1. 類的普通成員函數
對於Python的類,其普通類成員函數的第一個參數默認爲當前的類實例,通常的定義形式示例:

class C:
   def __init__(self):  ## NOTICE: 不傳入參數時創建實例會報錯;"self"只是約定俗成的參數名而已,非語法的硬性規定
       pass
我們可以通過print C.__init__查看函數__init__():
>>> class C():
...     def __init__(self):
...         pass
... 
>>> print C.__init__
<unbound method C.__init__>  ## __init__()是類C的unbound method,即它此時未bound到任何類實例上
>>> c = C()
>>> print c.__init__
<bound method C.__init__ of <__main__.C instance at 0x7fb4346d38c0>>  ## 當類實例創建後,__init__()變成實例的bound method
>>> print C.__init__.__get__ 
<method-wrapper '__get__' of instancemethod object at 0x7fb434772c80> ## 類成員函數默認實現了__get__()方法
總之,對於類的普通成員函數來說,在創建類的具體實例前,它們是unbound method,通過類名調用時(如 C.fn()),會報錯;類實例創建後,它們都是實例的bound method,只能通過實例來調用(inst = C(); inst.fn())。
關於unbound method函數調用報錯的原因,與Python底層實現時對函數名的查找規則有關。從上面示例代碼可看到,__init__()函數默認實現了__get__()方法,而根據Python底層規則,當某個對象(Python中萬物皆對象)實現了_get__()/__set__()/__delete__()這3個method中任何一個時,它就成了一個支持descriptor protocal的descriptor。當調用c.__init__時(當然,這個是Python解釋器幫我們調用的,但這並不改變__init__實際上是個普通類函數的事實),根據descriptor invoking規則,其將被轉化爲type(c).__dict__['__init__'].__get__(c, type(c))的形式,可見,實際上發生的調用類似於C.__init__(c),即類C的實例c會被當作第1個參數傳給普通類成員函數。所以,在定義類普通成員函數時,至少需要有self參數,且類的普通成員函數必須通過類實例來調用,而不能通過類名直接調用。
關於上面提到的Descriptor Protocol及其對obj attribute查找規則的影響,強烈建議讀懂這篇文檔Descriptor HowTo Guide。不誇張的說,理解Descriptor對我們理解Python代碼的底層行爲有巨大幫助。

2. @classmethod
根據Python文檔的說明,classmethod(fn)表明函數fn是類的函數而非類實例的函數,在語法上,它要求fn的函數簽名至少要有1個參數,函數被調用時,解釋器會將類作爲第1個參數傳給fn。示例如下:
>>> class C():
...     def fn_classmethod(x):
...         print x
...     fn = classmethod(fn_classmethod)
... 
>>> C.fn
<bound method classobj.fn_classmethod of <class __main__.C at 0x7fb4346b6bb0>>
>>> C.fn()
__main__.C
可見,當調用classmethod()將fn_classmethod轉換爲class method後,我們可以直接通過C.fn來調用它,當然,通過C().fn()調用也可以。
更需要注意的是,在調用時C.fn()時,fn_classmethod()唯一參數x的實參確實是類C(即示例中print出來的__main__.C),該參數是解釋器自動傳入的。
在Python語法中,@classmethod是一種實現自動調用classsmethod(fn_classmethod)的語法糖,它實現的功能與上述示例代碼一致,只是看起來更精簡且更pythonic而已:
>>> class C():
...     @classmethod ## Python的decorator語法會保證classmethod(fn)的自動調用
...     def fn(x):
...         print x
... 
>>> C.fn
<bound method classobj.fn of <class __main__.C at 0x7fb4346b6808>>
>>> C.fn()
__main__.C

classmethod的典型使用場合:
1) 直接用類來調用函數,而不用藉助類實例
2) 更優雅地實現某個類的實例的構造(類似於Factory Pattern)
通常情況下,類實例是解釋器自動調用類的__init__()來構造的,但藉助classmethod可以在解釋器調用__init__前實現一些預處理邏輯,然後將預處理後的參數傳入類的構造函數來創建類實例。如dict類型支持的fromkeys()方法就是用classmethod實現的,它用dict實例當前的keys構造出一個新的dict實例。在文檔Descriptor HowTo Guide最後部分給出了它對應的Python pseudo-code,感興趣的話可以去研究一下。
關於實現類實例構造的另一個典型case,可以參考StackOverflow上的這篇問答帖。帖中Best Answer作者給出了一個典型場景,這個場景用非classmethod的方法也可以實現類實例的構造,但藉助classmethod語法,可以實現的更優雅(a. 與__init__構造實例相比,classmethod方法也保證了構造邏輯代碼複用度而且實現的更精簡,如解析date_as_string爲(year, month, day)的代碼也可以被複用;b. 它不用通過類的實例調用,直接用類來調用即可構造新的實例;c. 與定義實現相同功能的全局函數相比,更符合OOP思想;d. 基類被繼承時,基類中定義的classmethod也會被繼承到繼承類中)。

3. @staticmethod
根據Python文檔的說明,staticmethod(fn)表明函數fn是類的靜態方法。具體到類定義體內某個函數的定義上,如果該函數想聲明稱靜態成員,則只需在其定義體前加上"@staticmethod"這行,利用裝飾器語法糖來實現staticmethod(cls.fun)的目的。示例如下:
class C(object):
    @staticmethod
    def f(arg1, arg2, ...):
        ...
與classmethod的裝飾器語法糖類似,@staticmethod會自動調用staticmethod(f)。
Python中類靜態方法的語義跟C++/Java類似,即類的靜態成員屬於類本身,不屬於類的實例,它無法訪問實例的屬性(數據成員或成員函數)。定義爲staticmethod的函數被調用時,解釋器不會自動爲其隱式傳入類或類實例的參數,它的實際參數列表與調用時顯式傳入的參數列表保持一致。
staticmethod的典型應用場景:
若類的某個函數確認不會涉及到與類實例有關的操作時,可以考慮將該函數定義爲類的staticmethod。比如,根據業務邏輯,可將全局函數封裝到一個類中並聲明爲staticmethod,這樣看起來更符合OOP思想,具體的例子可以參考這篇Blog 。當然,這只是一種符合OOP的封裝思路,並非意味着碰到全局函數就一定要這樣做,需要看個人習慣或業務需求。
再次強調:定義爲staticmethod類型的函數,其函數體中最好不要涉及與類實例有關的操作(包括創建類實例或訪問實例的屬性),因爲一旦涉及到類實例就意味着這些實例名是硬編碼的,在類被繼承的場景下,調用這些staticmethod類型的函數會創建基類或訪問基類屬性,而這通常不是業務預期的行爲。具體的case可以參考StackOverflow這篇問答帖的第2個高票答案。

4. @property
根據Python文檔的說明,property([fget[, fset[, fdel[, doc]]]])爲new-style類創建並返回property對象,該對象是根據傳入的參數(fget/fset/fdel)創建的,它可以決定外部調用者對new-style類的某些屬性是否具有讀/寫/刪除權限。以官網文檔給出的demo爲例:

class C(object): ## NOTICE: property只對new style classes有效
    def __init__(self):
        self._x = None

    def getx(self):
        return self._x

    def setx(self, value):
        self._x = value

    def delx(self):
        del self._x

    x = property(getx, setx, delx, "I'm the 'x' property.")
上述示例中,x是類C的property對象,由於創建時傳入了3個函數對象,故通過訪問該屬性可以實現對self._x的讀/寫/刪除操作。具體而言,調用C().x時,解釋器最終會調用getx;調用C().x = value時,解釋器後最終調用setx;調用del C().x時,解釋器會最終調用delx。
由於property()的第1個參數是fget,利用這一點,可以很容易實現一個只有read-only權限的類屬性:
>>> class C(object):
...     def __init__(self):
...         self._name = 'name'
...     @property
...     def get_name(self):
...         return self._name
...     
... 
>>> c = C()
>>> c.get_name
'name'
>>> c.get_name = 'new name'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

當然,這裏所說的"read-only",只是指這個屬性名不會被賦值操作重新綁定新對象而已。如果這個屬性名初始綁定的是個可變對象(如list或dict),則即使通過@property裝飾,其綁定的對象的內容也可以通過屬性名來修改。
如果想通過類的實例對象來修改或刪除類實例的屬性,則需用下面的代碼來實現:

>>> class C(object):
...     def __init__(self):
...         self._name = 'name'
...     @property
...     def name(self):
...         return self._name
...     @name.setter
...     def name(self, value):
...         self._name = value
...     @name.deleter
...     def name(self):
...         del self._name
... 
>>> c = C()
>>> c.name
'name'
>>> c.__dict__
{'_name': 'name'}
>>> c.name = 'new name'
>>> c.__dict__
{'_name': 'new name'}
>>> c.name
'new name'
>>> del c.name
>>> c.__dict__
{}
上述代碼中,@property、@name.setter及@name.deleter均是裝飾器語法糖,其中:@name.setter中的name指代的是經@property裝飾後的對象(即property(name)返回的名爲name但類型爲property object的對象),setter是這個名爲name的property對象的built-in函數,其目的是通過name.setter(name)爲這個property對象提供修改其所屬類的屬性的功能。@name.deleter同理。
至於property對象支持的函數setter()和deleter()的來歷,文檔Descriptor HowTo Guide在介紹property原理時給出了property類底層實現的Python僞碼,值得精讀。
從僞碼還
可以看到,property類實現了__get__()、__set__()和__delete__()方法,這意味着property類是個遵循descriptor protocol的data descriptor,根據文檔Descriptor HowTo Guide關於Invoking Descriptors的說明,data descriptor會影響解釋器對屬性名的查找鏈,具體而言,當上面的代碼中調用c.name時,解釋器會將其轉化成type(c).__dict__['name'].__get__(c, type(c))(備註:通過print type(c).__dict__可以驗證,name確實存在於dict中),故這個轉化後的調用鏈會調用到property對象的__get__()方法,而根據property的實現僞碼,在__get__()中最終會調用到類C對應的name()函數。
上面這段話所描述的流程正是property魔法背後的原理。
當然,要想真正理解還需要仔細研究property的python僞碼邏輯。

@property除可以實現屬性的只讀權限功能外,還可以用在這種場景下:
類屬性已經暴露給外部調用者,但由於業務需求,需要針對這個屬性進行業務邏輯的修改(如增加邊界判定或修改屬性計算方法,等等),則此時引入property()或@property語法糖可以在修改邏輯的同時保證代碼的後向兼容,外部調用者無需修改調用方式。具體的case可以參考這篇Blog中提到的場景。

【參考資料】
1. Python的幾個高級語法概念淺析:lambda表達式 && 閉包 && 裝飾器    
2. Descriptor HowTo Guide  
3. StackOverflow: Python @classmethod and @staticmethod for beginner? 
4. Python Docs: classmethod 
5. Python Docs: staticmethod 
6. The definitive guide on how to use static, class or abstract methods in Python  
7. Newfound love of @staticmethod in Python 
8. Objects and classes in Python: Decorators 
9. Python Docs: property() 
10. Python Property 

====================== EOF =====================

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章