PEP 707 – __exit__ 和 __aexit__ 的简化签名
- 作者:
- Irit Katriel <irit at python.org>
- 讨论至:
- Discourse 帖子
- 状态:
- 已拒绝
- 类型:
- 标准跟踪
- 创建日期:
- 2023年2月18日
- Python 版本:
- 3.12
- 发布历史:
- 2023年3月2日
- 决议:
- Discourse 消息
拒绝通知
我们讨论了该 PEP 并决定拒绝它。我们的想法是,潜在破坏的魔力和风险不值得带来这些好处。然而,我们完全支持探索潜在的上下文管理器 v2 API 或__leave__
。
摘要
本 PEP 提议让解释器接受其 __exit__()
/ __aexit__()
方法仅接受单个异常实例的上下文管理器,同时为了向后兼容,继续支持当前的 (typ, exc, tb)
签名。
此提案是正在进行的努力的一部分,旨在从语言中移除 3 项异常表示的冗余,这是早期 Python 版本的遗留物,现在让语言用户感到困惑,同时增加了解释器的复杂性和开销。
提议的实现使用内省,该内省专为满足此用例的需求而设计。该解决方案通过仅在非模糊情况下支持新功能来确保其安全性。特别是,任何*可能*接受三个参数的签名都被假定为需要这些参数。
由于目前在 Python 中无法可靠地内省可调用对象,因此此处提出的解决方案受到限制,即只有常见的单参数可调用类型才会被识别为单参数,而一些更深奥的类型将继续使用三个参数进行调用。这个不完美的解决方案是在几种不完美的替代方案中,本着实用主义的精神选择的。我希望关于此 PEP 的讨论能够探索其他选项,并引导我们找到最佳前进方向,这很可能就是保持我们目前不完美的状态。
动机
过去,异常在 Python 的许多部分都由一个包含三个元素的元组表示:异常的类型、其值和其回溯。虽然当时这种设计有充分的理由,但现在它们不再适用,因为类型和回溯现在可以从异常实例可靠地推断出来。在过去的几年里,我们看到了一些简化异常表示的努力。
自 3.10 版本开始,在 CPython PR #70577 中,traceback
模块的函数接受上述的 3 元组,或仅接受一个异常实例作为单个参数。
在内部,解释器不再将异常表示为三元组。这在 3.11 中 针对已处理的异常被移除,并在 3.12 中 针对已引发的异常被移除。因此,一些暴露三元组的 API 现在可以被更简单的替代方案取代
传统 API | 替代方案 | |
---|---|---|
获取已处理异常 (Python) | sys.exc_info() |
sys.exception() |
获取已处理异常 (C) | PyErr_GetExcInfo() |
PyErr_GetHandledException() |
设置已处理异常 (C) | PyErr_SetExcInfo() |
PyErr_SetHandledException() |
获取引发的异常 (C) | PyErr_Fetch() |
PyErr_GetRaisedException() |
设置引发的异常 (C) | PyErr_Restore() |
PyErr_SetRaisedException() |
从 3 元组构造异常实例 (C) | PyErr_NormalizeException() |
不适用 |
当前的提案是此过程中的一个步骤,它考虑了 3 元组表示法已泄露到语言中的另一个情况的前进方向。所有这些工作的动机是双重的。
简化语言的实现
将解释器内部表示的处理异常简化为单个对象所带来的简化是显著的。以前,解释器在处理异常时需要将三个项目压入/弹出堆栈。这增加了堆栈深度(增加了缓存和寄存器的压力),并使一些字节码复杂化。将其减少到一个项目 从 ceval.c
(解释器的 eval 循环实现)中删除了大约 100 行代码,随后又移除了 POP_EXCEPT_AND_RERAISE
操作码,因为它变得足够简单,可以 被通用的堆栈操作指令取代。微基准测试显示 捕获和引发异常以及创建生成器方面有大约 10% 的加速。总而言之,消除 Python 内部的这种冗余简化了解释器并使其更快。
通过将多参数函数调用替换为单参数函数调用,还可以提高离开上下文管理器时调用 __exit__
/__aexit__
的性能。微基准测试显示,使用单参数 __exit__
进入和退出上下文管理器大约快 13%。
简化语言本身
Python 受欢迎的原因之一是它的简洁性。sys.exc_info()
三元组对于新学习者来说是神秘的,其中的冗余也让那些理解它的人感到困惑。
需要多个版本才能达到我们可以考虑弃用 sys.exc_info()
的程度。然而,我们可以相对较快地达到一个阶段,新学习者不需要了解它,或者不需要了解 3 元组表示,至少在他们维护遗留代码之前是这样。
基本原理
今天反对从语言中移除 3 元组最后剩余出现的原因是对这种更改可能带来的破坏的担忧。本 PEP 的目标是提出一种安全、渐进且对 __exit__
这种情况的更改影响最小的方法,并以此启动关于其方法签名演变选项的讨论。
在 traceback
模块的 API 中,将函数演变为混合签名相对简单和安全。函数接受一个位置参数和两个可选参数,并根据其类型解释它们。当使用哨兵作为默认值时,这是安全的。由用户程序定义的回调签名更难演变。
最安全的选项是让用户通过添加一个附加属性或赋予其不同的名称来明确指出回调期望的签名。例如,我们可以让解释器在上下文管理器上查找 __leave__
方法,如果存在,则以单个参数调用它(否则,它会查找 __exit__
并像现在一样继续)。这里提出的基于内省的替代方案旨在让用户更方便地编写新代码,因为他们只需使用单参数版本,而无需了解旧版 API。然而,如果发现内省的局限性过于严重,我们应该考虑一个显式选项。同时存在 __exit__
和 __leave__
5-10 年并具有相似功能并不理想,但这是一个选项。
现在让我们检查当前提案的局限性。它将双参数 Python 函数和 METH_O
C 函数识别为具有单参数签名,并假定其他任何函数都期望 3 个参数。显然,这种启发式方法可能会产生假阴性(它不会识别的单参数可调用对象)。以这种方式编写的上下文管理器将不起作用,当它们的 __exit__
函数以三个参数调用时,它们将继续像现在一样失败。
我相信在实践中这不是一个问题。首先,所有正常工作的代码将继续正常工作,所以这限制了新代码,而不是影响现有代码的问题。其次,__exit__
很少使用特殊的可调用类型,如果需要,它总是可以通过一个委托给可调用对象的普通方法进行封装。例如,我们可以这样写
class C:
__enter__ = lambda self: self
__exit__ = ExoticCallable()
如下
class CM:
__enter__ = lambda self: self
_exit = ExoticCallable()
__exit__ = lambda self, exc: CM._exit(exc)
在讨论本 PEP 中问题的实际影响时,值得注意的是,大多数 __exit__
函数不会对其参数执行任何操作。通常,实现上下文管理器是为了确保在退出时执行一些清理操作。__exit__
函数处理在上下文中引发的异常很少是合适的,并且它们通常被允许从 __exit__
传播到调用函数。这意味着大多数 __exit__
函数根本不访问其参数,我们在尝试评估不同解决方案对 Python 用户群的影响时应该考虑到这一点。
规范
上下文管理器的 __exit__
/__aexit__
方法可以具有单参数签名,在这种情况下,它由解释器调用,参数等于异常实例或 None
>>> class C:
... def __enter__(self):
... return self
... def __exit__(self, exc):
... print(f'__exit__ called with: {exc!r}')
...
>>> with C():
... pass
...
__exit__ called with: None
>>> with C():
... 1/0
...
__exit__ called with: ZeroDivisionError('division by zero')
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero
如果 __exit__
/__aexit__
具有任何其他签名,它将像现在一样,以 3 元组 (typ, exc, tb)
调用
>>> class C:
... def __enter__(self):
... return self
... def __exit__(self, *exc):
... print(f'__exit__ called with: {exc!r}')
...
>>> with C():
... pass
...
__exit__ called with: (None, None, None)
>>> with C():
... 1/0
...
__exit__ called with: (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x1039cb570>)
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero
这些 __exit__
方法也将以 3 元组调用
def __exit__(self, typ, *exc):
pass
def __exit__(self, typ, exc, tb):
pass
在 CPython PR #101995 中提供了一个参考实现。
当解释器到达上下文管理器作用域的末尾,并即将调用相关的 __exit__
或 __aexit__
函数时,它会对该函数进行内省,以确定它是单参数版本还是传统的 3 参数版本。在草案 PR 中,此内省由 is_legacy___exit__
函数执行
static int is_legacy___exit__(PyObject *exit_func) {
if (PyMethod_Check(exit_func)) {
PyObject *func = PyMethod_GET_FUNCTION(exit_func);
if (PyFunction_Check(func)) {
PyCodeObject *code = (PyCodeObject*)PyFunction_GetCode(func);
if (code->co_argcount == 2 && !(code->co_flags & CO_VARARGS)) {
/* Python method that expects self + one more arg */
return false;
}
}
}
else if (PyCFunction_Check(exit_func)) {
if (PyCFunction_GET_FLAGS(exit_func) == METH_O) {
/* C function declared as single-arg */
return false;
}
}
return true;
}
值得注意的是,这不是一个通用的内省函数,而是专门为我们的用例设计的。我们知道 exit_func
是上下文管理器类的属性(取自提供 __enter__
的对象的类型),它通常是一个函数。此外,为了使其有用,我们需要识别足够的单参数形式,但不必是所有形式。对向后兼容性至关重要的是,我们绝不会将传统的 exit_func
错误地识别为单参数形式。因此,例如,__exit__(self, *args)
和 __exit__(self, exc_type, *args)
都具有传统形式,尽管它们*可能*以一个参数调用。
总而言之,exit_func
将在以下情况下以单个参数调用:
- 它是一个
PyMethod
,其argcount
为2
(用于计算self
),且没有可变参数,或 - 它是一个带有
METH_O
标志的PyCFunction
。
请注意,内省的任何性能开销都可以通过特化来缓解,因此,如果我们需要出于某种原因使其比这更复杂,也不会成为问题。
向后兼容性
所有以前能正常工作的上下文管理器将继续以相同的方式工作,因为只要它们可以接受三个参数,解释器就会用三个参数调用它们。可能存在一些以前无法工作的上下文管理器,因为它们的 exit_func
期望一个参数,因此对 __exit__
的调用会导致 TypeError
异常,而现在调用将成功。这理论上可能会改变现有代码的行为,但在实践中不太可能成为问题。
在某些情况下,当库尝试将其上下文管理器从多参数签名迁移到单参数签名时,会遇到向后兼容性问题。如果 __exit__
或 __aexit__
由解释器的 eval 循环之外的任何代码调用,则不会自动发生自省。例如,当上下文管理器被子类化并且其 __exit__
方法直接从派生类的 __exit__
中调用时,就会发生这种情况。此类上下文管理器需要与其用户一起迁移到单参数版本,并且可以选择提供并行 API,而不是破坏现有 API。或者,超类可以保持签名 __exit__(self, *args)
,并支持一个和三个参数。由于大多数上下文管理器不使用 __exit__
参数的值,而只是允许异常继续传播,因此这很可能成为常见方法。
安全隐患
我不知道有任何。
如何教授此内容
语言教程将介绍单参数版本,上下文管理器的文档将包含关于 __exit__
和 __aexit__
传统签名的章节。
参考实现
CPython PR #101995 实现了本 PEP 的提案。
被拒绝的想法
支持 __leave__(self, exc)
曾考虑通过一个新名称(例如 __leave__
)来支持方法,并使用新签名。这基本上让程序员明确声明他们打算使用的签名,并避免了对内省的需求。
此想法的不同变体包括不同程度的魔术,可以帮助自动化 __leave__
和 __exit__
之间的等价性。例如,Mark Shannon 建议,当类上定义了 __exit__
和 __leave__
中的一个时,类型构造函数将为它们各自添加一个默认实现。这个默认实现充当一个跳板,调用用户的函数。这将使继承无缝工作,并使特定类从 __exit__
迁移到 __leave__
。解释器只需调用 __leave__
,然后它会在必要时调用 __exit__
。
尽管此建议比当前提案有几个优点,但它有两个缺点。首先是它在数据模型中添加了一个新的双下划线名称,我们最终会得到两个含义相同但签名略有不同的双下划线。其次是它需要将每个 __exit__
迁移到 __leave__
,而通过自省,无需更改许多不访问其参数的 __exit__(*arg)
方法。虽然它不像 grep 查找 __exit__
那么简单,但可以编写一个 AST 访问器来检测可以接受多个参数并访问这些参数的 __exit__
方法。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0707.rst