PEP 253 – 內建型別的子型別化
- 作者:
- Guido van Rossum <guido at python.org>
- 狀態:
- 最終版
- 型別:
- 標準跟蹤
- 建立日期:
- 2001年5月14日
- Python 版本:
- 2.2
- 釋出歷史:
摘要
本 PEP 提議對型別物件 API 進行補充,以允許在 C 和 Python 中建立內建型別的子型別。
[編者注:本 PEP 中描述的思想已併入 Python。本 PEP 不再準確描述其實現。]
引言
傳統上,Python 中的型別是透過宣告一個 PyTypeObject 型別的全域性變數並使用靜態初始化器對其進行初始化來靜態建立的。型別物件中的槽位描述了與 Python 直譯器相關的所有 Python 型別方面。一些槽位包含維度資訊(例如例項的基本分配大小),其他槽位包含各種標誌,但大多數槽位是指向函式以實現各種行為的指標。NULL 指標意味著該型別未實現特定行為;在這種情況下,系統可能會提供預設行為,或者在對該型別例項呼叫該行為時引發異常。一些通常一起定義的函式指標集合是透過指向包含更多函式指標的附加結構的指標間接獲得的。
雖然 PyTypeObject 結構初始化的細節尚未如此文件化,但可以很容易地從原始碼示例中推斷出來,我假設讀者對在 C 中建立新的 Python 型別的傳統方式足夠熟悉。
本 PEP 將引入以下特性:
- 型別可以是其例項的工廠函式
- 型別可以在 C 中進行子型別化
- 型別可以使用 class 語句在 Python 中進行子型別化
- 支援從型別進行多重繼承(在實際可行的情況下——您仍然不能從列表和字典進行多重繼承)
- 標準強制轉換函式(int、tuple、str 等)將被重新定義為相應的型別物件,這些型別物件充當它們自己的工廠函式
- class 語句可以包含
__metaclass__宣告,指定用於建立新類的元類 - class 語句可以包含
__slots__宣告,指定支援的例項變數的具體名稱
本 PEP 基於 PEP 252,後者為型別添加了標準自省;例如,當某個型別物件初始化 tp_hash 槽位時,該型別物件在自省時具有 __hash__ 方法。PEP 252 還為型別物件添加了一個包含所有方法的字典。在 Python 級別,此字典對於內建型別是隻讀的;在 C 級別,可以直接訪問(但除了作為初始化的一部分,不應修改)。
為了二進位制相容性,tp_flags 槽位中的一個標誌位表示下面介紹的型別物件中各種新槽位的存在。tp_flags 槽位中未設定 Py_TPFLAGS_HAVE_CLASS 位的型別被假定為所有子型別化槽位都為 NULL。(警告:當前的實現原型在檢查此標誌位方面尚不一致。這應在最終釋出之前修復。)
在當前的 Python 中,型別和類之間存在區別。本 PEP 連同 PEP 254 將消除這種區別。然而,為了向後相容性,這種區別可能會在未來幾年內仍然存在,並且如果沒有 PEP 254,這種區別仍然很大:型別最終將內建型別作為基類,而類最終派生自使用者定義的類。因此,在本 PEP 的其餘部分中,我將盡可能使用“型別”這個詞——包括基型別或超型別、派生型別或子型別以及元型別。然而,有時術語必然會融合,例如,物件的型別由其 __class__ 屬性給出,並且 Python 中的子型別化是透過 class 語句拼寫的。如果需要進一步區分,使用者定義的類可以被稱為“經典”類。
關於元型別
不可避免地,討論會涉及到元型別(或元類)。元型別在 Python 中並不是什麼新鮮事物:Python 總是能夠談論一個型別的型別。
>>> a = 0
>>> type(a)
<type 'int'>
>>> type(type(a))
<type 'type'>
>>> type(type(type(a)))
<type 'type'>
>>>
在此示例中,type(a) 是一個“常規”型別,而 type(type(a)) 是一個元型別。雖然所有型別都具有相同的元型別(PyType_Type,它也是它自己的元型別),但這並非強制要求,事實上,一個有用且相關的第三方擴充套件(Jim Fulton 的 ExtensionClasses)建立了一個額外的元型別。經典類的型別,即 types.ClassType,也可以被視為一個不同的元型別。
與元型別緊密相關的一個特性是“Don Beaudry 鉤子”,它指出如果一個元型別是可呼叫的,那麼它的例項(即常規型別)可以使用 Python class 語句進行子類化(實際上是子型別化)。我將使用這條規則來支援內建型別的子型別化,事實上,它極大地簡化了類建立的邏輯,使其始終簡單地呼叫元型別。當未指定基類時,將呼叫一個預設元型別——預設元型別是“ClassType”物件,因此在正常情況下,class 語句的行為將與以前相同。(可以透過設定全域性變數 __metaclass__ 來為每個模組更改此預設設定。)
Python 使用元型別或元類的方式與 Smalltalk 不同。在 Smalltalk-80 中,有一個元類層次結構,它反映了常規類的層次結構,元類與類一對一對映(除了層次結構根部的一些奇怪之處),並且每個類語句都建立了一個常規類及其元類,將類方法放入元類,例項方法放入常規類。
儘管這在 Smalltalk 的上下文中可能很好,但它與 Python 中元型別的傳統用法不相容,我更傾向於繼續使用 Python 的方式。這意味著 Python 元型別通常用 C 編寫,並且可能在許多常規型別之間共享。(在 Python 中可以對元型別進行子型別化,因此使用元型別並非絕對需要編寫 C;但 Python 元型別的能力將受到限制。例如,Python 程式碼將永遠不允許隨意分配原始記憶體並對其進行初始化。)
元型別決定了型別的各種**策略**,例如當呼叫一個型別時會發生什麼,動態型別是如何實現的(一個型別的 __dict__ 在建立後是否可以修改),方法解析順序是什麼,如何查詢例項屬性等等。
我會認為從左到右深度優先並不是當您希望充分利用多重繼承時最好的解決方案。
我會認為在多重繼承中,子型別的元型別必須是所有基型別的元型別的後代。
稍後我會再談到元型別。
使型別成為其例項的工廠
傳統上,對於每種型別,至少有一個 C 工廠函式用於建立該型別的例項(PyTuple_New()、PyInt_FromLong() 等)。這些工廠函式負責為物件分配記憶體並初始化該記憶體。截至 Python 2.0,如果該型別選擇參與垃圾回收(對於所謂的“容器”型別是可選的,但強烈推薦:可能包含對其他物件的引用,因此可能參與引用迴圈的型別),它們還必須與垃圾回收子系統進行介面。
在這個提議中,型別物件可以作為其例項的工廠函式,使型別可以直接從 Python 呼叫。這模仿了類例項化方式。用於建立各種內建型別例項的 C API 將保持有效,並且在某些情況下更高效。並非所有型別都會成為它們自己的工廠函式。
型別物件有一個新的槽位 tp_new,它可以充當該型別例項的工廠。現在型別是可呼叫的,因為在 PyType_Type(元型別)中設定了 tp_call 槽位;該函式會查詢被呼叫的型別的 tp_new 槽位。
解釋:常規型別物件(例如 PyInt_Type 或 PyList_Type)的 tp_call 槽位定義了當呼叫該型別的**例項**時會發生什麼;特別是,函式型別 PyFunction_Type 中的 tp_call 槽位是使函式可呼叫的關鍵。作為另一個例子,PyInt_Type.tp_call 為 NULL,因為整數不可呼叫。新的正規化使得**型別物件**可呼叫。由於型別物件是其元型別 (PyType_Type) 的例項,元型別的 tp_call 槽位 (PyType_Type.tp_call) 指向一個函式,當呼叫任何型別物件時,該函式會被呼叫。現在,由於每個型別都必須做一些不同的事情來建立自己的例項,PyType_Type.tp_call 會立即委託給被呼叫的型別的 tp_new 槽位。PyType_Type 本身也是可呼叫的:它的 tp_new 槽位建立了一個新型別。這被 class 語句使用(形式化了 Don Beaudry 鉤子,見上文)。那是什麼使得 PyType_Type 可呼叫呢?是**其**元型別的 tp_call 槽位——但由於它是它自己的元型別,那就是它自己的 tp_call 槽位!
如果該型別的 tp_new 槽位為 NULL,則會引發異常。否則,將呼叫 tp_new 槽位。tp_new 槽位的簽名是:
PyObject *tp_new(PyTypeObject *type,
PyObject *args,
PyObject *kwds)
其中“type”是被呼叫其 tp_new 槽位的型別,“args”和“kwds”是呼叫的順序引數和關鍵字引數,從 tp_call 未經更改地傳遞。(“type”引數與繼承結合使用,見下文。)
對返回的物件型別沒有限制,儘管按照慣例它應該是給定型別的一個例項。不一定返回一個新物件;對現有物件的引用也可以。返回值應該始終是一個新引用,由呼叫者擁有。
一旦 tp_new 槽位返回一個物件,如果返回物件的型別 tp_init() 槽位不為 NULL,則嘗試透過呼叫它進行進一步初始化。其簽名如下:
int tp_init(PyObject *self,
PyObject *args,
PyObject *kwds)
它更密切地對應於經典類的 __init__() 方法,事實上,根據槽位/特殊方法對應規則,它被對映到該方法。tp_new() 槽位和 tp_init() 槽位之間的職責差異在於它們確保的不變數。tp_new() 槽位應只確保最基本的、沒有它實現物件的 C 程式碼就會崩潰的不變數。tp_init() 槽位應用於可重寫的使用者特定初始化。以字典型別為例。其實現有一個指向雜湊表的內部指標,該指標永遠不應為 NULL。這個不變數由字典的 tp_new() 槽位負責。另一方面,字典的 tp_init() 槽位可以用於根據傳入的引數為字典提供一組初始的鍵和值。
請注意,對於不可變物件型別,初始化不能由 tp_init() 槽位完成:這將為 Python 使用者提供一種更改初始化的方法。因此,不可變物件通常具有空的 tp_init() 實現,並在其 tp_new() 槽位中完成所有初始化。
您可能想知道為什麼 tp_new() 槽位不應該自己呼叫 tp_init() 槽位。原因是在某些情況下(例如支援持久化物件),能夠建立一個特定型別的物件而無需進行不必要的進一步初始化非常重要。這可以透過呼叫 tp_new() 槽位而不呼叫 tp_init() 來方便地完成。也可能不會呼叫 tp_init(),或者呼叫多次——它的操作即使在這些異常情況下也應該健壯。
對於某些物件,tp_new() 可能會返回一個現有物件。例如,整數的工廠函式快取了 -1 到 99 的整數。這僅當 tp_new() 的 type 引數是定義了 tp_new() 函式的型別(在本例中,如果 type == &PyInt_Type),並且該型別的 tp_init() 槽位不執行任何操作時才允許。如果 type 引數不同,tp_new() 呼叫將由派生型別的 tp_new() 啟動,以建立物件並初始化物件的基型別部分;在這種情況下,tp_new() 應該始終返回一個新物件(或引發異常)。
Both tp_new() and tp_init() should receive exactly the same ‘args’ and ‘kwds’ arguments, and both should check that the arguments are acceptable, because they may be called independently.
還有第三個與物件建立相關的槽位:tp_alloc()。它的職責是為物件分配記憶體,初始化引用計數 (ob_refcnt) 和型別指標 (ob_type),並將物件的其餘部分初始化為全零。如果該型別支援垃圾回收,它還應該向垃圾回收子系統註冊該物件。這個槽位的存在是為了讓派生型別可以獨立於初始化程式碼來覆蓋記憶體分配策略(例如使用哪個堆)。其簽名是:
PyObject *tp_alloc(PyTypeObject *type, int nitems)
type 引數是新物件的型別。nitems 引數通常為零,除了具有可變分配大小的物件(主要是字串、元組和長整數)。分配大小由以下表達式給出:
type->tp_basicsize + nitems * type->tp_itemsize
tp_alloc 槽位僅用於可子類化的型別。基類的 tp_new() 函式必須呼叫作為其第一個引數傳入的型別的 tp_alloc() 槽位。tp_new() 函式負責計算項數。tp_alloc() 槽位將在 type->tp_itemsize 成員非零時設定新物件的 ob_size 成員。
(注:在某些除錯編譯模式下,型別結構已經包含名為 tp_alloc 和 tp_free 的槽位,用於分配和釋放的計數器。這些已更名為 tp_allocs 和 tp_deallocs。)
提供了 tp_alloc() 和 tp_new() 的標準實現。PyType_GenericAlloc() 從標準堆分配物件並正確初始化。它使用上述公式確定要分配的記憶體量,並負責 GC 註冊。不使用此實現的唯一原因是為不同的堆分配物件(某些非常小的常用物件,如整數和元組就是如此)。PyType_GenericNew() 增加的內容很少:它只調用型別的 tp_alloc() 槽位,nitems 為零。但對於在 tp_init() 槽位中完成所有初始化的可變型別,這可能正是所需的。
為子型別化準備型別
子型別化的思想與 C++ 中的單一繼承非常相似。基型別由一個結構宣告(類似於 C++ 類宣告)和一個型別物件(類似於 C++ vtable)描述。派生型別可以擴充套件結構(但必須保持基結構成員的名稱、順序和型別不變),並且可以覆蓋型別物件中的某些槽位,而保持其他槽位不變。(與 C++ vtables 不同,所有 Python 型別物件都具有相同的記憶體佈局。)
基型別必須執行以下操作:
- 將標誌值
Py_TPFLAGS_BASETYPE新增到tp_flags。 - 宣告並使用
tp_new()、tp_alloc()和可選的tp_init()槽位。 - 宣告並使用
tp_dealloc()和tp_free()。 - 匯出其物件結構宣告。
- 匯出一個支援子型別化的型別檢查宏。
tp_new()、tp_alloc() 和 tp_init() 的要求和簽名已在上面討論過:tp_alloc() 應該分配記憶體並將其大部分初始化為零;tp_new() 應該呼叫 tp_alloc() 槽位,然後進行最低限度的必要初始化;tp_init() 應該用於對可變物件進行更廣泛的初始化。
毫不奇怪,在物件生命週期結束時也有類似的約定。涉及的槽位是 tp_dealloc()(所有實現過 Python 擴充套件型別的人都熟悉)和 tp_free(),新來的。(這些名稱並不完全對稱;tp_free() 對應於 tp_alloc(),這很好,但 tp_dealloc() 對應於 tp_new()。也許 tp_dealloc 槽位應該重新命名?)
tp_free() 槽位應該用於釋放記憶體並將物件從垃圾回收子系統登出,並且可以由派生類覆蓋;tp_dealloc() 應該解除初始化物件(通常透過對各種子物件呼叫 Py_XDECREF()),然後呼叫 tp_free() 來釋放記憶體。tp_dealloc() 的簽名與以往相同:
void tp_dealloc(PyObject *object)
tp_free() 的簽名相同。
void tp_free(PyObject *object)
(在此 PEP 的早期版本中,tp_clear() 槽位也扮演了一個角色。事實證明這是一個壞主意。)
為了在 C 中進行有用的子型別化,型別必須透過標頭檔案匯出其例項的結構宣告,因為需要它來派生子型別。基型別的型別物件也必須匯出。
如果基型別具有型別檢查宏(例如 PyDict_Check()),則該宏應使其識別子型別。這可以透過使用新的 PyObject_TypeCheck(object, type) 宏來實現,該宏會呼叫一個遵循基類連結的函式。
PyObject_TypeCheck() 宏包含一個輕微的最佳化:它首先將 object->ob_type 直接與型別引數進行比較,如果匹配,則繞過函式呼叫。這應該足以滿足大多數情況下的速度需求。
請注意,型別檢查宏的這一更改意味著要求基型別例項的 C 函式可能會被派生型別例項呼叫。在啟用特定型別的子型別化之前,應檢查其程式碼以確保這不會破壞任何東西。在原型中,為內建 Python 物件型別新增另一個型別檢查宏以檢查精確型別匹配(例如,如果 x 是字典或字典子類的例項,則 PyDict_Check(x) 為真,而 PyDict_CheckExact(x) 僅在 x 是字典時為真)已被證明很有用。
在 C 中建立內建型別的子型別
最簡單的子型別化形式是在 C 中進行子型別化。它是最簡單的形式,因為我們可以要求 C 程式碼意識到一些問題,並且不遵循規則的 C 程式碼核心轉儲是可以接受的。為了進一步簡化,它僅限於單一繼承。
假設我們正在從一個 tp_itemsize 為零的可變基型別派生。子型別程式碼不具備 GC 意識,儘管它可能會從基型別繼承 GC 意識(這是自動的)。基型別的分配使用標準堆。
派生型別首先宣告一個包含基型別結構的型別結構。例如,這裡是內建列表型別子型別的型別結構:
typedef struct {
PyListObject list;
int state;
} spamlistobject;
請注意,基型別結構成員(此處為 PyListObject)必須是結構的第一個成員;任何後續成員都是附加的。另請注意,基型別不是透過指標引用的;其結構的實際內容必須包含在內!(目標是使子型別例項開頭的記憶體佈局與基型別例項的記憶體佈局相同。)
接下來,派生型別必須宣告並初始化一個型別物件。型別物件中的大多數槽位可以初始化為零,這表示基型別槽位必須複製到其中。一些必須正確初始化的槽位:
- 物件頭必須照常填充;型別應為
&PyType_Type。 tp_basicsize槽位必須設定為子型別例項結構的大小(在上面的示例中:sizeof(spamlistobject))。tp_base槽位必須設定為基型別物件所在的地址。- 如果派生槽位定義了任何指標成員,則
tp_dealloc槽位函式需要特別注意,見下文;否則,可以將其設定為零,以繼承基型別的解除分配函式。 tp_flags槽位必須設定為通常的Py_TPFLAGS_DEFAULT值。tp_name槽位必須設定;建議也設定tp_doc(這些不會被繼承)。
如果子型別沒有定義額外的結構成員(它只定義新行為,沒有新資料),則 tp_basicsize 和 tp_dealloc 槽位可以保留為零。
子型別的 tp_dealloc 槽位需要特別注意。如果派生型別沒有定義任何需要 DECREF 或釋放的額外指標成員,則可以將其設定為零。否則,子型別的 tp_dealloc() 函式必須對任何 PyObject * 成員呼叫 Py_XDECREF(),並對其擁有的任何其他指標呼叫正確的記憶體釋放函式,然後呼叫基類的 tp_dealloc() 槽位。此呼叫必須透過基型別的型別結構進行,例如,從標準列表型別派生時:
PyList_Type.tp_dealloc(self);
如果子型別想使用與基型別不同的分配堆,則子型別必須同時覆蓋 tp_alloc() 和 tp_free() 槽位。這些將分別由基類的 tp_new() 和 tp_dealloc() 槽位呼叫。
為了完成型別的初始化,必須呼叫 PyType_InitDict()。這將子型別中初始化為零的槽位替換為相應基型別槽位的值。(它還會填充 tp_dict,即型別的字典,並執行型別物件所需的各種其他初始化。)
在為其呼叫 PyType_InitDict() 之前,子型別無法使用;這最好在模組初始化期間完成,假設子型別屬於一個模組。對於新增到 Python 核心的子型別(它們不屬於特定模組),另一種方法是在其建構函式中初始化子型別。允許多次呼叫 PyType_InitDict();第二次和後續呼叫沒有效果。為了避免不必要的呼叫,可以進行 tp_dict==NULL 的測試。
(在 Python 直譯器初始化期間,有些型別在初始化之前實際上就被使用了。只要實際需要的槽位被初始化,尤其是 tp_dealloc,它就能工作,但這種做法很脆弱,不推薦作為通用實踐。)
要建立子型別例項,需要呼叫子型別的 tp_new() 槽位。這應該首先呼叫基型別的 tp_new() 槽位,然後初始化子型別的附加資料成員。為了進一步初始化例項,通常會呼叫 tp_init() 槽位。請注意,tp_new() 槽位**不應**呼叫 tp_init() 槽位;這取決於 tp_new() 的呼叫者(通常是工廠函式)。在某些情況下,不呼叫 tp_init() 是合適的。
如果子型別定義了 tp_init() 槽位,則 tp_init() 槽位通常應首先呼叫基型別的 tp_init() 槽位。
(XXX 這裡應該有一兩段關於引數傳遞的段落。)
Python 中的子型別化
下一步是透過 Python 中的 class 語句允許對選定的內建型別進行子型別化。現在我們只考慮單一繼承,下面是一個簡單 class 語句的發生情況:
class C(B):
var1 = 1
def method1(self): pass
# etc.
類語句的主體在一個新的環境(基本上是一個用作區域性名稱空間的新字典)中執行,然後建立 C。以下解釋 C 是如何建立的。
假設 B 是一個型別物件。由於型別物件是物件,並且每個物件都有一個型別,因此 B 有一個型別。由於 B 本身就是一個型別,我們也稱其型別為元型別。B 的元型別可以透過 type(B) 或 B.__class__ 訪問(後一種表示法是型別的新功能;它在 PEP 252 中引入)。我們稱此元型別為 M(表示 Metatype)。class 語句將建立一個新型別 C。由於 C 將像 B 一樣是一個型別物件,我們將 C 的建立視為元型別 M 的例項化。建立子類所需提供的資訊是:
- 其名稱(在本例中為字串“C”);
- 它的基類(一個包含 B 的單例元組);
- 執行類主體後的結果,以字典形式呈現(例如
{"var1": 1, "method1": <functionmethod1 at ...>, ...})。
class 語句將導致以下呼叫:
C = M("C", (B,), dict)
其中 dict 是執行類主體後產生的字典。換句話說,元型別 (M) 被呼叫。
請注意,即使示例只有一個基類,我們仍然傳入一個(單例)基類序列;這使得介面與多重繼承情況保持一致。
在當前的 Python 中,這被稱為“Don Beaudry 鉤子”,以其發明者命名;它是一個特殊情況,僅當基類不是常規類時才被呼叫。對於常規基類(或未指定基類時),當前 Python 直接呼叫 PyClass_New(),即類的 C 級工廠函式。
在新系統中,這被改變為 Python **總是**確定一個元型別並如上所述呼叫它。當給定一個或多個基類時,第一個基類的型別被用作元型別;當沒有給定基類時,選擇一個預設元型別。透過將預設元型別設定為“經典”類的元型別 PyClass_Type,保留了 class 語句的經典行為。可以透過設定全域性變數 __metaclass__ 來為每個模組更改此預設值。
這裡還有兩個進一步的改進。首先,一個有用的特性是能夠直接指定一個元型別。如果類套件定義了一個變數 __metaclass__,那麼這就是要呼叫的元型別。(請注意,在模組級別設定 __metaclass__ 只會影響沒有基類且沒有顯式 __metaclass__ 宣告的類語句;但在類套件中設定 __metaclass__ 會無條件地覆蓋預設元型別。)
其次,在多重繼承中,並非所有基類都需要具有相同的元型別。這被稱為元類衝突[1]。一些元類衝突可以透過在基類集合中搜索一個派生自所有其他給定元型別的元型別來解決。如果找不到這樣的元型別,則會引發異常,並且類語句失敗。
這種衝突解決可以透過元型別建構函式來實現:class 語句只調用第一個基類的元型別(或由 __metaclass__ 變數指定的元型別),而這個元型別的建構函式會查詢最派生的元型別。如果它是它自己,它會繼續;否則,它會呼叫那個元型別的建構函式。(最終的靈活性:另一個元型別可能會選擇要求所有基類具有相同的元型別,或者只有一個基類,或者其他什麼。)
(在[1]中,會自動派生一個新的元類,它是所有給定元類的子類。但是,由於在 Python 中如何合併各種元類的衝突方法定義存在疑問,我認為這不可行。如果需要,使用者可以手動派生這樣的元類並使用 __metaclass__ 變數指定它。也可以有一個新的元類來執行此操作。)
請注意,呼叫 M 要求 M 本身具有一個型別:元元型別。元元型別又有一個型別,即元元元型別。依此類推。這通常在某個級別透過使元型別成為它自己的元型別來截斷。這確實在 Python 中發生了:PyType_Type 中的 ob_type 引用被設定為 &PyType_Type。在沒有第三方元型別的情況下,PyType_Type 是 Python 直譯器中唯一的元型別。
(在本 PEP 的早期版本中,還有一個額外的元級別,有一個名為“turtle”的元元型別。事實證明這是不必要的。)
無論如何,建立 C 的工作由 M 的 tp_new() 槽位完成。它為“擴充套件”型別結構分配空間,其中包含:型別物件;輔助結構(as_sequence 等);包含型別名稱的字串物件(以確保在型別物件仍然引用它時不會被釋放);以及一些輔助儲存(稍後描述)。它將此儲存初始化為零,除了少數關鍵槽位(例如,tp_name 設定為指向型別名稱),然後將 tp_base 槽位設定為指向 B。然後呼叫 PyType_InitDict() 以繼承 B 的槽位。最後,C 的 tp_dict 槽位會更新為名稱空間字典(呼叫 M 的第三個引數)的內容。
多重繼承
Python 的 class 語句支援多重繼承,我們也將支援涉及內建型別的多重繼承。
然而,存在一些限制。C 執行時架構使得除了少數退化情況外,無法有意義地子型別化兩種不同的內建型別。改變 C 執行時以支援完全通用的多重繼承將是對程式碼庫的巨大沖擊。
從不同內建型別進行多重繼承的主要問題源於內建型別的 C 實現直接訪問結構成員;C 編譯器生成相對於物件指標的偏移量,就是這樣。例如,列表和字典型別結構都聲明瞭許多不同但重疊的結構成員。一個 C 函式期望一個列表物件卻傳入了一個字典,它將無法工作,反之亦然,對此我們無能為力,除非重寫所有訪問列表和字典的程式碼。這將是太多的工作,所以我們不會這樣做。
多重繼承問題是由衝突的結構成員分配引起的。在 Python 中定義的類通常不將其例項變數儲存在結構成員中:它們儲存在例項字典中。這是部分解決方案的關鍵。假設我們有以下兩個類:
class A(dictionary):
def foo(self): pass
class B(dictionary):
def bar(self): pass
class C(A, B): pass
(這裡,“dictionary”是內建字典物件的型別,也稱為 type({}) 或 {}.__class__ 或 types.DictType。)如果我們檢視結構佈局,我們發現一個 A 例項的佈局是一個字典,後面跟著 __dict__ 指標,而 B 例項具有相同的佈局;由於沒有結構成員佈局衝突,這是可以的。
這是另一個例子:
class X(object):
def foo(self): pass
class Y(dictionary):
def bar(self): pass
class Z(X, Y): pass
(這裡,'object'是所有內建型別的基類;它的結構佈局只包含 ob_refcnt 和 ob_type 成員。)這個例子更復雜,因為 X 例項的 __dict__ 指標的偏移量與 Y 例項的不同。Z 例項的 __dict__ 指標在哪裡?答案是 __dict__ 指標的偏移量不是硬編碼的,它儲存在型別物件中。
假設在特定機器上,一個“物件”結構長 8 位元組,一個“字典”結構長 60 位元組,一個物件指標長 4 位元組。那麼一個 X 結構是 12 位元組(一個物件結構後跟一個 __dict__ 指標),一個 Y 結構是 64 位元組(一個字典結構後跟一個 __dict__ 指標)。在此示例中,Z 結構與 Y 結構具有相同的佈局。每個型別物件(X、Y 和 Z)都有一個“__dict__ 偏移量”,用於查詢 __dict__ 指標。因此,查詢例項變數的方法是:
- 獲取例項的型別
- 從型別物件獲取
__dict__偏移量 - 將
__dict__偏移量新增到例項指標 - 在結果地址中查詢字典引用
- 在該字典中查詢例項變數名稱
當然,這個方法只能用 C 實現,而且我省略了一些細節。但這允許我們使用類似於經典類的多重繼承模式。
XXX 我應該在這裡寫出完整的演算法來確定基類相容性,但我現在懶得寫了。請檢視下面提到的實現中 typeobject.c 中的 best_base()。
MRO: 方法解析順序(查詢規則)
隨著多重繼承的到來,方法解析順序的問題也浮出水面:在查詢給定名稱的方法時,類或型別及其基類的搜尋順序。
在經典的 Python 中,規則由以下遞迴函式給出,也稱為從左到右深度優先規則:
def classic_lookup(cls, name):
if cls.__dict__.has_key(name):
return cls.__dict__[name]
for base in cls.__bases__:
try:
return classic_lookup(base, name)
except AttributeError:
pass
raise AttributeError, name
當我們考慮一個“菱形圖”時,這個問題變得顯而易見:
class A:
^ ^ def save(self): ...
/ \
/ \
/ \
/ \
class B class C:
^ ^ def save(self): ...
\ /
\ /
\ /
\ /
class D
箭頭從子型別指向其基 type(s)。這個特定的圖表示 B 和 C 派生自 A,D 派生自 B 和 C(因此也間接派生自 A)。
假設 C 覆蓋了在基類 A 中定義的方法 save()。(C.save() 可能會呼叫 A.save(),然後儲存它自己的某些狀態。)B 和 D 不覆蓋 save()。當我們在 D 例項上呼叫 save() 時,哪個方法會被呼叫?根據經典查詢規則,A.save() 被呼叫,忽略了 C.save()!
這不好。它可能會破壞 C(它的狀態沒有被儲存),從而違背了從 C 繼承的初衷。
為什麼在經典 Python 中這不是問題?菱形圖在經典 Python 類層次結構中很少出現。大多數類層次結構使用單一繼承,多重繼承通常僅限於 mix-in 類。事實上,這裡顯示的問題可能正是多重繼承在經典 Python 中不受歡迎的原因。
為什麼在新系統中這將成為一個問題?型別層次結構頂部的“object”型別定義了許多可以被子型別有用地擴充套件的方法,例如 __getattr__()。
(旁註:在經典 Python 中,__getattr__() 方法並非真正實現獲取屬性操作;它是一個僅在透過正常方式找不到屬性時才會被呼叫的鉤子。這經常被認為是缺點——有些類設計確實需要一個 __getattr__() 方法,該方法會被**所有**屬性引用呼叫。但當然,這個方法必須能夠直接呼叫預設實現。最自然的方式是使預設實現作為 object.__getattr__(self, name) 可用。)
因此,像這樣的經典類層次結構:
class B class C:
^ ^ def __getattr__(self, name): ...
\ /
\ /
\ /
\ /
class D
在新系統下將變成一個菱形圖:
object:
^ ^ __getattr__()
/ \
/ \
/ \
/ \
class B class C:
^ ^ def __getattr__(self, name): ...
\ /
\ /
\ /
\ /
class D
雖然在原始圖中呼叫了 C.__getattr__(),但在新系統中使用經典查詢規則時,將呼叫 object.__getattr__()!
幸運的是,有一種更好的查詢規則。解釋起來有點困難,但在菱形圖中它能做正確的事情,並且在繼承圖中沒有菱形時(即它是樹形結構時)與經典查詢規則相同。
新的查詢規則構建了一個繼承圖中所有類的列表,按照它們將被搜尋的順序。這種構建是在類定義時完成的,以節省時間。為了解釋新的查詢規則,讓我們首先考慮一下經典查詢規則的列表會是什麼樣子。請注意,在存在菱形的情況下,經典查詢會多次訪問某些類。例如,在上面的 ABCD 菱形圖中,經典查詢規則按此順序訪問類:
D, B, A, C, A
請注意 A 在列表中出現了兩次。第二次出現是多餘的,因為任何可以在那裡找到的東西在第一次搜尋時就已經找到了。
我們利用這個觀察來解釋我們的新查詢規則。使用經典的查詢規則,構建將被搜尋的類列表,包括重複項。現在對於列表中多次出現的每個類,刪除除了最後一次出現之外的所有出現。結果列表包含每個祖先類恰好一次(包括最派生類,在示例中是 D)。
按照這個順序搜尋方法將對菱形圖做正確的事情。由於列表的構建方式,在不涉及菱形的情況下,它不會改變搜尋順序。
這難道不是向後不相容嗎?它不會破壞現有程式碼嗎?如果更改所有類的方法解析順序,就會破壞。然而,在 Python 2.2 中,新的查詢規則將只應用於從內建型別派生的型別,這是一個新特性。沒有基類的類語句建立“經典類”,其基類本身是經典類的類語句也如此。對於經典類,將使用經典查詢規則。(要試驗經典類的新查詢規則,您將能夠明確指定一個不同的元類。)我們還將提供一個工具,分析類層次結構,查詢可能受方法解析順序更改影響的方法。
XXX 另一種解釋新 MRO 動機的方式,由 Damian Conway 提出:如果您尚未探索派生類中定義的方法(使用舊的搜尋順序),則永遠不會使用基類中定義的方法。
XXX 待辦事項
本 PEP 中將討論的其他主題:
- 向後相容性問題!!!
- 類方法和靜態方法
- 協作方法和
super() - 型別物件槽位(tp_foo)和特殊方法(
__foo__)之間的對映(實際上,這可能屬於 PEP 252) - 內建型別的內建名稱(object、int、str、list 等)
__dict__和__dictoffset____slots__HEAPTYPE標誌位- GC 支援
- 所有新函式的 API 文件
- 如何使用
__new__ - 編寫元類(使用
mro()等) - 高階使用者概覽
開放問題
- 我們需要
__del__嗎? - 賦值給
__dict__,__bases__ - 命名不一致(例如 tp_dealloc/tp_new/tp_init/tp_alloc/tp_free)
- 為“dictionary”新增內建別名“dict”?
- 當字典/列表等的子類傳遞給系統函式時,
__getitem__覆蓋(等)並不總是被使用
實施
本 PEP(以及 PEP 252)的原型實現可從 CVS 獲取,幷包含在 Python 2.2 alpha 和 beta 版本系列中。有關此處描述功能的示例,請參閱檔案 Lib/test/test_descr.py 和擴充套件模組 Modules/xxsubtype.c。
參考資料
版權
本文件已置於公共領域。
來源:https://github.com/python/peps/blob/main/peps/pep-0253.rst