Python 3.15 的 sentinel:让“参数未传入”成为一等公民

2026-09-28 27 预计阅读时间: 1 分钟
来源: realpython.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:8 分钟

过去,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 的几个关键特性:

  1. MISSING 的 repr 简洁,函数签名更容易阅读;
  2. 分支判断使用 is,明确检查对象身份;
  3. 浅复制和深复制不会制造一个“看起来一样、实际上不同”的哨兵;
  4. 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 的签名、类型和运行时行为对“参数缺席”形成一致且可验证的约定。


相关推荐