几乎每一位维护过 Python 库的作者,都在代码顶部写过 _MISSING = object() 这样一行。它的动机十分直接:当函数默认值需要同时容纳“调用者未传入该参数”与“调用者显式传入 None”两种情形时,必须有第三方标记登场,因为 None 本身就可能是一份合法数据。

以 object() 充当这枚标记虽然可以运行,却仅限于“可以运行”。PEP 661 在提案文本中逐项列出了它的缺陷,下面结合代码逐一说明。随 Python 3.15 正式版本落地的内置类型 sentinel 正是针对这些缺陷给出的官方答案,本站在下文完整梳理其定义、行为、使用步骤与踩坑细节。

object() 哨兵的三处短板:repr、类型注解与拷贝后的身份

第一处是 repr 几乎没有可用信息。以 get_config(key, default=_MISSING) 为例,通过 inspect.signature() 观察签名时打印结果是 (key, default=<object object at 0x7f9c1a2b3e40>)。help() 输出、签名查看以及异常堆栈中出现的都是这串十六进制内存地址,使用者翻阅文档后往往会怀疑这个库尚未完工。

第二处是缺少独立类型。type(_MISSING) 的结果就是 object,于是想给默认值写一条精确的注解时只能落到 default: str | int | object = _MISSING 这样的写法,而 str | int | object 在语义上等价于 object,相当于没有标注,类型检查器从这一步起就无法提供帮助。

第三处是拷贝与 pickle 之后身份丢失,这一处最隐蔽。一旦哨兵跟随参数经历过一次 deepcopy、穿过一次多进程队列,或者被放入缓存,copy.deepcopy(_MISSING) 与 pickle.loads(pickle.dumps(_MISSING)) 都会产生新的对象实例,is 判断便不再成立。程序不会抛出异常,只是逻辑开始偏离预期。

Python 3.15新增内置sentinel类型:一条is判断替代_MISSING=object()

sentinel 是什么:builtins 中的新类型与构造签名

一句话概括:sentinel 是位于 builtins 中的新类型,每一次实例化都会得到一个身份唯一且自带名字的哨兵值,专门用来表示“此处存在一个特殊标记”。

该特性和 Python 3.15.0 一同发布,发布日期为 2026 年 10 月 9 日。使用时不需要任何 import 语句,直接写出 MISSING = sentinel("MISSING") 即可。此时 MISSING.__name__ 为 'MISSING',MISSING.__module__ 为 '__main__'。

构造函数只有两个参数:sentinel(name, /, *, repr=None)。其中 name 是必填项,仅允许按位置传入且必须是 str 类型,传入整数会立刻触发 TypeError。repr 是可选参数,只能以关键字形式给出,作用在于改写对象的 str() 与 repr() 输出。例如构造 sentinel("MISSING", repr="<missing>") 之后,调用 str(MISSING) 得到的结果就是 '<missing>'。

__name__ 与 __module__ 均为可读属性,并且后者还允许被改写。在 exec() 之类动态创建模块的场合,这一能力具有实际用途,后文讲解 pickle 时还会提到。

还有一点需要记住:sentinel 类型不能被继承。写下 class MySentinel(sentinel) 会直接抛出 TypeError: type 'sentinel' is not an acceptable base type。

六条行为速查:真值、唯一性与拷贝保持不变

官方给出的是六条可直接记忆的行为特征:bool(MISSING) 结果为 True,也就是哨兵属于真值;哨兵只与自身相等,与任何其他对象比较均返回 False;每调用一次构造都会得到新对象,sentinel("X") is sentinel("X") 的结果是 False;哨兵可以用作 dict 的 key,也可以放进 set;copy() 与 deepcopy() 均原样返回,对象的身份不被改变;既不支持 <> 比较,也不支持 weakref。

Python 3.15新增内置sentinel类型:一条is判断替代_MISSING=object()

其中第一行最需要注意:哨兵是真值。因此所有判断都应统一写成 is 与 is not,不要用 ==,更不要写成 if value:。这不是编码风格上的偏好选择,而是正确性问题——举例而言,若意图写“哨兵则跳过某分支”却写成 if value:,由于哨兵为真,分支照样会进入。

另有一处容易被误认为 bug 的设计:每次调用都会产生全新对象,不做去重。PEP 661 明确否决了“按模块注册表去重”的提案,理由在于这种做法过于隐式——sentinel("STOP") 究竟应该复用哪一个实例,取决于当时的调用栈,这种魔法没有人愿意在半年之后的故障排查现场遇到。正确姿态是将哨兵定义为模块级变量,使其只被创建一次,之后全程复用,同时保证变量名与该哨兵实例的 name 完全一致。

