Python 中的哨兵值:关于 None 的大惊小怪
Much Ado About None: Sentinel Values in Python

原始链接: https://florianwilhelm.info/2026/10/sentinel_values/

在 Python 中,None 被过度使用,可能表示缺失、未设置、错误或实际值,从而造成歧义。哨兵值位于有效值域之外,永远不会与真实值冲突。Python 使用 Ellipsis 和 NotImplemented 等哨兵;Pydantic 等库使用 MISSING。像 object() 这样的自定义哨兵在运行时有效,但会增加静态类型复杂度,且变通方案各有取舍。PEP 661 先被延期,后被接受;自 Python 3.15 起,sentinel 是内置功能,并提供 typing_extensions 向后移植。类型检查器可以像处理 None 一样对其进行类型收窄。优先使用库提供的哨兵,用内置 sentinel 替换 object()/Enum 黑客技巧,并考虑使用异常或结果类型,而非返回 None 来表示错误。

讨论围绕一篇 Hacker News 帖子展开,主题是在 Python 中使用哨兵值以避免重用 `None` 带来的歧义。评论者争论 Python 的可选/联合类型是否足够,指出它们会扁平化,无法区分“缺失”与“存在但为 None”,而 Rust 等语言中的嵌套 optional 可以。例子包括文档所有者和 map 查找。有人认为应通过更好的 API 设计或异常而非哨兵值,也有人认为哨兵值有用且与 Python 习惯兼容。还有关于返回错误对象还是抛出异常的争论,涉及静态类型和运行时 bug 的担忧。总体而言,哨兵值有助于区分缺失值和空值,但其必要性取决于 API 设计和语言限制。
相关文章

原文

None is probably the most overloaded value in Python. We use it as a Swiss Army knife to express that something is missing, unset, that an error occurred, or simply as an actual value. In my talk about sentinel values, which I presented this year at PyConDE/PyData Berlin 2026, EuroPython 2026 and, most recently, the Swiss Python Summit 2026, I take a closer look at why this ambiguity bites us and what to use instead.

The problem shows up in many places. A simple cache returns None both for a missing key and for a key that maps to None. Does timeout=None in a configuration update mean “no timeout” or “keep the current value”? And a function that swallows a TimeoutError and returns None uses it as an error signal. Sentinel values resolve this by being a guard outside the domain of valid values, so they can never collide with an actual value.

Python itself relies heavily on sentinels, hidden in plain sight: Ellipsis skips dimensions in NumPy slicing and marks stubs, and NotImplemented means “find the implementation somewhere else”, which enables double dispatch for binary operators via methods like __rsub__. Pydantic’s MISSING distinguishes a field that was explicitly set to null from one that was never sent at all, and it has just left the experimental stage in Pydantic 2.14.

Things get tricky once you create your own sentinels. The classic MISSING = object() works perfectly at runtime but breaks type narrowing in static type checkers. Workarounds like TypeIs guards, magic Literal strings or the single-member Enum trick all come with trade-offs, and identity checks break when sentinels are copied or pickled.

Then came a plot twist. At PyConDE in April, I had to conclude that PEP 661, the proposal to standardize sentinels, was deferred. Only nine days later, the Steering Council accepted it. Since Python 3.15, sentinel is a built-in, and type checkers can narrow it just like None:

MISSING = sentinel("MISSING")

def update_email(user_id: int, email: str | None | MISSING = MISSING) -> None:
    if email is MISSING:
        return  # nothing to update
    elif email is None:
        db.delete_email(user_id)
    else:
        db.set_email(user_id, email.lower())  # narrowed to str

If you are on an older Python version, typing_extensions provides a backport. Type-checker support is still catching up, so check your type checker before relying on it.

My main takeaway: be aware of the ambiguity of None and think about what you actually want to express. Use sentinels to resolve the ambiguity, prefer the ones your library already provides, and replace the old object() and Enum hacks with the new built-in. If you are tempted to return None instead of raising an exception, my talk on exceptions and result types might also interest you.

Here are the slides from PyConDE, which still describe PEP 661 as deferred. The EuroPython recording below is the most up-to-date version of the talk and covers the new sentinel built-in.

联系我们 contact @ memedata.com