CPython 描述符对象 C API 详解:从 PyDescr_New 系列函数到描述符协议实现
本文基于 CPython 官方 C API 文档 Doc/c-api/descriptor.rst,系统讲解 CPython 描述符对象(Descriptor Objects)的完整 C API:五类描述符创建函数(PyDescr_NewGetSet、PyDescr_NewMember、PyDescr_NewMethod、PyDescr_NewWrapper、PyDescr_NewClassMethod)、对应的描述符类型对象、PyDescr_IsData 与 PyWrapper_New 工具函数,以及内置描述符类型(property、super、classmethod、staticmethod 的 C 层入口)。读完本文,你不仅能掌握这些 API 的签名、返回值约定与使用场景,还能对照 CPython 源码理解描述符如何进入类型字典、tp_descr_get/tp_descr_set 协议如何被调用,以及 PyDescr_Common 宏为何被标记为软弃用。
什么是描述符:对象类型字典中的“属性描述者”
按照 Doc/c-api/descriptor.rst 的定义:“Descriptors are objects that describe some attribute of an object. They are found in the dictionary of type objects.”——描述符是描述某个对象的部分属性的对象,它们存放在类型对象的字典(type.__dict__)中,而不是实例字典里。
这解释了 Python 层的日常现象:
>>> type(str.split) # 方法描述符
<class 'methoddescriptor'>
>>> type(str.__dict__) # 注意这是 mappingproxy,同文件也实现了它
<class 'mappingproxy'>
>>> str.split
<built-in method split of type object>
当你通过类访问 str.split 时,拿到的是描述符本身;通过实例访问时,CPython 的属性查找机制发现该描述符实现了 tp_descr_get 槽,就会调用它生成一个绑定对象(如 PyCFunction 或 wrapper 对象)。这一“数据描述符优先于实例字典、非数据描述符可被实例字典遮蔽”的规则,正是由本文介绍的 PyDescr_IsData 判断依据所支撑的。
CPython 在 C 层定义了五种主要描述符类型,每种类型都有对应的类型对象(PyTypeObject),并与 Python 层 types 模块中的类一一对应:
| C API 类型对象 | 对应 Python 类型 | 用途 |
|---|---|---|
PyGetSetDescr_Type |
types.GetSetDescriptorType |
由 PyGetSetDef 创建的 getter/setter 描述符 |
PyMemberDescr_Type |
types.MemberDescriptorType |
由 PyMemberDef 创建的 C 结构体字段描述符 |
PyMethodDescr_Type |
types.MethodDescriptorType |
由 PyMethodDef 创建的方法描述符 |
PyWrapperDescr_Type |
types.WrapperDescriptorType |
暴露类型槽(slot)实现的特殊方法,如 __repr__、__add__ |
PyClassMethodDescr_Type |
types.ClassMethodDescriptorType |
由 METH_CLASS 方法创建,绑定到类而非实例 |
这些类型对象均在 Include/descrobject.h 中通过 PyAPI_DATA(PyTypeObject) 声明(第 19–26 行),实现在 Objects/descrobject.c。
描述符创建函数:API 全览
文档共给出五个 PyDescr_New* 创建函数,签名在 Include/descrobject.h(第 27–33 行)中声明,全部实现于 Objects/descrobject.c。它们的统一约定是:成功时返回描述符的强引用(strong reference),失败时返回 NULL 并设置异常。
PyDescr_NewGetSet:C 级 getter/setter 描述符
PyObject* PyDescr_NewGetSet(PyTypeObject *type, struct PyGetSetDef *getset);
为扩展类型 type 从 PyGetSetDef 结构 getset 创建一个 get-set 描述符。get-set 描述符暴露的属性不是直接存储在实例中,而是由 C 级 getter 和 setter 函数实现——这与 PyTypeObject.tp_getset 数组条目自动创建的描述符是同一类,在 Python 中呈现为 types.GetSetDescriptorType 对象。
PyGetSetDef 结构定义见 Include/descrobject.h(第 11–17 行):
struct PyGetSetDef {
const char *name; // 属性名
getter get; // PyObject *(*)(PyObject *obj, void *closure)
setter set; // int (*)(PyObject *obj, PyObject *value, void *closure)
const char *doc; // 文档字符串
void *closure; // 传给 get/set 的附加上下文
};
一个典型的 C 扩展用法是:在类型定义的 tp_getset 中列出 PyGetSetDef 数组,CPython 会在类型初始化时自动为每条记录创建描述符(见下文“描述符如何进入类型字典”)。而 PyDescr_NewGetSet 允许你在运行时手动创建,例如把某个已有类型的计算属性“搬运”到新类型中。
从源码看,PyDescr_NewGetSet 的实现极其精简(Objects/descrobject.c 第 1010–1020 行):调用统一的 descr_new 构造公共部分,再把 d_getset 指针指向传入的 PyGetSetDef 记录。注意描述符只保存指针而非拷贝,因此 PyGetSetDef 数组必须比描述符生命周期更长(扩展模块的静态数组天然满足)。
PyDescr_NewMember:C 结构体字段描述符
PyObject* PyDescr_NewMember(PyTypeObject *type, struct PyMemberDef *member);
为扩展类型 type 从 PyMemberDef 结构 member 创建成员描述符。成员描述符把类型 C 结构体中的字段直接暴露为 Python 属性——这是 tp_members 条目创建的那类描述符,在 Python 中呈现为 types.MemberDescriptorType。
PyMemberDef 与相关常量定义在 Include/descrobject.h(第 41–86 行),要点包括:
type字段取值Py_T_SHORT、Py_T_INT、Py_T_LONG、Py_T_DOUBLE、Py_T_STRING、Py_T_CHAR、Py_T_OBJECT_EX、Py_T_PYSSIZET等,决定如何把结构体内存解释为 Python 对象;flags支持Py_READONLY(只读,无法通过 Python 赋值)、Py_AUDIT_READ(读取时触发object.__getattr__审计事件,3.10 引入)与Py_RELATIVE_OFFSET(内部相对偏移,不能用于本 API);- 数组必须以
name == NULL的条目结尾。
实现上(Objects/descrobject.c 第 992–1008 行),PyDescr_NewMember 会显式拒绝 Py_RELATIVE_OFFSET 标志并抛出 SystemError:
if (member->flags & Py_RELATIVE_OFFSET) {
PyErr_SetString(PyExc_SystemError,
"PyDescr_NewMember used with Py_RELATIVE_OFFSET");
return NULL;
}
这与 tp_members 的自动处理不同:相对偏移只能由类型内部的成员填充逻辑解析,手动创建的描述符必须以绝对偏移为准。
PyDescr_NewMethod 与 PyDescr_NewClassMethod:方法描述符
PyObject* PyDescr_NewMethod(PyTypeObject *type, struct PyMethodDef *meth);
PyObject* PyDescr_NewClassMethod(PyTypeObject *type, PyMethodDef *method);
PyDescr_NewMethod为 type 从PyMethodDef创建方法描述符,把 C 函数暴露为类型上的方法。这是tp_methods条目创建的那类描述符,在 Python 中呈现为types.MethodDescriptorType。PyDescr_NewClassMethod创建类方法描述符,对应tp_methods中带有METH_CLASS标志的条目。类方法描述符在访问时绑定的是类而不是实例,呈现为types.ClassMethodDescriptorType。
PyDescr_NewMethod 的实现(Objects/descrobject.c 第 934–978 行)有一个值得注意的细节:它根据 ml_flags 中的调用约定(METH_VARARGS、METH_FASTCALL、METH_NOARGS、METH_O、METH_METHOD 等组合)预计算并缓存一个 vectorcall 函数:
switch (method->ml_flags & (METH_VARARGS | METH_FASTCALL | METH_NOARGS |
METH_O | METH_KEYWORDS | METH_METHOD))
{
case METH_VARARGS:
vectorcall = method_vectorcall_VARARGS;
break;
...
default:
PyErr_Format(PyExc_SystemError,
"%s() method: bad call flags", method->ml_name);
return NULL;
}
也就是说,创建阶段就确定了绑定的 PyCFunction 之后的向量调用路径;flags 组合非法时直接以 SystemError 失败。而 PyDescr_NewClassMethod(第 980–990 行)不缓存 vectorcall,仅记录 d_method 指针。
PyDescr_NewWrapper 与 wrapperbase:类型槽的特殊方法描述符
PyObject* PyDescr_NewWrapper(PyTypeObject *type, struct wrapperbase *base, void *wrapped);
为 type 从 wrapperbase 结构 base 与被包装的槽函数指针 wrapped 创建 wrapper 描述符。wrapper 描述符暴露由类型槽实现的特殊方法——正是 CPython 为 __repr__、__add__ 这类槽式特殊方法创建的那类描述符,在 Python 中呈现为 types.WrapperDescriptorType。
wrapperbase 结构定义在 Include/cpython/descrobject.h(第 11–19 行,属于内部头文件,不暴露给受限 API):
struct wrapperbase {
const char *name; // Python 可见名称,如 "__repr__"
int offset; // 在类型中的槽偏移
void *function;
wrapperfunc wrapper; // 把槽适配到 Python 调用约定的包装函数
const char *doc;
int flags; // PyWrapperFlag_KEYWORDS(1) 表示 wrapper 接收关键字参数
PyObject *name_strobj;
};
PyDescr_NewWrapper 的实现(Objects/descrobject.c 第 1022–1034 行)同样保存 d_base 与 d_wrapped 两个指针。该函数在 Py_LIMITED_API 之外的 C API 中可用,但 wrapperbase 结构本身来自内部头,实际使用场景主要是 CPython 内部或深度嵌入定制。
PyDescr_IsData:区分数据描述符与非数据描述符
int PyDescr_IsData(PyObject *descr);
返回非零当且仅当 descr 描述的是一个数据属性(data attribute),否则(描述方法)返回 0。文档明确强调:descr 必须是描述符对象,不做错误检查。
实现只有一行(Objects/descrobject.c 第 1036–1040 行):
int
PyDescr_IsData(PyObject *ob)
{
return Py_TYPE(ob)->tp_descr_set != NULL;
}
从源码结构看,判定标准就是描述符类型是否实现了 tp_descr_set 槽:能“写”的描述符(如成员描述符、get-set 描述符、property)是数据描述符,只有 tp_descr_get 的方法描述符则是非数据描述符。这直接决定了属性查找的优先级——数据描述符会遮蔽实例字典中的同名键,非数据描述符则不会。
PyWrapper_New:绑定 wrapper 对象
PyObject* PyWrapper_New(PyObject *d, PyObject *self);
由 wrapper 描述符 d 与实例 self 创建新的绑定 wrapper 对象。这是 PyDescr_NewWrapper 创建的描述符的绑定形式:当通过实例访问一个 slot wrapper 时,CPython 就会创建这类对象,它在 Python 中呈现为 types.MethodWrapperType(例如 (1, 2).__add__)。
实现(Objects/descrobject.c 第 1509–1527 行)包含两条 assert 前置条件:d 必须是 PyWrapperDescr_Type 类型的描述符,且 self 的类必须是描述符所属类型的子类。函数为 wrapperobject 分配 GC 追踪对象并强引用保存 descr 与 self 两个字段。
描述符的内部结构:PyDescr_COMMON 与软弃用
所有描述符共享一个公共前缀结构 PyDescrObject,定义在 Include/cpython/descrobject.h(第 26–36 行):
typedef struct {
PyObject_HEAD
PyTypeObject *d_type; // 描述符所属的类型
PyObject *d_name; // 属性名(interned 字符串)
PyObject *d_qualname; // 限定名
} PyDescrObject;
#define PyDescr_COMMON PyDescrObject d_common
#define PyDescr_TYPE(x) (((PyDescrObject *)(x))->d_type)
#define PyDescr_NAME(x) (((PyDescrObject *)(x))->d_name)
各具体描述符类型只是在此基础上追加自己的指针字段:
typedef struct { PyDescr_COMMON; PyMethodDef *d_method; vectorcallfunc vectorcall; } PyMethodDescrObject;
typedef struct { PyDescr_COMMON; PyMemberDef *d_member; } PyMemberDescrObject;
typedef struct { PyDescr_COMMON; PyGetSetDef *d_getset; } PyGetSetDescrObject;
typedef struct { PyDescr_COMMON; struct wrapperbase *d_base; void *d_wrapped; } PyWrapperDescrObject;
Doc/c-api/descriptor.rst 对 PyDescr_COMMON 宏给出了重要告诫:
This was included in Python's C API by mistake; do not use it in extensions.
该宏被错误地纳入了 Python C API,文档标注其于 3.15 起软弃用(soft-deprecated)。如果你在编写自定义描述符类型,文档建议的正确做法是:定义一个自己的类,实现描述符协议,即设置 PyTypeObject 的 tp_descr_get 与 tp_descr_set 两个槽,而不是套用 PyDescr_COMMON 布局。
描述符如何进入类型字典:CPython 的内部流程
CPython 在类型初始化时遍历 tp_methods、tp_members、tp_getset 数组,为每条记录创建描述符并写入类型字典。这一过程在 Objects/typeobject.c 中:
type_add_members(第 8576–8597 行):遍历type->tp_members,对每条记录调用PyDescr_NewMember(type, memb),再用PyDict_SetDefaultRef以PyDescr_NAME(descr)为名存入类型字典——SetDefault语义意味着同名的 Python 层覆盖不会被 C 层记录冲掉;type_add_getset(第 8600–8622 行):同样的模式调用PyDescr_NewGetSet(type, gsp);type_add_method(约第 8480–8549 行):按ml_flags分派——METH_CLASS走PyDescr_NewClassMethod,METH_STATIC走PyStaticMethod_New(注意它不是PyDescrObject派生类型),其余走PyDescr_NewMethod。
所有创建路径最终汇聚到统一的工厂函数 descr_new(Objects/descrobject.c 第 914–932 行):
static PyDescrObject *
descr_new(PyTypeObject *descrtype, PyTypeObject *type, const char *name)
{
PyDescrObject *descr;
descr = (PyDescrObject *)PyType_GenericAlloc(descrtype, 0);
if (descr != NULL) {
_PyObject_SetDeferredRefcount((PyObject *)descr);
descr->d_type = (PyTypeObject*)Py_XNewRef(type); // 强引用所属类型
descr->d_name = PyUnicode_InternFromString(name); // 名称被 intern
...
}
return descr;
}
两个值得注意的实现事实:其一,d_name 通过 PyUnicode_InternFromString 国际化,保证同一属性名全进程共享一个字符串对象,这也是 PyDescr_NAME(descr) 能安全用作字典键的原因;其二,descr->d_type 持有所属类型的强引用,描述符析构时由 descr_dealloc(第 22–31 行)释放。
描述符协议:tp_descr_get / tp_descr_set 的调用链
描述符的“魔法”发生在属性访问时。以成员描述符为例,其 tp_descr_get 实现 member_get(Objects/descrobject.c 第 162–181 行)展示了完整协议流程:
obj == NULL(通过类访问):返回描述符自身的新引用(Py_NewRef(descr))——这就是Type.attr返回描述符本身的原因;descr_check:校验实例是否属于d_type,不匹配则抛出形如descriptor 'x' for 'Y' objects doesn't apply to a 'Z' object的TypeError;- 若
PyMemberDef.flags带Py_AUDIT_READ,先触发PySys_Audit("object.__getattr__", ...)审计; - 调用
PyMember_GetOne((char *)obj, descr->d_member)按类型码从结构体内存读出 Python 对象。
get-set 描述符的 getset_get(第 183–201 行)与 getset_set(第 242–259 行)遵循同样骨架:类访问返回自身;实例访问时先做 descr_check(写入路径对应 descr_setcheck),然后调用 d_getset->get/d_getset->set,并传入 closure;若 getter/setter 为 NULL 则抛出 “not readable”/“not writable” 的 AttributeError。源码中这些调用经由 descr_get_trampoline_call/descr_set_trampoline_call 转发,以支持 WebAssembly 等平台的调用约定适配。
方法描述符的 method_get(第 137–160 行)则演示了“绑定”的诞生:实例访问时根据 METH_METHOD 标志选择创建 PyCMethod(支持向量调用与 class 传递)或经典的 PyCFunction_NewEx(descr->d_method, obj, NULL)。这就是为什么 instance.split 是 bound method,而 str.split 是描述符。
内置描述符类型:property、super、classmethod、staticmethod
Doc/c-api/descriptor.rst 的 “Built-in descriptors” 一节列出了 C 层可直接使用的内置描述符类型对象:
| C API 符号 | Python 层对应 | 说明 |
|---|---|---|
PyProperty_Type |
property |
property 对象的类型对象,两个符号是同一个对象 |
PySuper_Type |
super |
super 对象的类型对象,同为同一对象 |
PyClassMethod_Type |
classmethod |
classmethod 对象的类型 |
PyClassMethodDescr_Type |
types.ClassMethodDescriptorType |
C 层类方法描述符类型,对应 C 扩展类型中定义 classmethod 时创建的描述符 |
PyStaticMethod_Type |
staticmethod |
staticmethod 对象的类型 |
配套的两个构造函数:
PyObject *PyClassMethod_New(PyObject *callable);
PyObject *PyStaticMethod_New(PyObject *callable);
PyClassMethod_New 创建包装 callable 的新 classmethod 对象;PyStaticMethod_New 创建包装 callable 的新 staticmethod 对象。两者都要求 callable 必须是可调用对象且不得为 NULL;成功时返回新对象的强引用,失败返回 NULL 并设置异常。
值得指出的是 property 本身就是描述符协议的 C 级范本。Objects/descrobject.c 第 1530 行起(注释中的等价 Python 代码从第 1534 行开始)给出了 propertyobject 的完整语义:__get__ 在 inst is None 时返回自身(即类访问得到 property 对象本身),getter 缺失时抛 AttributeError("property has no getter");getter()/setter()/deleter() 辅助方法通过 property_copy 返回替换了相应回调的新 property 副本(第 1591–1608 行)。property 的 fget/fset/fdel 属性正是用 PyMemberDef 以 Py_READONLY 标志暴露的(第 1582–1588 行)——一个描述符类型内部又使用成员描述符的典型嵌套。
另外,Objects/descrobject.c 中还有 PyDictProxy_Type(第 24 行声明的 mappingproxy,即 Type.__dict__ 的只读代理类型),文件内第 1042 行起的注释也承认它“没有理由放在这个文件里,只是新增文件有点麻烦”——阅读该文件时可以把它视为随附的只读映射代理实现。
实践:在 C 扩展中定义描述符属性的完整示例
下面是一个最小 C 扩展片段,展示如何用 tp_members 与 tp_getset 数组声明描述符(无需手动调用 PyDescr_New* 函数,类型初始化会自动完成,见上文 Objects/typeobject.c 的 type_add_members/type_add_getset):
typedef struct {
PyObject_HEAD
int count;
} Counter;
static PyObject *
counter_get_total(PyObject *obj, void *closure)
{
Counter *self = (Counter *)obj;
return PyLong_FromLong(self->count * 2); // 计算属性,不落存储
}
static int
counter_set_value(PyObject *obj, PyObject *value, void *closure)
{
Counter *self = (Counter *)obj;
if (PyFloat_Check(value)) {
PyErr_SetString(PyExc_TypeError, "value must be int");
return -1;
}
self->count = (int)PyLong_AsLong(value);
return self->count == -1 && PyErr_Occurred() ? -1 : 0;
}
static PyMemberDef counter_members[] = {
{"count", Py_T_INT, offsetof(Counter, count), Py_READONLY,
"Access the raw counter value (read-only member descriptor)."},
{NULL}
};
static PyGetSetDef counter_getsets[] = {
{"total", counter_get_total, counter_set_value,
"Computed attribute: twice the count.", NULL},
{NULL}
};
行为验证:counter.count 是 types.MemberDescriptorType(只读,实例字典无法遮蔽),counter.total 是 types.GetSetDescriptorType,读取调用 counter_get_total、写入调用 counter_set_value。若需要自定义的完全自定义描述符(例如只读、值来自全局状态的复杂属性),则应遵循文档建议,实现 tp_descr_get/tp_descr_set 槽,而不要使用已弃用的 PyDescr_COMMON 宏。
测试与验证路径
CPython 用 Lib/test/test_descr.py 覆盖描述符行为,其中包含对 types.MemberDescriptorType 的断言(如第 1471、1486 行)以及 TestGenericDescriptors 等测试类(第 6269 行),验证描述符协议在各种继承与代理场景下的表现。若你修改或依赖描述符行为,可直接运行该测试模块回归验证:
python -m test test_descr -v
小结
- 五个创建函数、五种类型对象:
PyDescr_NewGetSet/PyMember/Method/ClassMethod/Wrapper分别对应PyGetSetDescr_Type/PyMemberDescr_Type/PyMethodDescr_Type/PyClassMethodDescr_Type/PyWrapperDescr_Type,成功返回强引用、失败返回NULL并置错; - 两个工具 API:
PyDescr_IsData通过tp_descr_set != NULL判定数据描述符(无错误检查);PyWrapper_New生成 slot wrapper 的绑定形式(types.MethodWrapperType); - 内置描述符:
PyProperty_Type、PySuper_Type、PyClassMethod_Type、PyStaticMethod_Type与 Python 层的property/super/classmethod/staticmethod是同一对象,另可用PyClassMethod_New/PyStaticMethod_New在 C 层构造后两者; - 源码要点:统一工厂
descr_new负责类型强引用与名称 intern;type_add_members/type_add_getset/type_add_method是描述符进入类型字典的入口;属性访问经tp_descr_get/tp_descr_set协议分派到member_get、getset_get、method_get等实现; - 注意事项:
PyDescr_COMMON宏属于历史误入 C API 的部分,3.15 起软弃用,自定义描述符请直接实现tp_descr_get/tp_descr_set协议;PyDescr_NewMember拒绝Py_RELATIVE_OFFSET;PyDescr_NewMethod会在创建期校验调用约定 flags 并缓存 vectorcall 路径。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00