Skip to main content

Chapter 66: descrobject.c and funcobject.c

Objects/descrobject.cObjects/funcobject.c 时,主线可以固定为一个问题:一次 obj.name(...) 最终怎样从属性访问转成函数调用。读者读完本章后,应能定位 descriptor 对象在 class dict 中的位置,区分 C 字段描述符、Python 函数对象、bound method、property 和 vectorcall 之间的责任,并能解释一次调用的绑定对象、参数数组、错误返回和实例字典覆盖边界。

本章以一个短代码现象贯穿全章:profile.score 触发 propertyprofile.scale(10) 触发函数 descriptor 绑定,Profile.scale(profile, 10) 走未绑定函数调用。三个表达式看起来都只是点号访问,CPython 内部却会分别经过 descrobject.cfuncobject.c、method object 和 call protocol 的不同分支。

class Profile:
def __init__(self, raw):
self.raw = raw

@property
def score(self):
return self.raw * 2

def scale(self, factor):
return self.raw * factor

profile = Profile(7)
value = profile.score
bound = profile.scale
result = profile.scale(10)
manual = Profile.scale(profile, 10)

源码阅读范围以 CPython 3.13/3.14 形态为主。可对照官方源码中的 Objects/descrobject.cObjects/funcobject.c、Python 3.14 的 Call ProtocolDescriptor Guide。字段名、内部 helper 和优化细节会随版本调整,本章只把它们整理成稳定的源码阅读模型。

66.1 descriptor implementation

Descriptor 是放在类命名空间里的对象,它通过 __get____set____delete__ 接管属性访问。CPython 的 C 层把这些入口落实为 type object 上的 tp_descr_gettp_descr_set。因此,读 descrobject.c 时应先找对象种类,再看该种对象有没有 get slot 和 set slot,最后再把它放回 attribute lookup 顺序中判断优先级。

在贯穿示例中,Profile.__dict__['score'] 是一个 property 对象,Profile.__dict__['scale'] 是一个 function object。二者都位于 class dict,但读取 profile.scoreprofile.scale 的结果不同。原因在于二者的 descriptor 行为不同:property 是 data descriptor,读取时调用 getter,写入时进入 setter 或抛出 AttributeError;function object 是 non-data descriptor,实例读取时产生 bound method,实例字典中同名条目可以覆盖它。

descrobject.c 的核心价值是把多种 C 层描述符统一到同一套属性协议中。常见类型可以按输入来源区分:member_descriptorPyMemberDef 描述的 C struct 字段暴露成属性;getset_descriptorPyGetSetDef 描述的 getter/setter 回调暴露成属性;wrapper_descriptor 把类型 slot 包装成 Python 可见的特殊方法;method_descriptor 把内建类型的 C 函数作为方法暴露出来;property 把 Python 层的 fget、fset、fdel 组合成 data descriptor。

下面的简化 C 形状只表达阅读关系,具体字段和 helper 以目标 CPython 版本为准。

/* 简化示意:descriptor 对象保存所属类型、名字和具体访问策略。 */
typedef struct {
PyObject_HEAD
PyTypeObject *d_type;
PyObject *d_name;
PyObject *d_qualname;
} PyDescrObject;

/* 简化示意:getset descriptor 把 attribute read/write 转到 C 回调。 */
static PyObject *getset_get(PyObject *self, PyObject *obj, PyObject *type) {
PyGetSetDescrObject *descr = (PyGetSetDescrObject *)self;
if (obj == NULL) {
return Py_NewRef(descr);
}
return descr->d_getset->get(obj, descr->d_getset->closure);
}

这个结构说明 descriptor 的第一层判断并非“属性名属于实例还是类”,而是“类层级查找到的对象是否声明了 descriptor 行为”。object.__getattribute__ 会先沿 MRO 查类属性,如果命中 data descriptor,就优先调用它;之后才检查实例字典;随后处理 non-data descriptor 和普通 class attribute。这个顺序解释了 profile.score 读取不到 profile.__dict__['score'] 的典型现象,也解释了实例上给 scale 赋值后可以覆盖方法名的现象。

profile.__dict__['score'] = 999
profile.__dict__['scale'] = lambda factor: -1

score_value = profile.score # 仍由 property getter 计算
scale_value = profile.scale(10) # 命中实例字典中的 lambda

