你能在 C 扩展中安全地更改 Python 对象的类型吗?

Can you safely change a Python object's type in a C extension?

问题

假设我使用 C 扩展 API 实现了两个 Python 类型,并且这些类型是相同的(相同的数据 layouts/C struct),除了他们的名字和一些方法。假设所有方法都遵循数据布局,您能否在 C 函数中安全地将对象的类型从其中一种类型更改为另一种类型?

值得注意的是,从 Python 3.9 开始,似乎有一个函数 Py_SET_TYPE,但文档并不清楚 whether/when 这是安全的。我很想知道如何安全地使用此功能以及是否可以在 3.9 版之前安全地更改类型。

动机

我正在编写一个 Python C 扩展来实现持久性 Hash Array Mapped Trie (PHAMT); in case it's useful, the source code is here (as of writing, it is at this commit). A feature I would like to add is the ability to create a Transient Hash Array Mapped Trie (THAMT) from a PHAMT. THAMTs can be created from PHAMTs in O(1) time and can be mutated in-place efficiently. Critically, THAMTs have the exact same underlying C data-structure 作为 PHAMT — PHAMT 和 THAMT 之间唯一真正的区别是它们 [=37= 封装的一些方法] 类型。 这种通用结构允许人们在完成一组编辑后非常有效地将 THAMT 变回 PHAMT。 (这种模式通常会在对 PHAMT 执行大量更新时减少内存分配的数量)。

实现从 THAMT 到 PHAMT 的转换的一种非常方便的方法是简单地将 THAMT 对象的类型指针从 THAMT 类型更改为 PHAMT 类型。我相信我可以编写安全地应对此更改的代码,但我可以想象这样做可能会破坏 Python 垃圾收集器。

(要明确:动机只是关于问题如何产生的背景。我不是在寻求帮助来实现 Motivation 中描述的结构,我是寻找上述问题的答案。)

根据语言参考,第 3 章“数据模型”(参见 here):

An object’s type determines the operations that the object supports (e.g., “does it have a length?”) and also defines the possible values for objects of that type. The type() function returns an object’s type (which is an object itself). Like its identity, an object’s type is also unchangeable.[1]

在我看来,类型绝不能改变,改变它是非法的,因为它会破坏语言规范。然而,脚注指出

[1] It is possible in some cases to change an object’s type, under certain controlled conditions. It generally isn’t a good idea though, since it can lead to some very strange behaviour if it is handled incorrectly.

我不知道有什么方法可以从 python 自身内部更改对象的类型,因此“可能”可能确实指的是 CPython 函数。

据我所知,PyObject 在内部定义为

struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    PyTypeObject *ob_type;
};

所以引用计数应该仍然有效。另一方面,如果您将类型设置为不是 PyTypeObject 的类型,或者如果指针是 free()d,那么您将对解释器进行段错误,因此通常需要注意。

除此之外,我同意规范有点模棱两可,但“合法性”的问题可能没有很好的答案。它的长短在我看来是“除非你知道你在做什么,否则不要改变类型,如果你不是在攻击 CPython 本身,你就不知道你在做什么”。

编辑:Py_SET_TYPE 函数是根据 this 提交添加到 Python 3.9 中的。显然,人们过去只是使用

设置类型
Py_TYPE(obj) = typeobj;

因此,包含(据我所知,以前没有 announced)更类似于添加便利功能。

支持的方式

正式可以在 Python 中更改对象的类型,只要内存布局兼容...但这主要限于类型 不是在C中实现。有一些限制,可以做到

# Python attribute assignment, not C struct member assignment
obj.__class__ = some_new_class

更改对象的 class,其中一个限制是旧的和新的 classes 都必须是“堆类型”,所有 classes 都在Python 是,大多数 class 用 C 实现的不是。 (尽管 types.ModuleType 不是堆类型,但该类型的 types.ModuleType 和 subclass 也是特别允许的。请参阅 source 以了解确切的限制。)

如果你想从 C 创建一个堆类型,you can,但是界面与从 C 定义 Python 类型的正常方式有很大不同。此外,对于 __class__ 分配工作,你必须不设置 Py_TPFLAGS_IMMUTABLETYPE 标志,这意味着人们将能够 monkey-patch 你的 classes 以你可能不喜欢的方式(或者你可能看到这是一个好处)。

如果您想走那条路,我建议您查看 CPython 3.10 _functools module source code 作为示例。 (他们设置了 Py_TPFLAGS_IMMUTABLETYPE 标志,您必须确保不要这样做。)


不支持的方式

有一次尝试允许 __class__ 分配给 non-heap 类型,只要内存布局有效。它被放弃是因为它导致了一些 built-in 不可变类型的问题,解释器喜欢在这些类型中重用实例。例如,允许 (1).__class__ = SomethingElse 会导致很多问题。您可以在 big comment 中阅读更多 __class__ setter 的源代码。 (评论有点过时,特别是关于 Py_TPFLAGS_IMMUTABLETYPE 标志,它是在评论写完后添加的。)

据我所知,这是唯一的问题,我认为此后没有添加任何问题。解释器不会积极地重用你的 classes 的实例,所以只要 不做那样的事情,并且内存布局兼容,我想想改变你的对象的类型 应该 现在工作,即使是 non-heap 类型。然而,它并没有得到官方支持,所以即使我现在对这个工作的看法是正确的,也不能保证它会继续工作。

Py_SET_TYPE 只设置对象的类型指针。它不执行任何可能需要的引用计数修复。这是一个非常low-level的操作。如果旧 class 和新 class 都不是堆类型,则不需要额外的引用计数修复,但如果旧 class 是堆类型,则必须减少旧 class,如果新的 class 是堆类型,则必须增加新的 class.

如果您需要减少旧的 class,请务必在 更改对象的 class 并可能增加新的 class.