Python官方文件:Descriptor 指南

icejoywoo發表於2015-12-10

摘要

定義Descriptor並概述其協議,以及展示如何呼叫Descriptor。深入學習自定義Descriptor和幾個內建的Python Descriptor,包括函式、property、靜態方法和類方法。通過純Python程式碼等價實現和應用示例來揭示其執行原理。

學習Descriptor不僅可以獲得更多的工具集,而且可以更好地體會Python的執行原理及其優雅的設計。

定義和介紹

一般來說,Descriptor是伴隨有“繫結行為”的物件屬性,其屬性訪問可以根據Descriptor協議通過方法來控制。方法有__get__()、__set__()和__delete__()。如果在物件中定義了其中任意方法,那麼這個物件就稱為Descriptor。

物件中屬性訪問的預設行為就是在物件的字典中get、set或delete相應的屬性。例如,a.x的查詢順序是從 a.__dict__[‘x’] 到 type(a).__dict__[‘x’],然後繼續在type(a)除元類(metaclass)外的基類中查詢。如果要查詢的值是定義了任意Descriptor方法的物件,那麼Python會呼叫Descriptor方法來覆蓋預設行為。查詢的優先順序順序取決於定義了哪些Descriptor方法。

Descriptor是一個強大而通用的協議,是property、方法、靜態方法、類方法和super()背後的機制。在Python的內部使用Descriptor來實現了2.2版本中引入的新風格類。Descriptor抽象了底層的c程式碼,為Python日常編碼提供了一個靈活的新工具集。

Descriptor協議

這就是協議的全部。物件只要定義其中任意方法就是Descriptor,就可以覆蓋屬性查詢的預設行為。

同時定義了__get__()和__set__()的物件就叫作Data Descriptor。而只定義了__get__()的Descriptor就被叫做Non-data Descriptor(這種方式就是類方法的典型用法,當然也可能有其他用法)。

Data Descriptor和Non-data Descriptor的不同體現在關於例項字典條目的覆蓋和計算順序上。如果例項字典中包含了與Data Descriptor同名的屬性,那麼Data Descriptor優先。如果例項字典中包含了與Non-data Descriptor同名的屬性,例項字典優先。