这段代码的读法是:score 在类上对应 data descriptor,所以实例字典里的同名值只成为普通数据;scale 在类上对应 non-data descriptor,所以实例字典同名项取得优先权。源码阅读时,descrobject.c 负责解释“descriptor 对象能做什么”,typeobject.cobject.c 负责解释“属性查找何时调用它”。本章关注前者,但判断结果必须放回完整查找顺序中。

Descriptor 实现还有一个容易忽略的边界:C 描述符通常会检查接收对象是否属于声明类型或其子类型。内建类型上的 list.appenddict.get 这类方法 descriptor 被单独取出后再调用时,CPython 会检查第一个实参是否匹配预期类型。这个检查把“函数可调用”与“方法适用于哪个对象”分开,错误时按照 C API 约定设置异常并返回 NULL

66.2 function object

Function object 是 Python 函数定义执行后创建的运行时对象,它把 code object、globals、defaults、kwdefaults、closure、annotations、函数名、限定名、文档、实例字典和调用入口组织在一起。读 funcobject.c 时,重点应放在“函数对象保存可复用的静态调用材料”,以及“一次调用产生的 frame 状态另行创建”。

在贯穿示例中,执行 class body 时,scale 的函数体已经编译成 code object。class namespace 中的 scale 绑定到 function object。这个对象本身保存全局命名空间引用、默认参数、闭包信息和 vectorcall 入口;它并没有保存某一次调用中的 selffactor 或局部变量值。self 进入函数要等到 attribute access 绑定或显式传参,局部变量进入 frame 要等到调用开始。

可以用下面的 Python 观察点把源码字段映射到用户可见属性。这个示例的目的不是枚举所有属性,而是固定 function object 与调用现场之间的边界。

func = Profile.__dict__['scale']

observed = {
"name": func.__name__,
"qualname": func.__qualname__,
"code": func.__code__,
"defaults": func.__defaults__,
"closure": func.__closure__,
"globals": func.__globals__,
}

func.__code__ 代表已编译的指令、常量、局部变量布局和参数布局;func.__globals__ 指向定义该函数时所在模块的 globals;func.__defaults__func.__kwdefaults__ 保存调用时可补齐的默认值;func.__closure__ 保存闭包 cell。调用 func(profile, 10) 时,解释器根据这些材料建立 frame,并把实参映射到 fast locals。调用完成后,frame 可以释放;函数对象仍然保留在类字典中,供下一次访问和调用复用。

funcobject.c 还给 function object 提供 descriptor 行为。Python 函数作为类属性读取时会参与 method binding,这由函数类型的 tp_descr_get 完成。实例为 NULL 时,读取类属性返回函数对象自身;实例存在时,返回 bound method。这个行为让 Profile.scaleprofile.scale 形成不同结果,也解释了 self 的来源。

from_class = Profile.scale
from_instance = profile.scale

same_function = from_instance.__func__ is from_class
same_self = from_instance.__self__ is profile

这里的 from_class 仍然是 function object。from_instance 是 method object,内部保存原函数和绑定实例。函数对象本身承担“可执行代码与定义环境”的角色,method object 承担“把某个接收者固定进调用”的角色。把这两个对象分开,能减少阅读 funcobject.c 时的混乱:funcobject.c 说明函数如何存在和发起调用,method object 说明实例如何被并入调用参数。

函数对象还承担 introspection 和动态修改边界。用户可以修改函数对象的 __defaults____kwdefaults____dict____annotations__ 等属性;这些修改会影响后续调用或工具观察。源码阅读时要区分“函数对象字段被修改”和“code object 被修改”。普通 Python 代码通常替换函数对象或调整函数元数据;code object 代表编译结果,直接替换 __code__ 需要满足参数布局、闭包和 flags 等约束。

66.3 method object

Method object 保存一个 callable 与一个已绑定接收者之间的关系。对普通实例方法来说,它公开为 __func____self____func__ 指向原 function object,__self__ 指向实例;对 classmethod 来说,__self__ 指向类对象。读源码时,可以把 method object 看成调用前的轻量包装层,它本身不重新编译函数,也不复制函数默认参数。

