Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 697 – 扩展不透明类型的受限 C API

作者:
Petr Viktorin <encukou at gmail.com>
讨论至:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
创建日期:
2022年8月23日
Python 版本:
3.12
发布历史:
2022年5月24日, 2022年10月6日
决议:
Discourse 消息

目录

重要

本 PEP 是一个历史文档。最新的规范文档现在可以在 PyType_Spec.basicsize, PyObject_GetTypeData(), Py_TPFLAGS_ITEMS_AT_END, Py_RELATIVE_OFFSET, PyObject_GetItemData() 中找到。

×

有关如何提出更改,请参阅 PEP 1

摘要

通过允许代码只处理特定(子)类的数据,为用不透明数据扩展某些类型添加了 受限 C API 支持。

此机制需要可与 PyHeapTypeObject 一起使用。

本 PEP 不提议允许扩展非动态大小的可变大小对象,例如 tupleint,因为它们有不同的内存布局,并且目前认为没有这样的需求。本 PEP 为将来在需要时通过相同的机制留下了空间。

动机

本 PEP 解决的激励性问题是将 C 级状态附加到自定义类型——即元类(type 的子类)。

在将另一个类型系统(例如 C++、Java、Rust)公开为 Python 类时,“包装器”通常需要这样做。这些通常需要将有关“包装”的非 Python 类的信息附加到 Python 类型对象。

这应该可以在 Limited API 中完成,以便语言包装器或代码生成器可用于创建稳定的 ABI 扩展。(有关提供稳定 ABI 的好处,请参阅 PEP 652。)

扩展 type 是一个更普遍问题的实例:在保持松散耦合的同时扩展一个类——即不依赖于超类使用的内存布局。(这有很多行话;有关扩展 list 的具体示例,请参阅“基本原理”。)

基本原理

扩展不透明类型

在 Limited API 中,大多数 struct 都是不透明的:它们的尺寸和内存布局不暴露,因此可以在 CPython 的新版本(或 C API 的替代实现)中更改。

这意味着通常的子类化模式——将用于类型实例的 struct 作为用于派生类型实例的 struct 的第一个元素——不起作用。为了用代码说明,教程中的示例 使用以下 struct 扩展了 PyListObjectlist

typedef struct {
    PyListObject list;
    int state;
} SubListObject;

这在 Limited API 中将无法编译,因为 PyListObject 是不透明的(以允许在实现功能和优化时进行更改)。

相反,本 PEP 建议使用一个只包含子类所需状态的 struct,即

typedef struct {
    int state;
} SubListState;

// (or just `typedef int SubListState;` in this case)

子类现在可以与超类的内存布局(和大小)完全解耦。

这在今天已经可能。要使用这样的结构,请执行以下操作:

  • 创建类时,使用 PyListObject->tp_basicsize + sizeof(SubListState) 作为 PyType_Spec.basicsize
  • 访问数据时,使用 PyListObject->tp_basicsize 作为实例(PyObject*)的偏移量。

然而,这有缺点

  • 基类的 basicsize 可能没有正确对齐,如果未缓解,可能导致某些架构上出现问题。(如果对齐在新版本中发生变化,这些问题可能特别棘手。)
  • PyTypeObject.tp_basicsize 未在 Limited API 中公开,因此支持 Limited API 的扩展需要使用 PyObject_GetAttrString(obj, "__basicsize__")。这很麻烦,并且在极端情况下不安全(Python 属性可能被覆盖)。
  • 可变大小对象未处理(参见下面的扩展可变大小对象)。

为了使其易于使用(甚至对于选择松散耦合而非最大性能的项目而言成为最佳实践),本 PEP 提出了一个 API 来

  1. 在类创建期间,指定 SubListState 应该“附加”到 PyListObject,而无需传递有关 list 的任何其他详细信息。(解释器本身从基类获取所有必要信息,例如 tp_basicsize。)

    这将通过负的 PyType_Spec.basicsize 指定:-sizeof(SubListState)

  2. 给定一个实例和子类 PyTypeObject*,获取指向 SubListState 的指针。为此将添加一个新函数 PyObject_GetTypeData

基类当然不限于 PyListObject:它可以用于扩展任何基类,其实例 struct 是不透明的、在不同版本之间不稳定或根本未公开的——包括 type (PyHeapTypeObject) 或第三方扩展(例如 NumPy 数组 [1])。