一套四步决策链:从 None 到 sentinel 再到 Enum

引入新工具最常见的误区是四处寻找可以用它的地方。下面是按顺序执行的四个判断步骤。

第一步,默认仍然优先用 None。如果目的只是在参数未传入时提供一个缺省值,并且 None 不可能作为真实数据现身,那么 None 已经足够,例如 connect(host: str, port: int | None = None) -> Connection。没有必要为了让代码“看起来更专业”换用 sentinel,那样只会多出一个全局变量和多一次 is 判断,收益为零。

第二步,只有 None 本身是合法数据时才引入 sentinel。判断标准很清楚:这个参数是否存在被调用方主动置为 None 的可能,并且 None 与“未传入”在语义上并不相同。以更新用户资料为例,字段不出现代表不修改,字段取值为 None 代表主动清空,这两件事必须分开处理,单靠 None 无法完成。

第三步,老代码中的 object() 哨兵按需替换,不必批量替换。满足下面任意一个条件就值得改动:该哨兵出现在公开签名中,会被 help()、inspect.signature() 或文档工具抓到;或者它会随数据流动,经过 copy、deepcopy、pickle 或多进程边界。如果哨兵仅在函数内部充当临时标记、从不外露,换与不换都可以,保持原样更省事。

Python 3.15新增内置sentinel类型:一条is判断替代_MISSING=object()

第四步,需要一组彼此关联且有顺序的哨兵时改用 Enum。像 END_OF_FILE、TIMEOUT、RETRY 这样成套出现的状态,Enum 能遍历、能排序、也能一次性看全,因而更为合适。sentinel 面向的是单一、孤立、语义上就是一个特殊值的场景。

四个可直接照写的场景:区分未传与清空、PATCH、流终止与缓存

场景一,区分“没传”和“传了 None”。这是最基础的用法,目标是让 default=None 与不传 default 变成两件不同的事。示例函数在 options 字典中查找指定键,调用方明确指定 default=None 时返回 None,完全不给兜底值时则抛出 KeyError 提示缺少配置项。若想让类型检查器也读懂意图,可以补上注解 default: str | MISSING = MISSING。sentinel 实现了 __or__ 与 __ror__,能够直接写在 | 两侧,组合结果为 typing.Union。工具会把 MISSING 看作一个“仅含自身单一成员的静态类型”,处理思路与 None 相似,前提是所用工具已经支持,这一点下文单独说明。

场景二,PATCH 局部更新,这是 sentinel 价值最高的地方,也正是 object() 版本会静默出错的场合。业务需求是:前端提交的表单中,未出现的字段表示不改动,出现且值为 null 表示主动清空。示例中定义 UNSET = sentinel("UNSET"),在 build_update 里对 email 一行最关键:若表单未携带该字段就整行跳过。三种输入由此得到三种干净的结果——空字典不产出更新项,显式传 None 产出 {'email': None},传入字符串则原样写入。

如果把哨兵换回 object(),再让 payload 经过一次 deepcopy,问题就出现了:新实例破坏了身份,if email is not _UNSET 于是在不该成立时成立,update["email"] 被写成了一个 object 对象。数据库层最终收到的是 <object object at 0x...> 这样东西,或者更常见的是序列化阶段报错、字段被固化为字符串 "<object object at 0x...>"。整个过程中没有抛出任何异常,留下的只有脏数据。换成 sentinel 之后这条路径被彻底封死,因为 deepcopy 对它只会原样返回,身份始终不变。

场景三,生产者与消费者流中的终止标记。拿字符串充当哨兵是另一个高发事故源。以 consume(stream) 为例,写成遇 "done" 即收工之后,只要上游某天真的发来一条内容恰为 "done" 的数据,循环就提前结束、后续数据全部丢弃。此类 bug 之所以极难复现,是因为触发条件取决于数据内容而非代码自身。改用 DONE = sentinel("done") 之后,循环只承认同一个对象作为结束信号:is 判断的是对象身份,而队列中传来的任何数据都不可能恰是你模块里的那个 DONE,冲突概率为零。

与此同时还有一条附带要求:如果这条流跨越进程,DONE 必须可以被 pickle,这要求它是模块级变量,并且变量名与该实例的 name 完全一致。避坑清单部分会再次展开。

Python 3.15新增内置sentinel类型:一条is判断替代_MISSING=object()

