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

Python 增强提案

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() N/A

当前提案是此过程中的一个步骤,并考虑了在 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 年且具有类似的功能并非理想,但这是一个选项。

现在让我们检查一下当前提案的局限性。它将 2 参数 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,其argcount2(用于计算self),并且没有可变参数,或者
  • 它是一个PyCFunction,具有METH_O标志。

请注意,可以通过专门化来缓解内省带来的任何性能成本,因此如果出于某种原因我们需要使其比这更复杂,这将不是问题。

向后兼容性

所有以前有效的上下文管理器都将继续以相同的方式工作,因为解释器将在它们可以接受三个参数时始终用三个参数调用它们。以前可能有一些上下文管理器无效,因为它们的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__方法。


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

上次修改时间:2023-10-10 15:15:34 GMT