对于不需要额外状态的情况,允许零 basicsize:在这种情况下,将继承基类的 tp_basicsize。(这目前有效,但缺乏明确的文档和测试。)

新类的 tp_basicsize 将设置为计算出的总大小,因此检查类的代码将继续像以前一样工作。

扩展可变大小对象

子类化 可变大小对象 时需要额外的考虑,同时保持松散耦合:可变大小数据可能与子类数据(上例中的 SubListState)冲突。

目前,CPython 没有提供防止此类冲突的方法。因此,扩展不透明类的建议机制(负 base->tp_itemsize)默认将失败

我们可以止步于此,但是由于激励类型——PyHeapTypeObject——是可变大小的,我们需要一种安全的方法来允许对其进行子类化。首先介绍一些背景知识:

可变大小布局

可变大小对象主要有两种内存布局。

在像 inttuple 这样的类型中,可变数据存储在固定偏移量处。如果子类需要额外的空间,它必须在任何可变大小数据之后添加

PyTupleObject:
┌───────────────────┬───┬───┬╌╌╌╌┐
│ PyObject_VAR_HEAD │var. data   │
└───────────────────┴───┴───┴╌╌╌╌┘

tuple subclass:
┌───────────────────┬───┬───┬╌╌╌╌┬─────────────┐
│ PyObject_VAR_HEAD │var. data   │subclass data│
└───────────────────┴───┴───┴╌╌╌╌┴─────────────┘

在其他类型中,例如 PyHeapTypeObject,可变大小数据始终位于实例内存区域的末尾

heap type:
┌───────────────────┬──────────────┬───┬───┬╌╌╌╌┐
│ PyObject_VAR_HEAD │Heap type data│var. data   │
└───────────────────┴──────────────┴───┴───┴╌╌╌╌┘

type subclass:
┌───────────────────┬──────────────┬─────────────┬───┬───┬╌╌╌╌┐
│ PyObject_VAR_HEAD │Heap type data│subclass data│var. data   │
└───────────────────┴──────────────┴─────────────┴───┴───┴╌╌╌╌┘

第一个布局可以快速访问项数组。第二个布局允许子类忽略可变大小数组(假设它们使用从对象开始处的偏移量来访问其数据)。

由于本 PEP 关注 PyHeapTypeObject,它提出了一个 API,允许对第二种变体进行子类化。对第一种变体的支持可以在以后作为API 兼容的更改添加(尽管 PEP 作者怀疑这是否值得付出努力)。

使用 PyHeapTypeObject 类似布局扩展类

本 PEP 提出了一个类型标志 Py_TPFLAGS_ITEMS_AT_END,它将指示 PyHeapTypeObject 类似的布局。这可以通过两种方式设置

  • 超类可以设置该标志,允许子类作者无需关心 itemsize 的参与,或者
  • 新的子类设置该标志,表明作者知道超类是合适的(但可能尚未更新以使用该标志)。

此标志将是使用负 basicsize 扩展可变大小类型所必需的。

除了使用标志之外,另一种方法是要求子类作者知道基类使用了兼容的布局(例如,从文档中得知)。本 PEP 的早期版本为此提出了一个新的 PyType_Slot。事实证明这很难解释,并且与将子类与基类布局解耦的想法相悖。

新标志将用于安全地扩展可变大小类型:使用 spec->basicsize < 0base->tp_itemsize > 0 创建类型将需要该标志。

此外,本 PEP 提出了一个辅助函数,用于获取给定实例的可变大小数据,如果它使用新的 Py_TPFLAGS_ITEMS_AT_END 标志。这将在 API 后面隐藏必要的指针算术,该 API 将来可能适应其他布局(包括,可能,VM 管理的布局)。

宏观视图

为了更容易验证所有情况都已涵盖,这里有一个看起来吓人的宏观决策树。

注意

