Python 开发者长期用 object() 创建哨兵值,以区分“调用者没有传参数”和“调用者明确传入了 None”。这个技巧有效,却会带来难看的函数签名、模糊的类型标注,以及复制和序列化时的身份问题。Python 3.15 预览版引入标准化的 Sentinel,目标是把这种约定升级为语言提供的明确能力。
Python 3.15 尚处于预览阶段,安装和部署前应核对所用预览版本的最终 API 与发布说明。下文示例采用预览设计中的
Sentinel接口。
None 为什么不总能表示“缺省”
考虑一个更新用户资料的函数:
def update_profile(*, nickname: str | None = None) -> None:
...
这个签名无法区分两种请求:
- 没有提供
nickname,因此保持原值; - 明确传入
nickname=None,因此清空昵称。
传统做法是创建一个匿名对象:
_MISSING = object()
def update_profile(*, nickname=_MISSING) -> None:
if nickname is _MISSING:
print("保持昵称不变")
elif nickname is None:
print("清空昵称")
else:
print(f"更新昵称为 {nickname!r}")
逻辑没有问题,但函数签名通常会出现 <object object at 0x...> 之类的表示,类型检查器也很难准确描述参数。普通 object() 经过 copy 或 pickle 后,还可能不再是原来的对象,破坏依赖 is 的判断。
标准 Sentinel 改善了什么
Python 3.15 的新能力可以这样使用:
from inspect import signature
MISSING = Sentinel("MISSING")
def update_profile(
*,
nickname: str | None | Sentinel = MISSING,
) -> None:
if nickname is MISSING:
print("保持昵称不变")
elif nickname is None:
print("清空昵称")
else:
print(f"更新昵称为 {nickname!r}")
print(signature(update_profile))
update_profile()
update_profile(nickname=None)
update_profile(nickname="Ada")
在 Python 3.15 预览环境中保存为 demo.py 后,可以这样运行:
python3.15 demo.py
关键变化不是少写了几行代码,而是哨兵值获得了稳定、可读的名称。函数签名会显示 MISSING,而不是带内存地址的匿名对象。参数类型也可以明确表达:这里接受字符串、None,或者代表“未提供”的 Sentinel。
判断时仍应使用身份比较:
if nickname is MISSING:
...
不要改成 ==。哨兵表达的是某个唯一标记的身份,而不是普通业务值之间的相等关系。
复制与 pickle 不再轻易破坏身份
哨兵值经常出现在配置对象、任务消息和缓存结构中,因此仅仅拥有好看的 repr 还不够。它还需要在复制和 pickle 往返之后保持可识别性。
下面的脚本可以直接用于检查当前 Python 3.15 预览版本的行为:
import copy
import pickle
MISSING = Sentinel("MISSING")
shallow = copy.copy(MISSING)
deep = copy.deepcopy(MISSING)
restored = pickle.loads(pickle.dumps(MISSING))
assert shallow is MISSING
assert deep is MISSING
assert restored is MISSING
print(MISSING)
print("copy、deepcopy 和 pickle 均保留了哨兵身份")
相比之下,裸 object() 不能提供同样明确的序列化语义:
import pickle
marker = object()
restored = pickle.loads(pickle.dumps(marker))
print(restored is marker) # False
如果 Sentinel 需要跨模块或进程传递,建议把它定义在一个可导入模块的顶层,而不是在函数内部临时创建。例如:
# markers.py
MISSING = Sentinel("MISSING")
# service.py
from markers import MISSING
def apply_patch(value=MISSING):
if value is MISSING:
return "unchanged"
return f"set to {value!r}"
这样所有调用方都从同一位置导入标记,代码审查和静态分析也更容易发现误用。跨进程时,每个进程仍有自己的对象空间;真正需要保证的是接收端反序列化后获得该进程中对应的规范 Sentinel,并在本地继续使用 is 判断。
最适合 Sentinel 的 API
Sentinel 特别适合以下接口:
- PATCH 或局部更新接口,需要区分“字段缺失”和“字段值为
null”; - 缓存查询,需要区分“没有缓存”和“缓存内容就是
None”; - 队列、迭代器或生产者—消费者流程,需要一个不会与业务数据冲突的结束标记;
- 配置合并,需要区分“沿用默认值”和“显式覆盖为空值”;
- 装饰器及框架内部 API,需要判断调用者是否真正提供了参数。
例如,一个缓存封装可以这样实践:
MISSING = Sentinel("MISSING")
_cache: dict[str, object] = {
"nullable-result": None,
}
def get_cached(key: str):
value = _cache.get(key, MISSING)
if value is MISSING:
return "cache miss"
return value
assert get_cached("unknown") == "cache miss"
assert get_cached("nullable-result") is None
这里不能用 dict.get(key) 的默认 None,因为 None 本身就是合法缓存内容。
采用时的边界与检查清单
Sentinel 解决的是“缺失状态”建模问题,并不意味着每个默认参数都需要它。若 None 在业务中永远不是合法值,继续使用 None 往往更简单。
准备采用时可以检查以下事项:
- 只有在必须区分“未提供”和某个合法值时才引入 Sentinel;
- 在模块顶层集中定义,并使用清楚的名称,如
MISSING、UNSET或NOT_GIVEN; - 始终使用
is和is not判断身份; - 在公开 API 中补充类型标注,并说明 Sentinel 对应的业务语义;
- 涉及任务队列、缓存持久化或跨进程通信时,加入真实的 pickle 与集成测试;
- 在 Python 3.15 正式发布前,确认构造方式和类型检查器支持没有发生变化;
- 若库仍需支持旧版 Python,可暂时保留私有哨兵实现,或在兼容层中统一封装。
标准 Sentinel 的价值在于消除每个项目各自实现哨兵的细小差异。它让函数签名更像文档,让类型提示能够描述缺失状态,也让复制和序列化行为更可预测。对于大量使用可选参数、局部更新和缓存语义的代码库,这是一项小而实用的改进。