Python 3.15 的 Sentinel:让“参数未传入”拥有正式表达

2026-09-28 16 预计阅读时间: 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,目标是把这种约定升级为语言提供的明确能力。

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 的价值在于消除每个项目各自实现哨兵的细小差异。它让函数签名更像文档,让类型提示能够描述缺失状态,也让复制和序列化行为更可预测。对于大量使用可选参数、局部更新和缓存语义的代码库,这是一项小而实用的改进。


相关推荐