个别情况更容易单独解释(请参阅参考实现以获取草案文档)。

  • spec->basicsize > 0:现状不变。(基类布局已知。)
  • spec->basicsize == 0:(继承 basicsize)
    • base->tp_itemsize == 0:项目大小设置为 spec->tp_itemsize。(现状不变。)
    • base->tp_itemsize > 0:(扩展可变大小类)
      • spec->itemsize == 0:项目大小已继承。(现状不变。)
      • spec->itemsize > 0:项目大小已设置。(这很难安全使用,但这是 CPython 当前的行为。)
  • spec->basicsize < 0:(扩展 basicsize)
    • base->tp_itemsize == 0:(扩展固定大小类)
      • spec->itemsize == 0:项目大小设置为 0。
      • spec->itemsize > 0:失败。(我们需要添加一个 ob_size,这仅适用于平凡类型——并且必须知道平凡布局。)
    • base->tp_itemsize > 0:(扩展可变大小类)
      • spec->itemsize == 0:(继承 itemsize)
        • 使用 Py_TPFLAGS_ITEMS_AT_END:继承 itemsize。
        • 未使用 Py_TPFLAGS_ITEMS_AT_END:失败。(可能存在冲突。)
      • spec->itemsize > 0:失败。(无法安全地更改/扩展 item size。)

设置 spec->itemsize < 0 始终是错误。本 PEP 不提议任何机制来扩展 tp->itemsize,而只是继承它。

相对成员偏移

难题的另一部分是 PyMemberDef.offset。使用特定于子类的 struct(上面的 SubListState)的扩展将能够指定“相对”偏移量(基于此 struct 的偏移量),而不是“绝对”偏移量(基于 PyObject struct)。

一种方法是在使用新 API 创建类时自动假定“相对”偏移量。然而,这种隐式假设会过于令人惊讶。

为了更明确,本 PEP 提出了一个用于“相对”偏移量的新标志。至少在最初,此标志将仅用于检查误用(并为审阅者提供提示)。如果与新 API 一起使用,则必须存在此标志,否则不得使用此标志。

规范

在下面的代码块中,只有函数头是规范的一部分。其他代码(大小/偏移量计算)是初始 CPython 实现的细节,可能会发生变化。

相对 basicsize

PyType_Specbasicsize 成员将允许为零或负数。在这种情况下,它的绝对值将指定新类的实例除了基类的 basicsize 之外所需的额外存储空间大小。也就是说,结果类的 basicsize 将是

type->tp_basicsize = _align(base->tp_basicsize) + _align(-spec->basicsize);

其中 _align 向上舍入到 alignof(max_align_t) 的倍数。

spec->basicsize 为零时,basicsize 将直接继承,即设置为 base->tp_basicsize 而不进行对齐。(这已经有效;将添加显式测试和文档。)

在实例上,特定于子类的内存区域——即子类除了其基类之外保留的“额外空间”——将通过一个新函数 PyObject_GetTypeData 获得。在 CPython 中,此函数将定义为

void *
PyObject_GetTypeData(PyObject *obj, PyTypeObject *cls) {
    return (char *)obj + _align(cls->tp_base->tp_basicsize);
}

将添加另一个函数以检索此内存区域的大小

Py_ssize_t
PyType_GetTypeDataSize(PyTypeObject *cls) {
    return cls->tp_basicsize - _align(cls->tp_base->tp_basicsize);
}

结果可能高于 -basicsize 请求的值。使用所有这些值是安全的(例如,使用 memset)。

新的 *Get* 函数带有一个重要的注意事项,这将在文档中指出:它们只能用于使用负 PyType_Spec.basicsize 创建的类。对于其他类,它们的行为是未定义的。(请注意,这允许上述代码假定 cls->tp_base 不为 NULL。)

继承 itemsize

spec->itemsize 为零时,tp_itemsize 将从基类继承。(这已经有效;将添加显式测试和文档。)

将添加一个新的类型标志 Py_TPFLAGS_ITEMS_AT_END。此标志只能在 tp_itemsize 非零的类型上设置。它表示实例的可变大小部分存储在实例内存的末尾。

默认元类型(PyType_Type)将设置此标志。

将添加一个新函数 PyObject_GetItemData,用于访问带有新标志的类型的可变大小内容所保留的内存。在 CPython 中,它将被定义为

