Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 661 – 哨兵值

作者:
Tal Einat <tal at python.org>
讨论至:
Discourse 帖子
状态:
推迟
类型:
标准跟踪
创建日期:
2021年6月6日
发布历史:
2021年5月20日, 2021年6月6日

目录

TL;DR: 请参阅规范参考实现

摘要

独特的占位符值,通常称为“哨兵值”,在编程中很常见。它们有许多用途,例如:

  • 函数参数的默认值,当未提供值时
    def foo(value=None):
        ...
    
  • 当找不到或不可用时,函数的返回值
    >>> "abc".find("d")
    -1
    
  • 缺失数据,例如关系数据库中的 NULL 或电子表格中的“N/A”(“不可用”)

Python 有特殊值 None,它在大多数情况下被设计为用作此类哨兵值。然而,有时需要一个替代的哨兵值,通常是在需要与 None 区分开来时,因为 None 在该上下文中是一个有效值。此类情况足够常见,以至于多年来出现了几种实现此类哨兵的惯用法,但又不够常见,以至于没有明确的标准化需求。然而,常见的实现,包括 stdlib 中的一些实现,存在几个显著的缺点。

本 PEP 提议添加一个用于定义哨兵值的工具,用于 stdlib 并在 stdlib 中公开可用。

注意:将 stdlib 中所有现有哨兵改为以这种方式实现被认为是不必要的,是否这样做取决于维护者的酌情决定。

动机

2021年5月,python-dev 邮件列表[1]上提出了一个关于如何更好地实现 traceback.print_exception 的哨兵值的问题。现有实现使用了以下常见惯用法:

_sentinel = object()

然而,这个对象的 repr 不具信息性且过于冗长,导致函数签名过长且难以阅读。

>>> help(traceback.print_exception)
Help on function print_exception in module traceback:

print_exception(exc, /, value=<object object at
0x000002825DF09650>, tb=<object object at 0x000002825DF09650>,
limit=None, file=None, chain=True)

此外,讨论中还提到了许多现有哨兵的另外两个缺点:

  1. 有些没有独特的类型,因此无法为以这些哨兵作为默认值的函数定义清晰的类型签名。
  2. 由于创建了单独的实例,因此在使用 is 进行比较时会失败,导致它们在复制或反序列化后行为异常。

在随后的讨论中,Victor Stinner 提供了一份 Python 标准库中当前使用的哨兵值列表[2]。这表明哨兵的需求相当普遍,即使在 stdlib 内部也使用了各种实现方法,并且其中许多至少存在上述三个缺点之一。

讨论并未就是否需要或期望标准实现方法、所提及的缺点是否显著以及哪种实现方法会很好达成任何明确共识。本 PEP 的作者在 bugs.python.org 上创建了一个问题(现在是 GitHub 问题[3]),提出了改进选项,但这只关注了少数几个案例中的一个问题方面,并且未能获得任何支持。

在 discuss.python.org 上创建了一项民意调查[4],以更清楚地了解社区的意见。经过近两周、进一步的讨论和 39 票投票,民意调查结果并不确定。40% 的人投票支持“现状很好/不需要在此方面保持一致”,但大多数投票者投票支持一种或多种标准化解决方案。具体而言,37% 的投票者选择了“一致使用一个新的、专用的哨兵工厂/类/元类,并在 stdlib 中公开可用”。

鉴于意见如此混杂,创建了本 PEP 以促进就此主题做出决定。

在撰写本 PEP 期间,作者在各种选项和实现上进行迭代并继续讨论,得出的结论是,在标准库中提供一个简单、良好的实现是值得的,无论是用于标准库本身还是其他地方。

基本原理

指导所选实现的标准是:

  1. 哨兵对象应按哨兵对象的预期行为:当使用 is 运算符进行比较时,它应始终被认为与其自身相同,但从不与任何其他对象相同。
  2. 创建哨兵对象应该是一个简单直接的单行代码。
  3. 应简单地定义所需数量的 distinct 哨兵值。
  4. 哨兵对象应具有清晰简洁的 repr。
  5. 应可能为哨兵使用清晰的类型签名。
  6. 哨兵对象在复制和/或反序列化后应表现正确。
  7. 此类哨兵应在使用 CPython 3.x 和 PyPy3 时工作,并且理想情况下也应与其他 Python 实现一起工作。
  8. 在实现中,尤其是在使用中,尽可能简单直接。避免这成为学习 Python 时要学习的又一件特殊事物。在需要时应易于查找和使用,并且在阅读代码时足够明显,通常不会觉得需要查阅其文档。