场景四,在缓存中区分“没算过”与“算出来就是 None”。常见的缓存写法是先取 cache.get(key),再判断 if value is None 决定是否回源。如果某个 key 的真实计算结果本来就是 None,这段代码每次都会回源查询,缓存形同虚设。示例中定义 NOT_COMPUTED = sentinel("NOT_COMPUTED"),读取时以 _cache.get(uid, NOT_COMPUTED) 作为判据:命中时直接返回,哪怕值就是 None。这样一来,“尚未计算”由 NOT_COMPUTED 表示,“算出来是 None”由 None 表示,两种状态不再混为一谈,缓存应有的命中率得以恢复。

六个反直觉细节,按踩坑概率排序

1. 两个同名的 sentinel 并非同一对象。名字只是标签而非身份标识。因此一个哨兵应当只创建一次,此后始终复用。

2. 判断时只能用 is,不要用 ==,更不能写成 if value:。sentinel 属于真值,if not value: 永远不会成立。这类错误编译器不会给出提示,只会出现在某天深夜的日志里。

3. 不要把哨兵的创建封进函数里返回。这样一来每次调用都是新对象,is 判断统统失效。哨兵必须是稳定的、以模块级形式存在的对象。

4. 变量名与 name 不一致会让 pickle 报错。sentinel 能被 pickle 的唯一前提是:它可以按“模块名 + 名字”的路径被重新 import 回来。例如写 INHERIT = sentinel("INHERITED"),一旦 pickle 就会抛 PicklingError,因为变量名与 name 对不上。报错信息中会直接给出正确的名字。模块级的哨兵要求变量名与 name 严格一致;若位于类作用域,则需要把限定名写进 name,例如 AUTO = sentinel("Config.AUTO")。在函数内部临时创建的 sentinel 不可被 pickle,除非把它绑定到模块全局或类属性上,并且名字完全对得上。

5. __module__ 可写,但只应在特殊场合改动。在以 exec() 动态生成模块、或者把哨兵迁移到其他命名空间时,pickle 按名字查找会在错误的位置进行,此时修改 __module__ 才是正确解法。普通业务代码不必触碰它。

Python 3.15新增内置sentinel类型:一条is判断替代_MISSING=object()

6. sentinel 无法被继承。若想加入自定义行为,比如添加方法或属性,应当老老实实编写自己的类。sentinel 只承担“独一无二的标记”这一项职责。

版本与工具支持:3.15 内置、3.14 及更早走 backport

在 Python 3.15 及更高版本上可以直接使用,无需 import,因为它已属于 builtins。Python 3.14 及更早版本没有内置 sentinel,可以借助 typing_extensions 提供的 backport。需要留意的是该 backport 属于早期实现,其行为与 PEP 661 最终版本并非完全一致;如果你在 3.14 上写完、计划等 3.15 上线后切回内置版本,中间必须跑一遍测试,pickle 与类型注解这两块尤其要覆盖。

关于类型检查器,PEP 661 要求它们把 sentinel 视为“仅含自身一个成员的类型”,并支持用 is 与 is not 对包含 sentinel 的联合类型做类型收窄。不过各工具的支持是逐步跟进的,使用前应先查阅自家所选工具(mypy、pyright、pyrefly)的 release notes。另外,若 NAME = sentinel("NAME") 中变量名与 name 不一致,类型检查器应当报错——这也是它在帮你防范前述第 4 条坑。

存量哨兵是否需要改动?标准库自身历史上就并存四种写法:configparser._UNSET 是裸的 object,inspect.Parameter.empty 直接使用类本身,typing.NoDefault 是专用类型的一个实例,unittest.mock.DEFAULT 则是专用类的实例。PEP 661 的初衷正是收敛这种碎片化局面,但存量代码是否修改、何时修改,由各模块的维护者自行决定。你手上的项目同样如此——新代码采用新方案,老代码则依据“是否外露、是否经过 pickle”这两个条件择要调整。

小结:把约定变成语言能表达、能检查的能力

sentinel 特性自身并不复杂,一个新类型、两个参数、若干条行为约束而已。它的价值在于把一件长期依赖约定俗成与注释维护的事情,升级为语言层面可表达、可检查、可序列化的能力。若要记住三句话:够用就继续用 None,只有当 None 本身是合法数据时才引入 sentinel;一个哨兵只创建一次,置于模块级,且变量名等于 name;判断永远写 is,永远不用 == 也不写 if value:。

下一次准备写下 _MISSING = object() 时不妨停顿一秒想一想:这东西会不会出现在 help() 里,会不会经过一次 deepcopy。只要答案里出现一个“会”,就应当把它替换掉。想核对具体实现细节,可查阅 PEP 661 原文。