void *
PyObject_GetItemData(PyObject *obj) {
    if (!PyType_HasFeature(Py_TYPE(obj), Py_TPFLAGS_ITEMS_AT_END) {
        <fail with TypeError>
    }
    return (char *)obj + Py_TYPE(obj)->tp_basicsize;
}

此函数最初不会添加到 Limited API 中。

使用负 spec->basicsize 扩展带有正 base->itemsize 的类将失败,除非设置了 Py_TPFLAGS_ITEMS_AT_END,无论是在基类上还是在 spec->flags 中。(有关完整解释,请参阅扩展可变大小对象。)

使用负 spec->basicsize 扩展带有正 spec->itemsize 的类将失败。

相对成员偏移

在使用负 PyType_Spec.basicsize 定义的类型中,通过 Py_tp_members 定义的成员的偏移量必须相对于额外的子类数据,而不是完整的 PyObject 结构体。这将在 PyMemberDef.flags 中由一个新标志表示:Py_RELATIVE_OFFSET

在初始实现中,新标志将是冗余的。它仅用于使偏移量的更改含义清晰,并有助于避免错误。不使用带负 basicsizePy_RELATIVE_OFFSET 将是一个错误,而在任何其他上下文(即对 PyDescr_NewMemberPyMember_GetOnePyMember_SetOne 的直接或间接调用)中使用它也将是一个错误。

CPython 将在初始化类型时调整偏移量并清除 Py_RELATIVE_OFFSET 标志。这意味着

  • 创建的类型的 tp_members 将与输入定义的 Py_tp_members 槽不匹配,并且
  • 任何读取 tp_members 的代码都无需处理该标志。

新 API 列表

提出了以下新的函数/值。

这些将添加到 Limited API/Stable ABI 中

  • void * PyObject_GetTypeData(PyObject *obj, PyTypeObject *cls)
  • Py_ssize_t PyType_GetTypeDataSize(PyTypeObject *cls)
  • Py_TPFLAGS_ITEMS_AT_END 标志用于 PyTypeObject.tp_flags
  • Py_RELATIVE_OFFSET 标志用于 PyMemberDef.flags

这些将仅添加到公共 C API 中

  • void *PyObject_GetItemData(PyObject *obj)

向后兼容性

未发现向后兼容性问题。

假设

该实现假设实例的内存位于 type->tp_base->tp_basicsizetype->tp_basicsize 偏移量之间“属于” type(可变长度类型除外)。这并未明确记录,但 CPython 3.11 及更早版本在向子类添加 __dict__ 时依赖于它,因此应该安全。

安全隐患

未知。

认可

pybind11 的作者最初要求解决此问题(请参阅此列表中的第 2 点),并且一直在验证实现

HPy 项目的 Florian 表示该 API 总体上看起来不错。(有关性能问题的可能解决方案,请参阅下文。)

如何教授此内容

初始实现将包括参考文档和“新增功能”条目,这对于目标受众——C 扩展库的作者——来说应该足够了。

参考实现

参考实现在 encukou/cpython GitHub 存储库的 extend-opaque 分支中。

未来可能的增强

对齐与性能

如果实例结构需要比 alignof(max_align_t) 更小的对齐方式,则拟议的实现可能会浪费一些空间。此外,处理对齐会使计算速度变慢,如果我们能够依赖 base->tp_basicsize 正确对齐子类型,那么计算速度会更快。

换句话说,提议的实现侧重于安全性和易用性,并为此牺牲了空间和时间。如果这被证明是一个问题,可以在不破坏 API 的情况下调整实现

  • 类型特定缓冲区的偏移量可以存储起来,这样 PyObject_GetTypeData 实际上就变成了 (char *)obj + cls->ht_typedataoffset,这可能会以类中多一个指针为代价来加快速度。
  • 然后,一个新的 PyType_Slot 可以指定所需的对齐方式,以减少实例的空间需求。

可变大小类型的其他布局

可以添加一个类似 Py_TPFLAGS_ITEMS_AT_END 的标志,以指示扩展可变大小对象中描述的“类元组”布局,并且本 PEP 提出的所有机制都可以适应以支持它。也可以添加其他布局。然而,似乎实际收益很小,因此这只是一个理论上的可能性。

被拒绝的想法

可以用一个新的 PyType_Spec 标志代替负的 spec->basicsize。对于任何在没有最新更改知识的情况下访问这些内部的现有代码,其效果将是相同的,因为在此情况下,字段值的含义正在改变。

脚注


来源: https://github.com/python/peps/blob/main/peps/pep-0697.rst

最后修改: 2025-02-01 08:55:40 GMT