鉴于 Python 标准库中[2]有如此多的用途,在标准库中实现将非常有用,因为 stdlib 不能使用其他地方可用的哨兵对象实现(例如 PyPI 包中的 sentinels[5]sentinel[6])。

在研究了现有惯用法和实现,并尝试了许多不同的可能实现之后,编写了一个满足所有这些标准的实现(参见参考实现)。

规范

一个新的 Sentinel 类将被添加到新的 sentinellib 模块中。

>>> from sentinellib import Sentinel
>>> MISSING = Sentinel('MISSING')
>>> MISSING
MISSING

检查一个值是否是哨兵值,*应该*使用 is 运算符,这与推荐 None 的方式相同。使用 == 进行相等性检查也将按预期工作,仅当对象与自身比较时才返回 True。身份检查,例如 if value is MISSING: 通常应优于布尔检查,例如 if value:if not value:

哨兵实例是“truthy”的,即布尔评估将产生 True。这与任意类的默认值以及 Ellipsis 的布尔值并行。这与 None 不同,None 是“falsy”的。

哨兵的名称在每个模块中都是唯一的。当在已定义该名称哨兵的模块中调用 Sentinel() 时,将返回该名称的现有哨兵。在不同模块中定义的同名哨兵将彼此不同。

创建哨兵对象的副本,例如使用 copy.copy() 或通过 pickle 和 unpickle,将返回相同的对象。

Sentinel() 也将接受一个可选参数 module_name。通常不需要提供此参数,因为 Sentinel() 通常能够识别其被调用的模块。module_name 仅在自动识别未按预期工作的不寻常情况下才应提供,例如在使用 Jython 或 IronPython 时。这与 Enumnamedtuple 的设计并行。有关更多详细信息,请参阅PEP 435

为了避免支持子类化带来的更大复杂性,Sentinel 类可能不允许子类化。

哨兵对象的排序比较是未定义的。

类型标注

为了在类型化的 Python 代码中使哨兵的使用清晰简单,我们建议修改类型系统,为哨兵对象添加一个特殊情况。

哨兵对象可以在类型表达式中使用,表示它们自身。这类似于现有类型系统中 None 的处理方式。例如:

from sentinels import Sentinel

MISSING = Sentinel('MISSING')

def foo(value: int | MISSING = MISSING) -> int:
    ...

更正式地说,类型检查器应识别形式为 NAME = Sentinel('NAME') 的哨兵创建,将其视为创建了一个新的哨兵对象。如果传递给 Sentinel 构造函数的名称与对象被分配的名称不匹配,类型检查器应发出错误。

使用此语法定义的哨兵可在类型表达式中使用。它们表示一个完全静态类型,该类型只有一个成员,即哨兵对象本身。

类型检查器应支持使用 isis not 运算符缩小涉及哨兵的联合类型

from sentinels import Sentinel
from typing import assert_type

MISSING = Sentinel('MISSING')

def foo(value: int | MISSING) -> None:
    if value is MISSING:
        assert_type(value, MISSING)
    else:
        assert_type(value, int)

为了支持在类型表达式中使用,Sentinel 类的运行时实现应具有 __or____ror__ 方法,返回 typing.Union 对象。

向后兼容性

此提案不应影响向后兼容性。

如何教授此内容

新标准库模块和功能的正常文档类型,即 doc-strings、模块文档和“新增功能”部分中的一节,应已足够。

安全隐患

此提案不应涉及安全隐患。

参考实现

参考实现可在专门的 GitHub 仓库[7]中找到。简化版本如下:

_registry = {}

class Sentinel:
    """Unique sentinel values."""

    def __new__(cls, name, module_name=None):
        name = str(name)

        if module_name is None:
            module_name = sys._getframemodulename(1)
            if module_name is None:
                module_name = __name__

        registry_key = f'{module_name}-{name}'

        sentinel = _registry.get(registry_key, None)
        if sentinel is not None:
            return sentinel

        sentinel = super().__new__(cls)
        sentinel._name = name
        sentinel._module_name = module_name

        return _registry.setdefault(registry_key, sentinel)

    def __repr__(self):
        return self._name

    def __reduce__(self):
        return (
            self.__class__,
            (
                self._name,
                self._module_name,
            ),
        )

被拒绝的想法

使用 NotGiven = object()

