PEP 661 – 哨兵值
- 作者:
- Tal Einat <tal at python.org>
- 讨论至:
- Discourse 线程
- 状态:
- 草稿
- 类型:
- 标准跟踪
- 创建:
- 2021 年 6 月 6 日
- 发布历史:
- 2021 年 6 月 6 日
摘要
在编程中,常见的“哨兵值”,也就是独特的占位符值,有很多用途,例如:
- 函数参数的默认值,用于未提供值的情况
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)
此外,讨论中还提到了许多现有哨兵值的另外两个缺点
- 没有明确的类型,因此无法为具有哨兵值作为默认值的函数定义清晰的类型签名
- 由于创建了单独的实例,因此复制或解序列化后会产生不正确的行为,从而导致使用
is
进行的比较失败
在随后的讨论中,Victor Stinner 提供了 Python 标准库中当前使用的哨兵值的列表 [2]。这表明对哨兵值的需要相当普遍,即使在标准库内部,也存在各种不同的实现方法,并且其中许多方法至少存在上述三个缺点中的一个。
讨论并没有对是否需要或需要标准实现方法,是否需要解决所提到的缺点,以及哪种实现方法比较好,达成任何明确的共识。此 PEP 的作者在 bugs.python.org 上创建了一个问题 [3],建议改进方案,但这只关注几个案例中单个有问题的方面,未能获得任何支持。
在 discuss.python.org 上创建了一个投票 [4],以更清楚地了解社区的意见。投票结果并不确定,40% 的投票者选择了“现状很好/不需要一致性”,但大多数投票者选择了一个或多个标准化解决方案。具体来说,37% 的投票者选择了“一致地使用新的专用哨兵工厂/类/元类,并在标准库中公开提供”。
由于意见如此不一致,因此创建了此 PEP 来帮助就此事做出决定。
在编写此 PEP 的过程中,在反复尝试各种选项和实现,并继续讨论的过程中,作者认为,在标准库中提供一个简单、良好的实现将是值得的,无论是在标准库本身还是在其他地方使用。
基本原理
指导所选实现的标准是
- 哨兵对象应按哨兵对象的预期方式运行:当使用
is
运算符进行比较时,它应始终被视为与其自身相同,但决不应该与任何其他对象相同。 - 创建哨兵对象应是一个简单、直接的一行代码。
- 应能简单地定义任意多个不同的哨兵值。
- 哨兵对象应具有清晰且简短的 repr。
- 应能为哨兵值使用清晰的类型签名。
- 哨兵对象应在复制和/或解序列化后正常运行。
- 此类哨兵应在使用 CPython 3.x 和 PyPy3 时工作,理想情况下也适用于其他 Python 实现。
- 实现和使用尽可能简单直接。避免让它成为学习 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
。这与 Enum
和 namedtuple
的设计类似。有关更多详细信息,请参阅 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()
这存在于 基本原理 部分中提到的所有缺点。
添加一个新的哨兵值,例如 MISSING
或 Sentinel
由于这样的值可以在各种地方用于各种目的,因此无法始终确信它在某些用例中永远不会是有效的值。另一方面,可以使用专用且不同的哨兵值,而不必考虑潜在的边缘情况,从而增强信心。
此外,能够为哨兵值提供有意义的名称和 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
这种使用类的做法很不寻常,可能会造成混淆。如果不加注释,代码的意图将难以理解。它还会导致这些哨兵出现一些意外且不希望有的行为,例如可调用性。
定义一个推荐的“标准”惯用法,不提供实现
大多数现有的常用习语都存在显著的缺点。到目前为止,还没有找到既清晰简洁又避免这些缺点的习语。
此外,在关于此主题的投票 [4] 中,推荐使用习语的选项并不受欢迎,得票率最高的选项仅获得 25% 的投票者支持。
其他说明
- 本 PEP 和初始实现是在一个专门的 GitHub 仓库 [7] 中编写的。
- 对于在类作用域中定义的哨兵,为了避免潜在的命名冲突,应使用模块中变量的完全限定名。在默认的 repr 中只使用最后一个句点之后的名称部分。例如
>>> class MyClass: ... NotGiven = sentinel('MyClass.NotGiven') >>> MyClass.NotGiven <NotGiven>
- 在函数或方法中创建哨兵时要小心,因为在同一模块中的代码创建的同名哨兵将是相同的。如果需要不同的哨兵对象,请确保使用不同的名称。
- 在 typing-sig 邮件列表 [8] 上讨论了这些哨兵的类型,并讨论了不同的选项。
参考
版权
本文件放置在公共领域或根据 CC0-1.0-Universal 许可证,以较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0661.rst
最后修改时间:2024-08-06 12:31:33 GMT