贯穿示例中,bound = profile.scale 这一步会进行属性读取。类字典命中 function object,函数 descriptor 收到 obj=profiletype=Profile,于是创建 method object。随后 bound(10) 的调用语义等价于把 profile 作为第一个参数传给原函数,再传入 10

bound = profile.scale

assert bound.__func__ is Profile.scale
assert bound.__self__ is profile
assert bound(10) == Profile.scale(profile, 10)

这段代码对应的对象关系可以画成一个最小路径。图只覆盖普通 Python 函数在实例上读取并调用的情况;内建方法 descriptor、staticmethodclassmethodproperty 会进入相邻分支。

图中关键点是绑定发生在调用之前的属性访问阶段。profile.scale 单独求值已经生成 method object;profile.scale(10) 只是把这个属性访问结果继续调用。CPython 的字节码和解释器可能使用 LOAD_METHODCALL 等优化路径减少临时 method object 分配,但语义上仍然等价于“读取属性得到可调用对象,再调用它”。阅读源码时应把语义模型和优化路径分开:语义模型解释结果,优化路径解释成本。

Method object 也解释了手动调用和绑定调用的差异。Profile.scale(profile, 10) 从类上读取函数,由于实例参数为空,descriptor 返回原 function object。调用时用户显式传入 profileprofile.scale(10) 从实例上读取函数,descriptor 创建 method object,调用时 method object 把 profile 自动放到参数前面。二者到达函数体后看到的 self 相同,进入调用入口之前的对象路径不同。

内建类型的方法还会出现另一种对象:method descriptor。比如 list.append 属于 C 层 method descriptor,位于 descrobject.c 相关实现中。它并非 Python function object,也没有 __code__。从实例读取 [].append 时,CPython 返回绑定到具体 list 对象的内建方法对象;直接调用 list.append([], 1) 时,descriptor 检查第一个实参确实是 list 或兼容子类型。这个差异让源码阅读有一个稳定顺序:先判断类字典里的对象是 Python function、C method descriptor、property 还是其它 descriptor,再判断绑定和调用。

66.4 vectorcall

Vectorcall 是 CPython 用于降低调用开销的一种调用约定。它把位置参数和关键字参数值放入连续的 PyObject * 数组,用 nargsf 保存位置参数数量和少量 flags,用 kwnames 保存关键字参数名元组。与传统 tp_call 相比,vectorcall 的核心收益来自减少临时 tuple 和 dict 构造,尤其适合函数、method、内建函数和高频调用路径。

官方 C API 文档把 CPython 的调用协议分成 tp_call 和 vectorcall。tp_call 接收 args tuple 和 kwargs dict;vectorcall 接收数组、参数计数和关键字名元组。读 funcobject.c 时,Python function object 的调用入口通常会连接到 vectorcall;读 descrobject.c 时,内建 method descriptor 也会根据 C 函数签名选择相应的 vectorcall helper。二者的目标相同:保持 Python 调用语义,同时降低参数搬运成本。

下面的 C 形状只表达调用约定,省略了错误处理、递归检查和版本差异。

typedef PyObject *(*vectorcallfunc)(
PyObject *callable,
PyObject *const *args,
size_t nargsf,
PyObject *kwnames
);

这个签名中的 args 不是 Python tuple,而是 C 数组。位置参数放在前面,关键字参数的值接在后面,关键字名字放在 kwnames tuple 中。nargsf 需要通过 PyVectorcall_NARGS() 取得实际位置参数数量。这个设计让调用方在已经持有连续参数栈时,可以把参数直接交给 callee。解释器执行 CALL 时,value stack 上的参数天然接近这种布局,所以 vectorcall 与解释器栈模型匹配。

profile.scale(10) 中,如果按语义模型展开,会有一个 bound method,再把 10 传给它。优化路径可以让解释器在属性读取和调用连用时减少 bound method 临时对象,并把 profile10 作为连续参数交给函数调用入口。也就是说,vectorcall 优化的对象是参数传递形态和临时对象数量,函数体内部的 self 绑定、默认参数补齐、关键字检查、异常语义仍要保持一致。

Vectorcall 的版本边界需要单独记住。PEP 590 引入了这个调用约定,官方 C API 页面会标出各个 API 的加入版本和 Stable ABI 状态。扩展类型如果声明支持 vectorcall,仍需保持与 tp_call 一致的语义;某些路径仍可能通过 tp_call 调用对象。源码阅读时可以采用一个稳妥判断:vectorcall 是性能入口,tp_call 是通用调用契约,二者返回值、异常和参数语义必须对齐。

