PEP 661 – 哨兵值
- 作者:
- Tal Einat <tal at python.org>
- 讨论至:
- Discourse 帖子
- 状态:
- 推迟
- 类型:
- 标准跟踪
- 创建日期:
- 2021年6月6日
- 发布历史:
- 2021年5月20日, 2021年6月6日
摘要
独特的占位符值,通常称为“哨兵值”,在编程中很常见。它们有许多用途,例如:
- 函数参数的默认值,当未提供值时
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)
此外,讨论中还提到了许多现有哨兵的另外两个缺点:
- 有些没有独特的类型,因此无法为以这些哨兵作为默认值的函数定义清晰的类型签名。
- 由于创建了单独的实例,因此在使用
is进行比较时会失败,导致它们在复制或反序列化后行为异常。
在随后的讨论中,Victor Stinner 提供了一份 Python 标准库中当前使用的哨兵值列表[2]。这表明哨兵的需求相当普遍,即使在 stdlib 内部也使用了各种实现方法,并且其中许多至少存在上述三个缺点之一。
讨论并未就是否需要或期望标准实现方法、所提及的缺点是否显著以及哪种实现方法会很好达成任何明确共识。本 PEP 的作者在 bugs.python.org 上创建了一个问题(现在是 GitHub 问题[3]),提出了改进选项,但这只关注了少数几个案例中的一个问题方面,并且未能获得任何支持。
在 discuss.python.org 上创建了一项民意调查[4],以更清楚地了解社区的意见。经过近两周、进一步的讨论和 39 票投票,民意调查结果并不确定。40% 的人投票支持“现状很好/不需要在此方面保持一致”,但大多数投票者投票支持一种或多种标准化解决方案。具体而言,37% 的投票者选择了“一致使用一个新的、专用的哨兵工厂/类/元类,并在 stdlib 中公开可用”。
鉴于意见如此混杂,创建了本 PEP 以促进就此主题做出决定。
在撰写本 PEP 期间,作者在各种选项和实现上进行迭代并继续讨论,得出的结论是,在标准库中提供一个简单、良好的实现是值得的,无论是用于标准库本身还是其他地方。
基本原理
指导所选实现的标准是:
- 哨兵对象应按哨兵对象的预期行为:当使用
is运算符进行比较时,它应始终被认为与其自身相同,但从不与任何其他对象相同。 - 创建哨兵对象应该是一个简单直接的单行代码。
- 应简单地定义所需数量的 distinct 哨兵值。
- 哨兵对象应具有清晰简洁的 repr。
- 应可能为哨兵使用清晰的类型签名。
- 哨兵对象在复制和/或反序列化后应表现正确。
- 此类哨兵应在使用 CPython 3.x 和 PyPy3 时工作,并且理想情况下也应与其他 Python 实现一起工作。
- 在实现中,尤其是在使用中,尽可能简单直接。避免这成为学习 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 时。这与 Enum 和 namedtuple 的设计并行。有关更多详细信息,请参阅PEP 435。
为了避免支持子类化带来的更大复杂性,Sentinel 类可能不允许子类化。
哨兵对象的排序比较是未定义的。
类型标注
为了在类型化的 Python 代码中使哨兵的使用清晰简单,我们建议修改类型系统,为哨兵对象添加一个特殊情况。
哨兵对象可以在类型表达式中使用,表示它们自身。这类似于现有类型系统中 None 的处理方式。例如:
from sentinels import Sentinel
MISSING = Sentinel('MISSING')
def foo(value: int | MISSING = MISSING) -> int:
...
更正式地说,类型检查器应识别形式为 NAME = Sentinel('NAME') 的哨兵创建,将其视为创建了一个新的哨兵对象。如果传递给 Sentinel 构造函数的名称与对象被分配的名称不匹配,类型检查器应发出错误。
使用此语法定义的哨兵可在类型表达式中使用。它们表示一个完全静态类型,该类型只有一个成员,即哨兵对象本身。
类型检查器应支持使用 is 和 is 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()
这存在理由部分中提及的所有缺点。
添加一个新的哨兵值,例如 MISSING 或 Sentinel
由于此类值可在各种地方用于各种事物,因此人们无法始终确信它在某些用例中永远不是一个有效值。另一方面,专用且独特的哨兵值可以自信地使用,而无需考虑潜在的边缘情况。
此外,能够为哨兵值提供有意义的名称和 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
以这种方式使用类是不寻常的,可能会引起混淆。没有注释,代码的意图将难以理解。它还会导致此类哨兵具有一些意外和不希望的行为,例如可调用。
定义一个推荐的“标准”惯用法,不提供实现
大多数现有的常见惯用法都有显著的缺点。到目前为止,还没有发现一种既清晰又简洁同时又能避免这些缺点的惯用法。
此外,在关于此主题的民意调查[4]中,推荐惯用法的选项不受欢迎,投票最高的选项仅获得了 25% 的选票。
允许定制 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 模块应谨慎进行,所以即使不是完美匹配,选择现有模块是否会更好?
脚注
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0661.rst