(譯註:例項字典是指類例項中__dict__。關於優先順序的程式碼示例參考:https://gist.github.com/icejoywoo/0f19fa8575ac664140fc)

同時定義__get__()和__set__()方法,並且__set__()在呼叫時丟擲AttributeError異常,就可以建立一個只讀的Data Descriptor。只需要定義一個丟擲異常的__set__()方法就足以讓該物件成為Data Descriptor。

呼叫Descriptor

Descriptor可以直接通過方法名來進行呼叫。例如,d.__get__(obj)。

另外,更常用的方式是通過屬性訪問來自動地呼叫Descriptor。例如,obj.d在obj的物件字典中查詢d。如果d定義了__get__()方法,那麼根據下面列出的優先順序規則,就會優先呼叫d.__get__(obj)。

呼叫的細節取決於obj是物件還是類。

對於物件來說,其機制是object.__getattribute__()將b.x轉換為type(b).__dict__[‘x’].__get__(b, type(b))。其實現的優先順序鏈是:Data Descriptor優先順序高於例項變數(instance variables),例項變數優先順序高於Non-data Descriptor,而 __getattr__() 的優先順序是最低的。完整的c程式碼實現在Objects/object.c的PyObject_GenericGetAttr()函式中。

對於類來說,其機制是type.__getattribute__()將B.x轉換為B.__dict__[‘x’].__get__(None, B)。純Python的程式碼實現如下:

需要記住的重要幾點:

  • Descriptor是通過__getattribute__()方法來呼叫的
  • 覆寫__getattribute__()可以阻止Descriptor的自動呼叫
  • object.__getattribute__()和type.__getattribute__()呼叫__get__()的方式不同
  • Data Descriptor總是覆蓋例項字典
  • Non-data Descriptor可能會被例項字典覆蓋

super()返回的物件也有一個用於呼叫Descriptor的定製__getattribute__()方法。super(B, obj).m()會搜查obj.__class__.__mro__中的基類A,返回A.__dict__[‘m’].__get__(obj, B)。如果不是Descriptor,m返回也是一樣的。如果m不在例項字典中,就還原為通過object.__getattribute__()來搜尋。

實現細節在Object/typeobject.c的super_getattro()函式中。Guido的入門教程有純Python的等價實現。

上面描述了在object、type和super()中隱藏在__getattribute__()方法內部的Descriptor機制。這種機制是可繼承的。如果一個類派生自某個物件,或者這個類的元類實現了相似的機制,這個類就可以繼承該機制。同樣地,類可以通過覆寫__getattribute__()來遮蔽Descriptor。

Descriptor示例

下面的程式碼建立了一個Data Descriptor的類,會在get或set時列印一條資訊。覆寫__getattribute__()也可以為每個屬性加上列印資訊。然而,在監控幾個選定的屬性時Descriptor是很用的:

Descriptor協議簡單並且提供了令人興奮的可能性。這幾種使用場景是非常普遍的,所以都打包成了單獨的函式呼叫。Property,繫結和未繫結的方法,靜態方法和類方法都是基於Descriptor協議的。

屬性

呼叫property()是一種簡潔的建立Data Descriptor的方式,會在訪問屬性時觸發函式呼叫。函式簽名如下:

文件展示了託管屬性x的典型用法:

來看下property()是如何使用Descriptor協議來實現的,下面是純Python的等價實現:

每當使用者介面授權屬性訪問並且後續變化需要方法的接入,property()內建函式都是有用的。

例如,電子表格類可以授權通過Cell(‘b10’).value訪問單元格的值。對程式的後續變化需要單元格在每次訪問時重新計算;然而,程式設計師不希望影響現有直接訪問屬性的客戶端程式碼。解決方案就是用Property Data Descriptor來封裝對值屬性的訪問:

函式和方法

Python的物件導向特性是建立在以函式為基礎的環境之上的。使用Non-data Descriptor,函式和方法可以無縫地融合起來。

Class字典將方法儲存為函式。在Class的定義中,方法和函式同樣都用def和lambda來定義。方法與函式唯一的不同是其第一個引數預留給物件例項(object instance)的。按照Python的慣例,這個例項引用被稱為self,在其他語言中可能是this或其他名字。

為了支援方法呼叫,函式有__get__()方法,可以在屬性訪問時繫結方法。這意味著所有的函式都是Non-data Descriptor,根據呼叫方是物件或類來返回繫結或非繫結方法。純Python實現如下:

在直譯器中展示函式Descriptor實際是如何工作的:

上面的輸出資訊表示繫結和非繫結方法是兩種不同的型別。儘管我們可以用上述方式實現,但是在Objects/classobject.c 中的 PyMethod_Type 其實是用一個物件實現的,只是這個物件存在兩種不同的表現形式,而表現形式則取決於 im_self 的值是否為空(在 C 語言中表示 None 的關鍵字為 NULL)。

同樣地,方法物件呼叫的效果依賴於im_self欄位。如果賦值(意味著繫結),原函式(儲存在im_func欄位中)在呼叫時會設定第一個引數為例項。如果非繫結,所有的引數保持不變傳入原函式中。instancemethod_call()的C實現因為包含一些型別檢查而變得稍稍複雜了一點。

靜態方法和類方法

Non-data descriptor為函式繫結到方法的常用模式中提供了一個簡單的變化機制。

總的來說,函式有__get__()方法,因此在當作屬性訪問時會轉換為方法。Non-data Descriptor將obj.f(*args)變成f(obj, *args),將klass.f(*args)變成f(*args)。

下表總結了繫結和它的兩個最有用的變種:

Transformation Called from an Object Called from a Class
function f(obj, *args) f(*args)
staticmethod f(*args) f(*args)
classmethod f(type(obj), *args) f(klass, *args)

靜態方法返回沒有任何變化的原函式。呼叫c.f或C.f相當於直接查詢object.__getattribute__(c, “f”)或object.__getattribute__(C, “f”)。因此,函式通過物件或類來呼叫是等價的。

靜態方法是沒有引用self變數的方法。

例如,統計學的package可以包含存放實驗資料的容器類。這個類提供了標準的方法,計算平均值、均值、中值和其他依賴資料的描述性統計。然而,可能有隻是概念相關但不依賴資料的函式。例如,erf(x)是在統計工作中方便的轉換程式,但是不直接依賴特定的資料集。可以通過物件或類來呼叫:s.erf(1.5) –> .9332或Sample.erf(1.5) –> .9332。

因為靜態方法返回沒有變化的原函式,所以示例呼叫就沒有特別之處:

使用Non-data Descriptor協議,staticmethod()的純Python版本如下:

不同於靜態方法,類方法在呼叫函式之前在引數列表的前面加了類引用。無論其呼叫者是物件還是類結果是一致的:

當函式僅需要類引用並且不關心任何內部資料時,類方法是非常有用的。類方法的一個用途就是代替類建構函式來建立物件。在Python 2.3中,類方法dict.fromkeys()通過鍵值列表來建立新字典。等價的純Python實現如下:

現在,獨立鍵名的新字典會像下面這樣來構建:

使用Non-data Descriptor協議,classmethod()的純Python版本如下:

相關文章