66.5 callable dispatch

Callable dispatch 是从“一个对象被调用”到“具体执行哪段代码”的分发过程。它会经过对象类型、tp_call、vectorcall slot、descriptor binding、argument parsing 和错误返回约定。阅读 descrobject.cfuncobject.c 时,最稳的顺序是:先确认调用对象由哪次属性访问产生,再确认该对象类型的调用入口,最后检查参数如何被整理成函数体能接收的局部变量。

贯穿示例可以分成三条路径。profile.score 停在属性读取阶段,调用 property getter 后返回普通值;profile.scale(10) 先通过 function descriptor 形成绑定调用,再进入 function vectorcall;Profile.scale(profile, 10) 从类上取得原 function object,随后用显式参数调用它。三条路径都从点号访问开始,只有后两条进入 callable dispatch。

这张图的边界是 class attribute 已经被找到。它没有展开 MRO 查找、inline cache 和字节码 specialization;这些属于前后章节的内容。本章只关心 class dict 中的对象怎样把“属性”变成“值、bound method 或 C 方法调用入口”。

C API 的错误返回约定是阅读 call path 时的硬边界。返回 PyObject * 的调用函数成功时返回新引用或约定引用,失败时设置异常并返回 NULL;返回 int 的 setter 或初始化路径通常用 0 表示成功,用 -1 表示失败并设置异常。descrobject.c 中的 getter、setter、method descriptor 调用和 funcobject.c 中的参数检查都遵守这个模式。读源码时看到 return NULL,要立刻追踪前面是否已经设置 TypeErrorAttributeError 或其它异常。

Argument parsing 是 callable dispatch 的另一条主线。Python function 的参数布局来自 code object:位置参数、仅限位置参数、关键字参数、默认值、变长参数和闭包 cell 都已经在编译阶段确定。调用入口根据实参数组和 kwnames 把值映射到 frame 的局部槽位。内建 C 函数则根据 METH_OMETH_NOARGSMETH_FASTCALLMETH_KEYWORDS 等标志选择不同包装层。descrobject.c 中的 method descriptor vectorcall helper 正是在这些调用约定之间做分派。

对同类问题,检查顺序可以固定为五步。第一步,看表达式是否包含属性访问;第二步,查类字典命中的对象类型;第三步,判断该对象是 data descriptor、non-data descriptor 还是普通属性;第四步,看属性访问结果是否 callable,以及它的调用入口是 vectorcall 还是 tp_call;第五步,检查参数绑定、接收者类型和异常返回。这个顺序能解释 property、普通方法、内建方法、手动取函数调用和实例同名覆盖的大部分现象。

66.6 property implementation

propertydescrobject.c 中最适合连接 Python 语义和 C 实现的对象。它把 fgetfsetfdeldoc 和属性名等状态组合在一个 descriptor object 中,并通过 get/set/delete 入口把属性访问转换为函数调用。它作为 data descriptor 参与属性查找,所以它能稳定接管实例同名属性。

贯穿示例中,profile.score 的 class dict 命中 property 对象。实例读取时,property 的 get 入口收到 obj=profile,随后调用 fget,也就是 Profile.score 对应的函数对象或底层 callable。fget 返回 profile.raw * 2,属性读取表达式最终得到普通整数。用户写的是 attribute access,运行时执行的是 descriptor 调用。

class Profile:
def __init__(self, raw):
self.raw = raw

@property
def score(self):
return self.raw * 2

profile = Profile(7)
profile.__dict__['score'] = 100

assert profile.score == 14
assert Profile.score.__get__(profile, Profile) == 14

这段代码说明 property 的优先级来自 data descriptor 身份。Profile.score 通过 class access 取得 property 对象自身;profile.score 通过 instance access 调用 fget。实例字典里存在同名键时,data descriptor 仍先执行。若 property 没有 setter,profile.score = 100 会进入 property 的 set 入口并抛出 AttributeError,属性值不会落入实例字典成为覆盖项。

