PEP 253 – 內建類型的子類型化
- 作者:
- Guido van Rossum <guido at python.org>
- 狀態:
- 最終 (Final)
- 類型:
- 標準軌跡 (Standards Track)
- 建立日期:
- 2001年5月14日
- Python 版本:
- 2.2
- 公告歷史:
摘要
本 PEP 提議對類型物件 API 進行擴充,允許在 C 語言和 Python 中建立內建類型的子類型。
[編輯註:本 PEP 中描述的想法已被併入 Python。此 PEP 不再準確描述目前的實作方式。]
簡介
傳統上,Python 中的類型是通過宣告一個 PyTypeObject 類型的全域變數,並使用靜態初始化器進行初始化來靜態建立的。類型物件中的槽 (slots) 描述了與 Python 解釋器相關的 Python 類型之所有方面。少數槽包含維度資訊(如實例的基本分配大小),其他槽包含各種標誌,但大多數槽是指向函數的指標,用以實作各種類型的行為。NULL 指標意味著該類型沒有實作特定的行為;在這種情況下,系統可能會提供預設行為,或在實例調用該行為時引發異常。通常定義在一起的函數指標集合,是通過指向包含更多函數指標的附加結構的指標間接獲得的。
雖然 PyTypeObject 結構初始化的細節並未正式歸檔,但可以很容易地從原始碼範例中領會,並且我假設讀者已經熟悉在 C 語言中建立新 Python 類型的傳統方式。
本 PEP 將引入以下功能:
- 類型可以作為其實例的工廠函數
- 類型可以在 C 語言中被子類型化
- 類型可以在 Python 中通過 class 語句被子類型化
- 支援類型的多重繼承(在實際可行範圍內——你仍然不能同時繼承 list 和 dictionary)
- 標準強制轉換函數(int、tuple、str 等)將被重新定義為對應的類型物件,這些物件同時作為它們自己的工廠函數
- class 語句可以包含
__metaclass__宣告,指定用於建立新類別的元類別 (metaclass) - class 語句可以包含
__slots__宣告,指定支援的實例變數的具體名稱
本 PEP 建立在 PEP 252 的基礎上,後者為類型增加了標準內省功能;例如,當特定的類型物件初始化 tp_hash 槽時,該類型物件在進行內省時會擁有一個 __hash__ 方法。PEP 252 還為類型物件添加了一個包含所有方法的字典。在 Python 層級,對於內建類型而言,此字典是唯讀的;在 C 層級,它可以被直接存取(但不應在初始化以外的場合進行修改)。
為了二進制相容性,tp_flags 槽中的一個標誌位元用於指示下方引入的類型物件中存在各種新槽。在 tp_flags 槽中未設定 Py_TPFLAGS_HAVE_CLASS 位元的類型,其所有子類型化槽均被假定為 NULL 值。(警告:目前的實作原型在檢查此標誌位元時尚未完全一致。這應在最終發佈前修復。)
在目前的 Python 中,類型 (types) 和類別 (classes) 之間存在區別。本 PEP 與 PEP 254 將消除這種區別。然而,為了向後相容,這種區別可能還會存在多年,而且如果沒有 PEP 254,這種區別仍然很大:類型最終都有一個內建類型作為基底類別,而類別最終繼承自使用者定義的類別。因此,在本 PEP 的其餘部分中,我將盡可能使用「類型 (type)」一詞——包括基底類型 (base type) 或父類型 (supertype)、衍生類型 (derived type) 或子類型 (subtype) 以及元類型 (metatype)。不過,術語有時必然會融合,例如物件的類型是由其 __class__ 屬性給出的,而 Python 中的子類型化是通過 class 語句書寫的。如果需要進一步區分,使用者定義的類別可以稱為「傳統 (classic)」類別。
關於元類型 (metatypes)
討論不可避免地會涉及元類型(或元類別)。元類型在 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 hook」,它指出如果一個元類型是可調用的,那麼它的實例(即普通類型)可以使用 Python 的 class 語句進行子類別化(實際上是子類型化)。我將使用此規則來支援內建類型的子類型化,事實上,將 class 的建立簡化為始終僅僅調用元類型,極大地簡化了邏輯。當未指定基底類別時,將調用預設的元類型——預設元類型是「ClassType」物件,因此 class 語句在正常情況下表現如前。(此預設值可以通過設定全域變數 __metaclass__ 在每個模組中進行更改。)
Python 使用元類型或元類別的概念與 Smalltalk 不同。在 Smalltalk-80 中,存在一個反映普通類別層次結構的元類別層次結構,元類別與類別是一對一對映的(層次結構根部的某些特殊情況除外),並且每個 class 語句都會同時建立一個普通類別及其元類別,將類別方法放入元類別中,將實例方法放入普通類別中。
雖然這在 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 hook,見上文)。那麼是什麼讓 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() 槽之間的責任差異在於它們所確保的不變量 (invariants)。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() 應始終返回一個新物件(或引發異常)。
tp_new() 和 tp_init() 都應接收完全相同的 'args' 和 'kwds' 參數,並且兩者都應檢查參數是否可接受,因為它們可能會被獨立調用。
還有第三個與物件建立相關的槽: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++ 的 vtable 不同,所有 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 和 type 參數,如果匹配,則跳過函數調用。這應使其在大多數情況下足夠快。
請注意,類型檢查巨集的這種變化意味著需要基底類型實例的 C 函數可能會接收到衍生類型的實例。在啟用特定類型的子類型化之前,應檢查其程式碼以確保這不會破壞任何東西。在原型中,為內建 Python 物件類型添加另一個類型檢查巨集以檢查精確類型匹配也很有用(例如,如果 x 是字典或字典子類別的實例,PyDict_Check(x) 為真;而僅當 x 是字典時,PyDict_CheckExact(x) 才為真)。
在 C 語言中建立內建類型的子類型
最簡單的子類型化形式是 C 語言中的子類型化。它是最簡單的形式,因為我們可以要求 C 程式碼意識到一些問題,並且對於不遵循規則的 C 程式碼,出現核心傾印 (dump core) 也是可以接受的。為了進一步簡化,它僅限於單一繼承。
假設我們正從一個可變基底類型衍生,且其 tp_itemsize 為零。子類型程式碼不具備 GC 感知能力,儘管它可能從基底類型繼承 GC 感知能力(這是自動的)。基底類型的分配使用標準堆。
衍生類型首先宣告一個包含基底類型結構的類型結構。例如,這裡是內建 list 類型的子類型的類型結構:
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() 槽。此調用必須通過基底類型的類型結構進行,例如,在從標準 list 類型衍生時:
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.
class 語句的主體在一個全新的環境(基本上是一個用作區域命名空間的新字典)中執行,然後建立 C。以下解釋 C 是如何建立的。
假設 B 是一個類型物件。由於類型物件也是物件,並且每個物件都有一個類型,因此 B 也有一個類型。由於 B 本身是一個類型,我們也稱它的類型為元類型。B 的元類型可以通過 type(B) 或 B.__class__(後者對類型而言是新的表示法,在 PEP 252 中引入)來存取。假設這個元類型是 M (Metatype)。class 語句將建立一個新類型 C。由於 C 將像 B 一樣是一個類型物件,我們將 C 的建立視為元類型 M 的實例化。建立子類別所需的資訊是:
- 它的名稱(在此例中為字串 "C");
- 它的基底(包含 B 的單元素元組);
- 執行 class 主體的結果,以字典形式呈現(例如
{"var1": 1, "method1": <functionmethod1 at ...>, ...})。
class 語句將導致以下調用:
C = M("C", (B,), dict)
其中 dict 是執行 class 主體後產生的字典。換句話說,元類型 (M) 被調用了。
請注意,即使範例只有一個基底,我們仍然傳入了一個(單元素)基底序列;這使得介面在多重繼承情況下保持一致。
在目前的 Python 中,這被稱為「Don Beaudry hook」(以其發明者命名);這是一種僅在基底類別不是普通類別時才會被觸發的特殊情況。對於普通基底類別(或未指定基底類別時),目前的 Python 直接調用 PyClass_New(),即類別的 C 層級工廠函數。
在新的系統下,這被改變了,使得 Python 總是確定一個元類型並按上述方式調用它。當給出一個或多個基底時,第一個基底的類型將被用作元類型;當沒有給出基底時,會選擇預設的元類型。通過將預設元類型設定為 PyClass_Type(「傳統」類別的元類型),保留了 class 語句的傳統行為。此預設值可以通過在每個模組中設定全域變數 __metaclass__ 來更改。
這裡還有兩個進一步的改進。首先,一個實用的功能是能夠直接指定一個元類型。如果 class 套件定義了一個變數 __metaclass__,那麼這就是要調用的元類型。(請注意,在模組層級設定 __metaclass__ 只會影響沒有基底類別且沒有顯式 __metaclass__ 宣告的 class 語句;但在 class 套件中設定 __metaclass__ 會無條件覆寫預設元類型。)
其次,對於多重基底,並非所有基底都必須具有相同的元類型。這稱為元類別衝突 [1]。一些元類別衝突可以通過在基底集合中搜尋一個繼承自所有其他給定元類別的元類型來解決。如果找不到這樣的元類型,則會引發異常,並且 class 語句失敗。
這種衝突解決可以由元類型建構函數實作:class 語句僅調用第一個基底的元類型(或由 __metaclass__ 變數指定的元類型),而該元類型的建構函數會查找最派生的元類型。如果那就是它自己,則繼續;否則,它會調用該元類型的建構函數。(終極靈活性:另一個元類型可能選擇要求所有基底具有相同的元類型,或者只能有一個基底類別,等等。)
(在 [1] 中,自動衍生出一個作為所有給定元類別子類別的新元類別。但由於在 Python 中如何合併各種元類別的衝突方法定義存在疑問,我不認為這是可行的。如果需要,使用者可以手動衍生這樣的元類別並使用 __metaclass__ 變數指定它。擁有一個執行此操作的新元類別也是可能的。)
請注意,調用 M 要求 M 本身有一個類型:元-元類型 (meta-metatype)。而元-元類型又有一個類型,即元-元-元類型。依此類推。這通常在某個層級通過使一個元類型成為其自身的元類型來截斷。這確實是 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 編譯器產生相對於物件指標的偏移量,就是這樣。例如,list 和 dictionary 類型結構各自宣告了許多不同但重疊的結構成員。一個期待 list 的 C 函數在傳入一個字典時將無法運作,反之亦然,若不重寫所有存取 list 和 dictionary 的程式碼,我們對此無能為力。這將是過大的工作量,所以我們不會這麼做。
多重繼承的問題是由於衝突的結構成員分配引起的。在 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 實例具有 dictionary 的佈局,後接一個 __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__ 指標的偏移量並非硬編碼,它儲存在類型物件中。
假設在特定機器上,'object' 結構長度為 8 位元組,'dictionary' 結構為 60 位元組,而物件指標為 4 位元組。那麼 X 結構為 12 位元組(一個 object 結構後跟一個 __dict__ 指標),而 Y 結構為 64 位元組(一個 dictionary 結構後跟一個 __dict__ 指標)。在這個例子中,Z 結構具有與 Y 結構相同的佈局。每個類型物件(X、Y 和 Z)都有一個用於查找 __dict__ 指標的「__dict__ offset」。因此,查找實例變數的配方是:
- 取得實例的類型
- 從類型物件中取得
__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
當我們考慮「菱形圖 (diamond diagram)」時,這個問題顯而易見:
class A:
^ ^ def save(self): ...
/ \
/ \
/ \
/ \
class B class C:
^ ^ def save(self): ...
\ /
\ /
\ /
\ /
class D
箭頭從子類型指向其基底 type(s)。這個特定的圖表意味著 B 和 C 繼承自 A,D 繼承自 B 和 C(因此也間接繼承自 A)。
假設 C 覆寫了方法 save(),該方法在基底 A 中定義。(C.save() 可能會調用 A.save() 然後保存它自己的一些狀態。)B 和 D 沒有覆寫 save()。當我們在 D 實例上調用 save() 時,哪個方法被調用?根據傳統的查找規則,A.save() 被調用,而忽略了 C.save()!
這不好。它可能會破壞 C(它的狀態沒有被保存),這違背了從 C 繼承的初衷。
為什麼這在傳統 Python 中不是問題?菱形圖在傳統 Python 類別層次結構中很少見。大多數類別層次結構使用單一繼承,多重繼承通常僅限於混入 (mix-in) 類別。事實上,這裡顯示的問題可能是多重繼承在傳統 Python 中不受歡迎的原因。
為什麼這在系統中會成為問題?類型層次結構頂部的 'object' 類型定義了許多可以由子類型有效地擴充的方法,例如 __getattr__()。
(旁白:在傳統 Python 中,__getattr__() 方法並非真正獲取屬性操作的實作;它是一個僅在無法通過正常方式找到屬性時才被觸發的鉤子 (hook)。這經常被引用為一個缺點——有些類別設計合法地需要一個在所有屬性參考時都會被調用的 __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 中,新的查找規則將僅應用於衍生自內建類型的類型,這是一項新功能。沒有基底類別的 class 語句會建立「傳統類別」,基底類別本身為傳統類別的 class 語句也是如此。對於傳統類別,將使用傳統的查找規則。(若要試驗傳統類別的新查找規則,您將能夠顯式指定不同的元類別。)我們還將提供一個工具,用於分析類別層次結構,尋找會受方法解析順序變更影響的方法。
XXX 另一種解釋對新 MRO 動機的方式,來自 Damian Conway:如果您尚未探索衍生類別中定義的方法(使用舊搜尋順序),則永遠不要使用基底類別中定義的方法。
XXX 待辦事項
本 PEP 中將討論的其他主題:
- 向後相容性問題!!!
- 類別方法 (class methods) 和靜態方法 (static methods)
- 協作方法 (cooperative methods) 和
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'?
- 當 dict/list 等的子類別傳遞給系統函數時,
__getitem__的覆寫(等)並不總是會被使用
實作
本 PEP(以及 PEP 252)的原型實作可從 CVS 以及 Python 2.2 alpha 和 beta 系列發行版中獲得。有關此處描述功能的範例,請參閱檔案 Lib/test/test_descr.py 和擴充模組 Modules/xxsubtype.c。
參考文獻
版權
此文件已歸入公有領域 (public domain)。
來源: https://github.com/python/peps/blob/main/peps/pep-0253.rst
最後修改: 2025-02-01 08:55:40 GMT