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