这存在理由部分中提及的所有缺点。

添加一个新的哨兵值,例如 MISSINGSentinel

由于此类值可在各种地方用于各种事物,因此人们无法始终确信它在某些用例中永远不是一个有效值。另一方面,专用且独特的哨兵值可以自信地使用,而无需考虑潜在的边缘情况。

此外,能够为哨兵值提供有意义的名称和 repr,并使其特定于使用的上下文,这很有用。

最后,在民意调查[4]中,这是一个非常不受欢迎的选择,只有 12% 的人投票支持它。

使用现有的 Ellipsis 哨兵值

这并非 Ellipsis 的原始预期用途,尽管它越来越常用于定义空的类或函数块,而不是使用 pass

此外,类似于潜在的新单个哨兵值,Ellipsis 无法像专用、独特的值那样在所有情况下都被自信地使用。

使用单值枚举

建议的惯用法是:

class NotGivenType(Enum):
    NotGiven = 'NotGiven'
NotGiven = NotGivenType.NotGiven

除了过度重复之外,repr 也过长:<NotGivenType.NotGiven: 'NotGiven'>。可以定义一个更短的 repr,但这需要更多一点代码和更多的重复。

最后,在民意调查[4]的九个选项中,这个选项是最不受欢迎的,是唯一一个没有获得任何票数的选项。

哨兵类装饰器

建议的惯用法是:

@sentinel
class NotGivenType: pass
NotGiven = NotGivenType()

虽然这允许装饰器实现得非常简单明了,但这种惯用法过于冗长、重复且难以记忆。

使用类对象

由于类本质上是单例,因此将类用作哨兵值是合理的,并且允许简单的实现。

最简单的版本是:

class NotGiven: pass

为了有一个清晰的 repr,需要使用一个元类

class NotGiven(metaclass=SentinelMeta): pass

... 或者一个类装饰器

@Sentinel
class NotGiven: pass

以这种方式使用类是不寻常的,可能会引起混淆。没有注释,代码的意图将难以理解。它还会导致此类哨兵具有一些意外和不希望的行为,例如可调用。

允许定制 repr

这是为了允许在不改变现有哨兵值的 repr 的情况下使用它。然而,最终由于不值得增加复杂性而被放弃。

在类型注解中使用 typing.Literal

这在讨论中被几个人提出,也是本 PEP 最初的方案。然而,有人指出这可能会导致潜在的混淆,例如 Literal["MISSING"] 指的是字符串值 "MISSING",而不是对哨兵值 MISSING 的前向引用。在讨论中也经常建议使用裸名。这遵循了 None 所建立的先例和众所周知的模式,并且具有不需要导入且更短的优点。

附加说明

  • 本 PEP 和初始实现都在一个专门的 GitHub 仓库[7]中起草。
  • 对于在类作用域中定义的哨兵,为避免潜在的名称冲突,应使用模块中变量的完全限定名。完整名称将用作 repr。例如:
    >>> class MyClass:
    ...    NotGiven = sentinel('MyClass.NotGiven')
    >>> MyClass.NotGiven
    MyClass.NotGiven
    
  • 在函数或方法中创建哨兵时应小心,因为由同一模块中的代码创建的同名哨兵将是相同的。如果需要不同的哨兵对象,请务必使用不同的名称。
  • 哨兵的“truthiness”,即它们的布尔值,没有单一的理想值。有时布尔值为 True 很有用,有时为 False 很有用。在 Python 的内置哨兵中,None 求值为 False,而 Ellipsis(又名 ...)求值为 True。在讨论中也提到了根据需要设置此值的愿望。
  • NotImplemented 的布尔值为 True,但自 Python 3.9 起已弃用(这样做会生成弃用警告)。此弃用是由于 NotImplemented 的特定问题,如 bpo-35712[8] 中所述。
  • 要定义多个相关的哨兵值,可能在它们之间具有定义的排序,则应改用 Enum 或类似的东西。
  • 在 typing-sig 邮件列表[9]上有一个关于这些哨兵类型化的讨论,其中讨论了不同的选项。

未解决的问题

  • 添加新的 stdlib 模块是正确的方法吗? 我找不到任何现有模块看起来是此功能的逻辑位置。然而,添加新的 stdlib 模块应谨慎进行,所以即使不是完美匹配,选择现有模块是否会更好?

脚注


来源:https://github.com/python/peps/blob/main/peps/pep-0661.rst

最后修改:2025-04-10 18:12:41 GMT