CPython 3.15 Sentinel 哨兵对象指南:PySentinel_Type、PySentinel_Check 与 PySentinel_New 完整解析
本文以 CPython 仓库中的 sentinel C API 文档 为核心,完整讲解 Python 3.15 新增 sentinel 内建类型配套的 C 扩展 API——PySentinel_Type、PySentinel_Check/PySentinel_CheckExact 与 PySentinel_New 的语义、参数约束与失败行为,并结合 实现源码、类型定义头文件 和 官方测试用例,说明哨兵对象在 C 扩展开发中的正确用法与底层机制。
1. Sentinel 背景:为什么需要内建哨兵类型
哨兵(sentinel)是一种用"独特对象身份"而非"特殊值"来表达缺失、默认或终止状态的惯用技术。在 Python 3.15(PEP 661,见 whatsnew 3.15)之前,开发者通常手写 object() 或自定义类来充当哨兵,缺点是 repr 不可读、无法跨进程传递。3.15 起,builtins 中新增了一等公民类型的 sentinel 内建类型,sentinel(name, /, *, repr=None) 可以创建带有简洁可读表示的唯一哨兵对象。
标准库已经率先采用了这一新类型。例如 Lib/dataclasses.py 用它替换了过去匿名对象式的哨兵:
# A sentinel object to detect if a parameter is supplied or not.
MISSING = sentinel("MISSING")
# A sentinel object to indicate that following fields are keyword-only by default.
KW_ONLY = sentinel("KW_ONLY")
这样 dataclasses 在检测"参数是否显式传入"以及"字段是否为 keyword-only"时,得到的调试输出是 MISSING、KW_ONLY,而不是一长串 <object object at 0x...>。
2. sentinel 类型的关键行为(源码印证)
要正确使用 C API,先必须清楚被操作对象的行为边界。以下内容均可从 Objects/sentinelobject.c 得到验证:
- 不可子类化。
PySentinel_Type的tp_flags为Py_TPFLAGS_DEFAULT | Py_TPFLAGS_IMMUTABLETYPE | Py_TPFLAGS_HAVE_GC(Objects/sentinelobject.c#L203-L222),没有Py_TPFLAGS_BASETYPE,因此class Sub(sentinel)会抛TypeError——这正是 C API 文档中"该类型目前不允许子类"这句话的实现依据。 - 三个成员槽。C 结构体
sentinelobject包含name、module、repr三个PyObject *(Objects/sentinelobject.c#L13-L18),对应__name__、__module__两个成员描述符(__name__只读,__module__可写,见 Objects/sentinelobject.c#L188-L192)。 - repr 回退规则。
sentinel_repr在self->repr != NULL时返回自定义 repr,否则返回self->name(Objects/sentinelobject.c#L152-L160)。 - 可哈希、可比较、可 GC 追踪。类型实现了
tp_hash、tp_richcompare和完整的tp_traverse/tp_clear(Objects/sentinelobject.c#L124-L150),因此哨兵对象可以作为字典键、可以参与引用环检测。 - 支持
|构造类型表达式。sentinel_as_number.nb_or = _Py_union_type_or(Objects/sentinelobject.c#L194-L196),所以sentinel("MISSING") | int能得到typing.Union,None | sentinel同样成立。测试用例 覆盖了missing | int、int | missing、missing | None、missing | list[int]、missing | (int | str)等场景,并验证missing | missing is missing、missing | 1抛TypeError。 - 拷贝即同一对象。
__copy__与__deepcopy__都直接Py_NewRef(self)(Objects/sentinelobject.c#L162-L172),即哨兵对象在深浅拷贝后仍保持身份不变。 - 注册为内建类型。Python/bltinmodule.c#L3577 通过
SETBUILTIN("sentinel", &PySentinel_Type);将其挂到builtins模块。
3. PySentinel_Type:类型对象
C API 文档(Doc/c-api/sentinel.rst)声明的第一个符号:
PyAPI_DATA(PyTypeObject) PySentinel_Type; /* versionadded: 3.15 */
该 PyTypeObject 实例就是 Python 侧的 sentinel 类型对象,"This is the same object as sentinel"——即 C 端的 PySentinel_Type 与 Python 端 builtins.sentinel 是同一个对象。在 C 扩展中的典型用途:
- 调用
Py_IS_TYPE(op, &PySentinel_Type)做精确类型判断(见下节宏); - 调用
Py_INCREF((PyObject*)&PySentinel_Type)后在 C 代码里引用sentinel类型本身; - 作为
PyType_Ready、PyObject_IsInstance等通用 API 的实参。
声明位置在 Include/cpython/sentinelobject.h#L10。注意该头文件整体被 #ifndef Py_LIMITED_API 包裹,因此这些符号只面向稳定 ABI 之外的全量 API 扩展。
4. PySentinel_Check 与 PySentinel_CheckExact:类型检查
文档定义了两个检查函数,语义分别对应"是否为 sentinel 或其子类型"与"是否严格是 sentinel":
| API | 语义 | 当前实现 |
|---|---|---|
PySentinel_Check(PyObject *o) |
o 是 sentinel 对象或其子类型时返回真;"当前该类型不允许子类,所以此检查是精确的;未来版本可能允许子类型";函数总是成功 |
等价于 PySentinel_CheckExact |
PySentinel_CheckExact(PyObject *o) |
o 是 sentinel 对象而非子类型时返回真;"当前该类型不允许子类;未来版本可能允许子类型";函数总是成功 |
Py_IS_TYPE(o, &PySentinel_Type) |
在 Include/cpython/sentinelobject.h#L12-L15 中可以看到具体实现:
#define PySentinel_CheckExact(op) Py_IS_TYPE((op), &PySentinel_Type)
/* Alias as long as subclasses are not allowed. */
#define PySentinel_Check(op) PySentinel_CheckExact(op)
两点工程含义:
- 两者目前都只是宏,开销等同一次类型指针比较,"This function always succeeds" 意味着不会设置异常,可以安全地在热路径中调用。
- 由于
PySentinel_Check被实现为PySentinel_CheckExact的别名(/* Alias as long as subclasses are not allowed. */),一旦未来 Python 版本允许sentinel子类,两者的语义将自动分叉:Check继续兼容子类,CheckExact收紧为精确匹配。编写扩展时应按意图选择:需要"广义哨兵"用PySentinel_Check,需要"严格内建哨兵"用PySentinel_CheckExact。这一补充 API 的引入也记录在 NEWS 条目 中。
C 扩展中使用检查宏的典型模式:
#include "Python.h"
#include "cpython/sentinelobject.h"
PyObject *
get_default(PyObject *arg)
{
if (arg == NULL) {
return Py_None;
}
if (PySentinel_Check(arg)) {
/* 调用方传入了哨兵,表示"未提供" */
return Py_NewRef(Py_None);
}
return Py_NewRef(arg);
}
5. PySentinel_New:创建哨兵对象
文档声明的原型与参数契约:
PyObject *PySentinel_New(const char *name,
const char *module_name,
const char *repr);
| 参数 | 约束 | 行为 |
|---|---|---|
name |
不得为 NULL |
设置为 __name__ |
module_name |
可以为 NULL |
为 NULL 时 __module__ 被设为 None |
repr |
可以为 NULL |
为 NULL 时 repr() 回退返回 __name__ |
返回值:成功时返回新建的 sentinel 对象;失败时返回 NULL 并设置异常。
对照 PySentinel_New 的实现(Objects/sentinelobject.c),可以看到文档契约与代码一一对应:
PyObject *
PySentinel_New(const char *name, const char *module_name, const char *repr)
{
PyObject *name_obj = PyUnicode_FromString(name); // name 为 NULL 时此处失败
...
PyObject *module_obj = module_name == NULL
? Py_None // module_name == NULL → __module__ = None
: PyUnicode_FromString(module_name);
...
PyObject *sentinel = sentinel_new_with_module(
&PySentinel_Type, name_obj, module_obj, repr_obj);
...
}
注意一个与文档措辞的细微差别:name "must not be NULL" 是前置契约——传 NULL 会经由 PyUnicode_FromString(NULL) 触发 SystemError 类的失败路径(实现上以失败返回并设置异常),而不是一个专门检查。因此在扩展代码中应始终传入非 NULL 的 name。
5.1 与 Python 侧 sentinel(...) 的差异
Python 层的 sentinel.__new__ 签名是 sentinel(name, /, *, repr=None)(见 sentinel_new_impl):__module__ 由解释器自动推断——caller() 从当前帧取所属函数模块(Objects/sentinelobject.c#L31-L47),而不是把 None 交给用户。也就是说:
- Python 里写
sentinel("MISSING"),其__module__是调用所在模块的名字,这保证了该对象可被 pickle(见下节); - C 扩展里调用
PySentinel_New时没有"当前调用者模块"可推断,因此必须显式提供module_name,或传NULL接受__module__ = None(此时对象不可 pickle)。
5.2 Pickle 支持及其前置条件
文档中关于序列化的一段必须完整理解:
For pickling to work, module_name must be the name of an importable module, and the sentinel must be accessible from that module under a path matching name. Pickle treats name as a global variable name in module_name (see
object.__reduce__).
机制在 sentinel_reduce:__reduce__ 直接返回 self->name,pickle 随即按"模块全局变量"的常规路径去 module_name 中按 name 查找对象。由此得出使用规则:
- 模块必须可导入:
module_name是真实模块名(可含包前缀,如"mylib.constants"); - 名字必须与模块属性路径一致:如果哨兵是模块级变量
mylib.constants.MISSING,则name应传"MISSING";如果它挂在类上(如SomeClass.MISSING),name需传对应的属性路径; - 两条同时满足时,
pickle.loads(pickle.dumps(x)) is x成立(同一模块加载下按名字解析回同一对象);否则pickle.dumps会抛pickle.PicklingError。
测试 完整验证了这两个方向:模块级 A_SENTINEL 与类属性 SentinelContainer.CLASS_SENTINEL 在所有 pickle 协议下往返后 is 同一对象;而一个从未被任何模块以 MISSING 名字暴露的 sentinel("MISSING"),在所有协议下 pickle.dumps 均抛 PicklingError。
5.3 在 C 扩展中创建哨兵的示例
假设你要在扩展中定义一个模块级哨兵并在函数里使用它,可参考标准库的模式(如 dataclasses 在 Python 层等价地写 MISSING = sentinel("MISSING")):
static PyObject *g_missing = NULL; /* 模块级哨兵,module init 时创建一次 */
/* 模块初始化阶段:name 必须与模块中可访问的属性路径一致 */
g_missing = PySentinel_New("MISSING", "__myext", "MISSING");
if (g_missing == NULL) {
return -; /* 异常已设置,模块初始化失败 */
}
PyModule_AddObject(m, "MISSING", g_missing); /* 转移引用 */
/* 后续使用:以身份比较判断"参数是否提供" */
static PyObject *
myext_do_stuff(PyObject *module, PyObject *arg)
{
PyObject *result;
if (PySentinel_Check(arg)) { /* 调用方传入了 MISSING 哨兵 */
result = PyLong_FromLong(-1);
} else {
result = PyObject_Str(arg); /* 示例处理 */
}
return result;
}
要点回顾:
name用"MISSING",与PyModule_AddObject注册的属性名一致——这是可 pickle 的前提;module_name用"__myext"(扩展模块的真实__name__)——保证__module__指向可导入模块;- 第三个参数显式给了
"MISSING"作为自定义 repr;若改为NULL,repr()会回退返回__name__,即同样显示MISSING。
6. 边界行为速查
以下行为全部有测试或源码依据,便于在扩展开发中直接引用:
- 构造错误:
sentinel()(缺 name)、位置参数多于 1 个、repr传非str(如repr=42)均抛TypeError(测试); - 两个同 name 的哨兵身份不同:
sentinel("MISSING") is not sentinel("MISSING")(测试)——"哨兵"的价值来自身份而非相等性; sentinel.__flags__含Py_TPFLAGS_IMMUTABLETYPE与Py_TPFLAGS_HAVE_GC、不含Py_TPFLAGS_BASETYPE,禁止子类化(测试);- 即使
name是str子类(可能形成引用环),对象也被 GC 追踪且可被正常回收(测试,印证tp_traverse/tp_clear实现的正确性); - 类型不可被动态添加属性(
sentinel.attribute = "value"抛AttributeError,对应IMMUTABLETYPE标志)。
7. 小结
sentinel C API(3.15+,见 Doc/c-api/sentinel.rst)只有三个符号,但契约完整:PySentinel_Type 是 Python sentinel 类型的同一对象;PySentinel_Check/PySentinel_CheckExact 是无副作用的检查宏,当前互为别名、为未来可能的子类化预留了语义分叉;PySentinel_New 创建对象时须牢记"name 非 NULL、module_name 决定 __module__ 且为 pickle 前提、repr 为 NULL 时回退 __name__"三条契约。结合 Objects/sentinelobject.c 的实现细节与 test_builtin 的 sentinel 测试,扩展开发者可以安全地在 C 层创建、检查并利用哨兵对象表达"未提供/缺失"状态,同时保证其在 Python 侧拥有可读 repr、可哈希身份和(满足命名条件时的)可序列化能力。
适用前提:以上 API 仅存在于 Python 3.15 及之后的开发版本,且 Include/cpython/sentinelobject.h 位于全量(非 limited API)头文件之下,扩展需按完整 C API 构建。
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 StartedRust0625
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