过去,Python 开发者通常用 object() 创建哨兵值,用来区分“调用者没有传参数”和“调用者明确传入了 None”。这种写法能工作,却会留下难看的函数签名、不够精确的类型提示,以及复制、序列化时的身份问题。
Python 3.15 引入新的 sentinel 内置能力,把这一常见模式变成语言直接支持的概念:哨兵拥有清晰的名称,适合出现在函数签名和类型注解中,并能在复制与 pickle 往返后保持预期身份。
为什么 None 有时不够用
考虑一个更新配置的函数:
- 没传
timeout:保持原配置不变; - 传入
None:明确关闭超时; - 传入数字:设置新的超时时间。
这里有三种状态,而 None 只能表达其中一种。传统做法会创建一个私有对象:
_MISSING = object()
def update_timeout(timeout=_MISSING):
if timeout is _MISSING:
return "keep existing value"
if timeout is None:
return "disable timeout"
return f"set timeout to {timeout}"
问题在于,默认参数通常会显示成类似 <object object at 0x...> 的内容。它既不利于阅读文档,也不适合作为稳定的公共 API 表达。
使用 Python 3.15 的 sentinel 后,可以给这个特殊值一个明确名称:
MISSING = sentinel("MISSING")
这样,函数签名中的默认值会直接显示为 MISSING,而不是一段无意义的内存地址。
一个可运行的完整示例
下面的示例需要 Python 3.15。将它保存为 sentinel_demo.py:
from copy import copy, deepcopy
from inspect import signature
import pickle
MISSING = sentinel("MISSING")
def configure_timeout(
timeout: float | None | sentinel = MISSING,
) -> str:
if timeout is MISSING:
return "保持现有超时配置"
if timeout is None:
return "关闭超时"
if timeout < 0:
raise ValueError("timeout 不能为负数")
return f"将超时设置为 {timeout} 秒"
print(signature(configure_timeout))
print(configure_timeout())
print(configure_timeout(None))
print(configure_timeout(2.5))
assert copy(MISSING) is MISSING
assert deepcopy(MISSING) is MISSING
assert pickle.loads(pickle.dumps(MISSING)) is MISSING
print("复制与 pickle 检查通过")
运行:
python3.15 sentinel_demo.py
这个示例体现了 sentinel 的几个关键特性:
MISSING的repr简洁,函数签名更容易阅读;- 分支判断使用
is,明确检查对象身份; - 浅复制和深复制不会制造一个“看起来一样、实际上不同”的哨兵;
- pickle 往返后仍能恢复正确的哨兵身份。
由于这是 Python 3.15 的预览特性,实际采用前应以所用 Python 3.15 构建版本的文档和类型检查器支持情况为准。
类型提示解决了什么
过去把 _MISSING 定义成 object() 时,开发者往往只能写出这样的注解:
def configure_timeout(timeout: float | None | object = _MISSING):
...
object 的范围过宽。字符串、列表以及几乎所有 Python 值都符合这个类型,因此静态检查器很难从中获得有用信息。
使用专门的 sentinel 类型后,签名可以更准确地表达三种输入:数字、None 或哨兵值。代码进入 timeout is MISSING 分支后,支持该特性的类型检查器也有机会进行更精确的类型收窄。
不过,类型检查器和 IDE 对预览语言特性的支持可能晚于解释器。如果项目准备提前使用 Python 3.15,应该同时检查 mypy、Pyright、IDE 插件和文档生成工具是否能正确理解这种注解。必要时,可以暂时使用较保守的注解,并保留身份判断。
复制和 pickle 为什么值得关注
手写哨兵最容易被忽略的问题,是它必须依赖身份,而不是值相等:
if value is MISSING:
...
假如复制操作创建了另一个对象,那么 copied is MISSING 就会变成 False。类似地,如果经过 pickle 序列化和反序列化后产生了新实例,任务队列、缓存或多进程代码中的身份判断也可能失效。
标准化的 sentinel 对这些行为作出了明确约束。不过,工程上仍建议把需要序列化的哨兵定义为模块级常量,并使用稳定名称:
# markers.py
NOT_PROVIDED = sentinel("NOT_PROVIDED")
其他模块统一导入它:
from markers import NOT_PROVIDED
不要在每次函数调用时临时创建同名哨兵,也不要把“名称一样”当成“身份一样”。哨兵的正确比较方式仍然是 is 和 is not。
哪些场景适合使用 sentinel
Sentinel 最适合表达“缺席状态”,尤其是 None 本身具有业务含义时:
- 区分字典键不存在和键对应的值为
None; - 区分 PATCH API 中“字段未提交”和“字段被清空”;
- 为缓存区分“尚未查询”和“查询结果为空”;
- 为配置合并区分“继承默认值”和“显式禁用”;
- 在迭代器、解析器或队列中表达特殊终止状态。
一个典型的字典查询封装可以这样写:
NOT_FOUND = sentinel("NOT_FOUND")
def require_key(mapping: dict, key: str):
value = mapping.get(key, NOT_FOUND)
if value is NOT_FOUND:
raise KeyError(f"缺少必需字段:{key}")
return value
assert require_key({"email": None}, "email") is None
这里不能简单使用 if value is None,因为 None 是允许保存的真实值。
采用前的检查清单
引入 sentinel 时,可以按以下规则控制复杂度:
- 如果
None已经足够表达“未提供”,就不要额外创建哨兵; - 将公共哨兵定义在模块顶层,并让所有调用方导入同一个对象;
- 始终使用
is或is not,不要使用==; - 为哨兵选择能直接说明语义的名称,例如
MISSING、NOT_PROVIDED; - 涉及进程通信、缓存或持久化时,增加 pickle 往返测试;
- 升级 Python 3.15 前,确认类型检查器、IDE 和文档工具已经兼容。
sentinel 并不是要替代 None,而是补上 None 无法表达的那一个状态。它最有价值的地方,也不是少写几行样板代码,而是让 API 的签名、类型和运行时行为对“参数缺席”形成一致且可验证的约定。