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 年 6 月 6 日

目录

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

摘要

在编程中,常见的“哨兵值”,也就是独特的占位符值,有很多用途,例如:

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

Python 具有特殊值 None,它旨在用作大多数情况下的哨兵值。但是,有时需要替代哨兵值,通常是在它需要与 None 区分开来时。这些情况十分常见,以至于多年来出现了多种实现这些哨兵值的惯用法,但又不至于常见到需要明确标准化。然而,包括标准库中的一些常见实现,都存在一些严重的缺点。

此 PEP 建议添加一个定义哨兵值的工具,用于标准库,并作为标准库的一部分公开提供。

注意:更改标准库中所有现有的哨兵值,以使用此方法进行实现,并不被认为是必要的,是否这样做取决于维护者的判断。

动机

在 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]。这表明对哨兵值的需要相当普遍,即使在标准库内部,也存在各种不同的实现方法,并且其中许多方法至少存在上述三个缺点中的一个。

讨论并没有对是否需要或需要标准实现方法,是否需要解决所提到的缺点,以及哪种实现方法比较好,达成任何明确的共识。此 PEP 的作者在 bugs.python.org 上创建了一个问题 [3],建议改进方案,但这只关注几个案例中单个有问题的方面,未能获得任何支持。

在 discuss.python.org 上创建了一个投票 [4],以更清楚地了解社区的意见。投票结果并不确定,40% 的投票者选择了“现状很好/不需要一致性”,但大多数投票者选择了一个或多个标准化解决方案。具体来说,37% 的投票者选择了“一致地使用新的专用哨兵工厂/类/元类,并在标准库中公开提供”。

由于意见如此不一致,因此创建了此 PEP 来帮助就此事做出决定。

在编写此 PEP 的过程中,在反复尝试各种选项和实现,并继续讨论的过程中,作者认为,在标准库中提供一个简单、良好的实现将是值得的,无论是在标准库本身还是在其他地方使用。

基本原理

指导所选实现的标准是

  1. 哨兵对象应按哨兵对象的预期方式运行:当使用 is 运算符进行比较时,它应始终被视为与其自身相同,但决不应该与任何其他对象相同。
  2. 创建哨兵对象应是一个简单、直接的一行代码。
  3. 应能简单地定义任意多个不同的哨兵值。
  4. 哨兵对象应具有清晰且简短的 repr。
  5. 应能为哨兵值使用清晰的类型签名。
  6. 哨兵对象应在复制和/或解序列化后正常运行。
  7. 此类哨兵应在使用 CPython 3.x 和 PyPy3 时工作,理想情况下也适用于其他 Python 实现。
  8. 实现和使用尽可能简单直接。避免让它成为学习 Python 时需要学习的另一件特殊事情。它应该易于在需要时找到和使用,并且在阅读代码时足够明显,通常不需要查看其文档。

由于 Python 标准库中存在如此之多的使用案例 [2],在标准库中提供一个实现将很有用,因为标准库无法使用其他地方提供的哨兵对象实现(例如 sentinels [5]sentinel [6] PyPI 包)。

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

规范

一个新的 Sentinel 类将被添加到一个新的 sentinels 模块中。它的初始化器将接受一个必需的参数,即哨兵对象的名称,以及两个可选参数:对象的 repr 和其模块的名称

>>> from sentinels import Sentinel
>>> NotGiven = Sentinel('NotGiven')
>>> NotGiven
<NotGiven>
>>> MISSING = Sentinel('MISSING', repr='mymodule.MISSING')
>>> MISSING
mymodule.MISSING
>>> MEGA = Sentinel('MEGA', repr='<MEGA>', module_name='mymodule')
<MEGA>

检查一个值是否为这样的哨兵应该使用 is 运算符完成,正如推荐用于 None 一样。使用 == 进行的相等性检查也将按预期方式工作,仅当将对象与其自身进行比较时才会返回 True。通常应该使用诸如 if value is MISSING: 之类的身份检查,而不是诸如 if value:if not value: 之类的布尔检查。默认情况下,哨兵实例为真值,与 None 不同。

哨兵的名称在每个模块中都是唯一的。在模块中调用 Sentinel() 时,如果该模块中已经定义了具有该名称的哨兵,则将返回具有该名称的现有哨兵。不同模块中具有相同名称的哨兵将彼此区分开来。

创建哨兵对象的副本(例如,使用 copy.copy() 或通过序列化和反序列化)将返回同一个对象。

哨兵值的类型注释应使用 Literal[<sentinel_object>]。例如

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

通常不需要提供 module_name 可选参数,因为 Sentinel() 通常能够识别调用它的模块。仅在自动识别无法按预期工作的情况下,例如在使用 Jython 或 IronPython 时,才应提供 module_name。这与 Enumnamedtuple 的设计类似。有关更多详细信息,请参阅 PEP 435

可以对 Sentinel 类进行子类化。每个子类的实例都是唯一的,即使它们使用相同的名称和模块。这允许自定义哨兵的行为,例如控制它们的真值性。

参考实现

参考实现位于专门的 GitHub 仓库中 [7]。以下是简化版本

_registry = {}

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

    def __new__(cls, name, repr=None, module_name=None):
        name = str(name)
        repr = str(repr) if repr else f'<{name.split(".")[-1]}>'
        if module_name is None:
            try:
                module_name = \
                    sys._getframe(1).f_globals.get('__name__', '__main__')
            except (AttributeError, ValueError):
                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._repr = repr
        sentinel._module_name = module_name

        return _registry.setdefault(registry_key, sentinel)

    def __repr__(self):
        return self._repr

    def __reduce__(self):
        return (
            self.__class__,
            (
                self._name,
                self._repr,
                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(repr='<NotGiven>')
class NotGivenType: pass
NotGiven = NotGivenType()

虽然这允许对装饰器进行非常简单且清晰的实现,但惯用法过于冗长、重复且难以记住。

使用类对象

由于类天生就是单例,因此将类用作哨兵值是有意义的,并允许进行简单的实现。

此方法的最简单版本是

class NotGiven: pass

要获得清晰的 repr,需要使用元类

class NotGiven(metaclass=SentinelMeta): pass

… 或者使用类装饰器

@Sentinel
class NotGiven: pass

这种使用类的做法很不寻常,可能会造成混淆。如果不加注释,代码的意图将难以理解。它还会导致这些哨兵出现一些意外且不希望有的行为,例如可调用性。

其他说明

  • 本 PEP 和初始实现是在一个专门的 GitHub 仓库 [7] 中编写的。
  • 对于在类作用域中定义的哨兵,为了避免潜在的命名冲突,应使用模块中变量的完全限定名。在默认的 repr 中只使用最后一个句点之后的名称部分。例如
    >>> class MyClass:
    ...    NotGiven = sentinel('MyClass.NotGiven')
    >>> MyClass.NotGiven
    <NotGiven>
    
  • 在函数或方法中创建哨兵时要小心,因为在同一模块中的代码创建的同名哨兵将是相同的。如果需要不同的哨兵对象,请确保使用不同的名称。
  • 在 typing-sig 邮件列表 [8] 上讨论了这些哨兵的类型,并讨论了不同的选项。

参考


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

最后修改时间:2024-08-06 12:31:33 GMT