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.basicsizePyObject_GetTypeData()Py_TPFLAGS_ITEMS_AT_ENDPy_RELATIVE_OFFSETPyObject_GetItemData() 中找到。

×

请参阅 PEP 1,了解如何提出更改。

摘要

添加对 受限 C API 的支持,以便通过允许代码仅处理特定(子)类的数据来扩展某些类型的不透明数据。

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

此 PEP 并不建议允许扩展非动态大小的可变大小对象(例如 tupleint),因为它们的内存布局不同,并且人们认为对此的需求不足。此 PEP 为将来在需要时通过相同机制这样做留出了空间。

动机

此 PEP 解决的关键问题是将 C 级状态附加到自定义类型,即元类(type 的子类)。

这在将其他类型系统(例如 C++、Java、Rust)公开为 Python 类的“包装器”中通常是需要的。这些通常需要将有关“包装”的非 Python 类的信息附加到 Python 类型对象。

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

扩展 type 是一个更普遍问题的一个实例:在保持松耦合的同时扩展类,也就是说,不依赖于超类使用的内存布局。(这有很多术语;请参阅基本原理以获取扩展 list 的具体示例。)

基本原理

扩展不透明类型

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

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

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

这在受限 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 在受限 API 中未公开,因此支持受限 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 为不透明的、跨版本不稳定的或根本不公开的基类,包括 typePyHeapTypeObject)或第三方扩展(例如 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 可以在未来适应其他布局(包括潜在的虚拟机管理布局)。

全局视角

为了更轻松地验证所有情况都已涵盖,这里有一个看起来很吓人的大图决策树。

注意

各个案例在孤立的情况下更容易解释(有关草稿文档,请参阅参考实现)。

  • 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:失败。(无法安全地更改/扩展项目大小。)

设置spec->itemsize < 0始终是错误。此 PEP 没有提出任何扩展tp->itemsize而不是仅仅继承它的机制。

相对成员偏移量

拼图的另一块是PyMemberDef.offset。使用子类特定struct(上面为SubListState)的扩展将获得一种指定“相对”偏移量(基于此struct的偏移量)而不是“绝对”偏移量(基于PyObject结构)的方法。

一种方法是在使用新 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;
}

此函数最初将不会添加到受限 API 中。

使用负spec->basicsize扩展具有正base->itemsize的类将失败,除非在基类或spec->flags中设置了Py_TPFLAGS_ITEMS_AT_END。(有关完整说明,请参阅扩展可变大小对象。)

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

相对成员偏移量

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

在初始实现中,新标志将是冗余的。它仅用于使偏移量的更改含义更加清晰,并帮助避免错误。如果未与负basicsize一起使用Py_RELATIVE_OFFSET,则将发生错误,并且在任何其他上下文中使用它也将发生错误(即直接或间接调用PyDescr_NewMemberPyMember_GetOnePyMember_SetOne)。

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

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

新 API 列表

建议使用以下新函数/值。

这些将添加到受限 API/稳定 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 扩展库的作者——来说应该足够了。

参考实现

参考实现位于 extend-opaque 分支,位于 encukou/cpython GitHub 仓库中。

可能的未来增强

对齐和性能

提议的实现可能会浪费一些空间,如果实例结构体需要的对齐方式小于 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

上次修改: 2024-08-20 10:29:32 GMT