Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

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 中子类型化
  • 支持从类型进行多重继承(在可行的情况下 - 你仍然不能从 list 和 dictionary 中多重继承)
  • 标准强制转换函数(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 中,存在一个元类层次结构,它镜像普通类的层次结构,元类与类一一映射(除了层次结构根部的一些奇怪行为),并且每个 class 语句都创建了一个普通类和它的元类,将类方法放在元类中,将实例方法放在普通类中。

这在 Smalltalk 的上下文中可能很好,但它与 Python 中元类型的传统使用不兼容,我更愿意继续使用 Python 的方式。这意味着 Python 元类型通常是用 C 编写的,并且可能在许多普通类型之间共享。(在 Python 中子类型化元类型是可能的,因此绝对不必编写 C 来使用元类型;但 Python 元类型的能力将受到限制。例如,Python 代码将永远不允许分配原始内存并随意初始化它。)

元类型确定类型的各种 **策略**,例如类型被调用时会发生什么,类型的动态性如何(类型 __dict__ 是否可以在创建后被修改),方法解析顺序是什么,实例属性如何查找,等等。

我认为,当您想要充分利用多重继承时,从左到右的深度优先并不是最好的解决方案。

我认为,使用多重继承时,子类型的元类型必须是所有基类型元类型的后代。

我将稍后回到元类型。

使类型成为其实例的工厂

传统上,对于每种类型,至少有一个 C 工厂函数用于创建类型的实例(PyTuple_New()PyInt_FromLong() 等)。这些工厂函数负责为对象分配内存以及初始化该内存。从 Python 2.0 开始,如果类型选择参与垃圾回收,它们还必须与垃圾回收子系统进行交互(这是可选的,但强烈建议用于所谓的“容器”类型:可能包含对其他对象的引用的类型,因此可能参与引用循环)。

在本提案中,类型对象可以是其实例的工厂函数,使类型可以直接从 Python 中调用。这模仿了类实例化的方式。用于创建各种内置类型实例的 C API 将保持有效,并且在某些情况下效率更高。并非所有类型都将成为自己的工厂函数。

类型对象有一个新的槽,tp_new,它可以充当类型实例的工厂。类型现在是可调用的,因为 tp_call 槽在 PyType_Type(元类型)中设置;该函数查找正在调用的类型的 tp_new 槽。

解释:普通类型对象(如 PyInt_TypePyList_Type)的 tp_call 槽定义了当该类型的 **实例** 被调用时会发生什么;特别是,函数类型 PyFunction_Type 中的 tp_call 槽是使函数可调用的关键。例如,PyInt_Type.tp_callNULL,因为整数不可调用。新范式使 **类型对象** 可调用。由于类型对象是其元类型 (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() 槽位来进一步初始化,前提是它不为空。它的签名如下:

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() 的类型参数是定义 tp_new() 函数的类型时(例如,如果 type == &PyInt_Type),并且当此类型的 tp_init() 槽位不执行任何操作时才允许。如果类型参数不同,则 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)

类型参数是新对象的类型。nitems 参数通常为零,除非对象具有可变的分配大小(基本上是字符串、元组和长整数)。分配大小由以下表达式给出:

type->tp_basicsize  +  nitems * type->tp_itemsize

tp_alloc 槽位仅用于可子类化的类型。基类的 tp_new() 函数必须调用作为其第一个参数传递的类型的 tp_alloc() 槽位。计算项目数量是 tp_new() 函数的责任。如果 type->tp_itemsize 成员不为零,则 tp_alloc() 槽位将设置新对象的 ob_size 成员。

(注意:在某些调试编译模式下,类型结构曾经具有名为 tp_alloctp_free 的成员槽位,它们是分配和解除分配次数的计数器。这些已被重命名为 tp_allocstp_deallocs。)

tp_alloc()tp_new() 的标准实现是可用的。 PyType_GenericAlloc() 从标准堆分配一个对象并正确初始化它。它使用上面的公式来确定要分配的内存量,并负责 GC 注册。不使用此实现的唯一原因是从不同的堆分配对象(如某些非常小的经常使用的对象(如 int 和元组)所做的那样)。 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 直接与类型参数进行比较,如果匹配,则绕过函数调用。这应该足以满足大多数情况。

请注意,类型检查宏中的此更改意味着,需要基类型实例的 C 函数可以用派生类型的实例来调用。在启用特定类型的子类型化之前,应该检查其代码以确保这不会破坏任何东西。事实证明,在原型中添加另一个类型检查宏以用于内置的 Python 对象类型(以检查精确的类型匹配)也很有用(例如, PyDict_Check(x) 在 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_basicsizetp_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.

class 语句的主体在新的环境中执行(基本上是一个用作局部命名空间的新字典),然后创建 C。以下解释了如何创建 C。

假设 B 是一个类型对象。由于类型对象是对象,并且每个对象都有一个类型,因此 B 也有一个类型。由于 B 本身也是一个类型,因此我们也将其类型称为元类型。B 的元类型可以通过 type(B)B.__class__ 访问(后一种表示法是针对类型的新表示法;它在 PEP 252 中引入)。假设此元类型为 M(表示元类型)。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 本身有一个类型:元元类型。而元元类型也有一个类型,元元元类型。以此类推。这通常通过在某个级别上使元类型成为它自己的元类型来缩短。这实际上是 Python 中发生的情况:ob_typePyType_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 类语句支持多重继承,我们也将支持涉及内置类型的多重继承。

但是,有一些限制。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_refcntob_type 成员。)这个例子比较复杂,因为 X 实例的 __dict__ 指针具有与 Y 实例不同的偏移量。Z 实例的 __dict__ 指针在哪里?答案是 __dict__ 指针的偏移量不是硬编码的,而是存储在类型对象中。

假设在特定机器上,“object” 结构长 8 字节,“dictionary” 结构长 60 字节,对象指针长 4 字节。那么 X 结构长 12 字节(一个对象结构,后面跟着一个 __dict__ 指针),而 Y 结构长 64 字节(一个字典结构,后面跟着一个 __dict__ 指针)。在这个例子中,Z 结构与 Y 结构具有相同的布局。每个类型对象(X、Y 和 Z)都有一个“__dict__ 偏移量”,用于查找 __dict__ 指针。因此,查找实例变量的方案是

  1. 获取实例的类型
  2. 从类型对象中获取 __dict__ 偏移量
  3. __dict__ 偏移量添加到实例指针
  4. 在结果地址中查找字典引用
  5. 在该字典中查找实例变量名称

当然,这个方案只能在 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 类层次结构中。大多数类层次结构使用单一继承,而多重继承通常限于混合类。事实上,这里展示的问题可能是多重继承在经典 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 提出:如果你还没有探索过派生类(使用旧的搜索顺序),你永远不会使用在基类中定义的方法。

待办事项

将在本 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”?
  • 当将 dict/list 等的子类传递给系统函数时,__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

最后修改时间:2023-09-09 17:39:29 GMT