property 的 getter、setter、deleter 装饰器通常会返回新的 property 对象,保留已有函数并替换其中一个访问函数。这一设计让链式写法保持不可变配置风格:@score.setter 生成一个包含原 fget 和新 fset 的 property,再把类命名空间中的 score 重新绑定到这个新对象。class body 执行结束后,score 在 class dict 中就是最终 property 对象。

class Account:
def __init__(self, balance):
self._balance = balance

@property
def balance(self):
return self._balance

@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("negative balance")
self._balance = value

这个例子对应的源码判断是:balance 的读取进入 fget,写入进入 fset,异常由 fset 负责表达业务约束。property 自身不保存实际余额;余额仍然存放在实例的 _balance 属性中。也就是说,property 负责访问控制和调用转发,实例对象负责业务状态存储。把这两层分开,能防止把 descriptor object 误读成每个实例的字段容器。

阅读 property 实现时,还要注意 class access 边界。Account.balance 返回 property 对象,适合 introspection、继承和装饰器继续组合;account.balance 返回 fget 的结果,适合业务读取。这个分支由 obj == NULL 或实例对象是否存在决定。许多 descriptor 都采用类似模式:从类上读取返回 descriptor 本身或未绑定对象,从实例上读取返回绑定结果或计算值。

最小自检任务

阅读下面代码,判断每一行涉及哪类 descriptor 或 callable dispatch,并说明 Record.labelrecord.labelRecord.computerecord.computerecord.compute(3) 分别得到什么类型的结果或执行路径。

class Record:
def __init__(self, base):
self.base = base

@property
def label(self):
return f"record-{self.base}"

def compute(self, step):
return self.base + step

record = Record(4)
record.__dict__['label'] = "shadow"
record.__dict__['compute'] = lambda step: 0

class_label = Record.label
instance_label = record.label
class_compute = Record.compute
instance_compute = record.compute
call_result = record.compute(3)
manual_result = Record.compute(record, 3)

答案要点

Record.label 是 class access,返回 class dict 中的 property 对象本身。record.label 是 instance access,命中 data descriptor,调用 property 的 fget,结果是字符串 "record-4";实例字典中的 label 同名值没有取得优先权。Record.compute 是 class access,函数 descriptor 在实例为空时返回原 function object。record.compute 因为实例字典里存在同名 compute,并且类上的 function 是 non-data descriptor,所以结果是实例字典里的 lambda。record.compute(3) 调用这个 lambda,结果是 0Record.compute(record, 3) 手动调用原 function object,显式传入 record,结果是 7。这道题的检查顺序是先看 class dict 命中对象,再判断 data descriptor 或 non-data descriptor,随后检查实例字典覆盖,最后进入 callable dispatch。

本章知识点总结

  • Descriptor 对象:Descriptor 位于类命名空间,通过 get、set 或 delete 入口接管属性访问。
  • 读取顺序:实例属性读取会优先处理 data descriptor,再检查实例字典,再处理 non-data descriptor 和普通类属性。
  • C 字段暴露member_descriptorgetset_descriptor 把 C struct 字段或 C 回调暴露成 Python attribute。
  • 函数对象:Function object 保存 code、globals、defaults、kwdefaults、closure、annotations 和调用入口。
  • 调用现场:一次函数调用产生 frame 和局部变量绑定,函数对象本身保存可复用定义材料。
  • 函数绑定:Python 函数作为类属性读取时表现为 non-data descriptor,实例读取会生成 bound method。
  • Method 对象:Bound method 保存 __func____self__,调用时把绑定对象并入实参序列。
  • 内建方法:C method descriptor 没有 Python __code__,它通过 C 函数指针、flags 和接收者类型检查完成调用。
  • Vectorcall:Vectorcall 使用连续参数数组、nargsfkwnames 降低 tuple 与 dict 临时分配成本。
  • 调用契约:支持 vectorcall 的对象仍需保持与 tp_call 一致的返回值、异常和参数语义。
  • 错误返回:C 层调用失败通常设置异常并返回 NULL-1,源码阅读要沿错误设置点追踪原因。
  • Property 对象property 保存 fget、fset、fdel 和 doc,把属性读取、写入、删除转换为函数调用。
  • Property 优先级property 是 data descriptor,实例字典同名键不会覆盖它的读取入口。
  • 迁移顺序:分析点号调用时,先看属性访问命中对象,再判断 descriptor 类型,随后看绑定和 callable dispatch。