Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

PEP 377 – 允许 __enter__() 方法跳过语句体

作者:
Alyssa Coghlan <ncoghlan at gmail.com>
状态:
已拒绝
类型:
标准跟踪
创建:
2009-03-08
Python 版本:
2.7, 3.1
历史记录:
2009-03-08

目录

摘要

本 PEP 提出了一种向后兼容的机制,允许 __enter__() 方法跳过关联的 with 语句的主体。目前缺乏此功能意味着 contextlib.contextmanager 装饰器无法实现其规范,即通过将其移动到具有适当位置的 yield 的生成器函数中,将任意代码转换为上下文管理器。这的一个症状是 contextlib.nested 目前会在编写相应的嵌套 with 语句不会出现 [1] 的情况下引发 RuntimeError

提议的更改是引入一个新的流程控制异常 SkipStatement,如果 __enter__() 引发此异常,则跳过 with 语句主体的执行。

PEP 拒绝

Guido 拒绝了此 PEP [4],因为它在没有成比例地提高表达性和正确性的情况下,增加了过多的复杂性。在没有令人信服的使用案例需要本 PEP 提出的更复杂语义的情况下,现有行为被认为是可以接受的。

提议的更改

with 语句的语义将更改为在对 __enter__() 的调用周围包含一个新的 try/except/else 块。如果 __enter__() 方法引发了 SkipStatement,则 with 语句的主体部分(现在位于 else 子句中)将不会执行。为了避免在这种情况下使任何 as 子句中的名称未绑定,一个新的 StatementSkipped 单例(类似于现有的 NotImplemented 单例)将被分配给出现在 as 子句中的所有名称。

with 语句的组件保持与 PEP 343 中所述相同。

with EXPR as VAR:
    BLOCK

修改后,with 语句的语义将如下所示

mgr = (EXPR)
exit = mgr.__exit__  # Not calling it yet
try:
    value = mgr.__enter__()
except SkipStatement:
    VAR = StatementSkipped
    # Only if "as VAR" is present and
    # VAR is a single name
    # If VAR is a tuple of names, then StatementSkipped
    # will be assigned to each name in the tuple
else:
    exc = True
    try:
        try:
            VAR = value  # Only if "as VAR" is present
            BLOCK
        except:
            # The exceptional case is handled here
            exc = False
            if not exit(*sys.exc_info()):
                raise
            # The exception is swallowed if exit() returns true
    finally:
        # The normal and non-local-goto cases are handled here
        if exc:
            exit(None, None, None)

with 语句语义中进行上述更改后,contextlib.contextmanager() 将被修改为在底层生成器不产生 yield 时引发 SkipStatement 而不是 RuntimeError

更改的理由

目前,一些看似无害的上下文管理器在执行时可能会引发 RuntimeError。当上下文管理器的 __enter__() 方法遇到与上下文管理器相对应代码的已编写版本将跳过现在是 with 语句主体的代码的情况时,就会发生这种情况。由于 __enter__() 方法没有可用的机制来向解释器发出此信号,因此它被迫引发一个异常,该异常不仅跳过 with 语句的主体,而且还跳过所有代码直到最近的异常处理程序。这违反了 with 语句的设计目标之一,该目标是能够通过将任意公共异常处理代码放入生成器函数并将代码的可变部分替换为 yield 语句,将其分解到单个上下文管理器中。

具体来说,如果 cmB().__enter__() 引发一个异常,然后 cmA().__exit__() 处理并抑制该异常,则以下示例的行为会有所不同。

with cmA():
  with cmB():
    do_stuff()
# This will resume here without executing "do_stuff()"

@contextlib.contextmanager
def combined():
  with cmA():
    with cmB():
      yield

with combined():
  do_stuff()
# This will raise a RuntimeError complaining that the context
# manager's underlying generator didn't yield

with contextlib.nested(cmA(), cmB()):
  do_stuff()
# This will raise the same RuntimeError as the contextmanager()
# example (unsurprising, given that the nested() implementation
# uses contextmanager())

# The following class based version shows that the issue isn't
# specific to contextlib.contextmanager() (it also shows how
# much simpler it is to write context managers as generators
# instead of as classes!)
class CM(object):
  def __init__(self):
    self.cmA = None
    self.cmB = None

  def __enter__(self):
    if self.cmA is not None:
      raise RuntimeError("Can't re-use this CM")
    self.cmA = cmA()
    self.cmA.__enter__()
    try:
      self.cmB = cmB()
      self.cmB.__enter__()
    except:
      self.cmA.__exit__(*sys.exc_info())
      # Can't suppress in __enter__(), so must raise
      raise

  def __exit__(self, *args):
    suppress = False
    try:
      if self.cmB is not None:
        suppress = self.cmB.__exit__(*args)
    except:
      suppress = self.cmA.__exit__(*sys.exc_info()):
      if not suppress:
        # Exception has changed, so reraise explicitly
        raise
    else:
      if suppress:
        # cmB already suppressed the exception,
        # so don't pass it to cmA
        suppress = self.cmA.__exit__(None, None, None):
      else:
        suppress = self.cmA.__exit__(*args):
    return suppress

在提议的语义更改到位后,基于 contextlib 的上述示例将“正常工作”,但基于类的版本需要进行少量调整以利用新的语义。

class CM(object):
  def __init__(self):
    self.cmA = None
    self.cmB = None

  def __enter__(self):
    if self.cmA is not None:
      raise RuntimeError("Can't re-use this CM")
    self.cmA = cmA()
    self.cmA.__enter__()
    try:
      self.cmB = cmB()
      self.cmB.__enter__()
    except:
      if self.cmA.__exit__(*sys.exc_info()):
        # Suppress the exception, but don't run
        # the body of the with statement either
        raise SkipStatement
      raise

  def __exit__(self, *args):
    suppress = False
    try:
      if self.cmB is not None:
        suppress = self.cmB.__exit__(*args)
    except:
      suppress = self.cmA.__exit__(*sys.exc_info()):
      if not suppress:
        # Exception has changed, so reraise explicitly
        raise
    else:
      if suppress:
        # cmB already suppressed the exception,
        # so don't pass it to cmA
        suppress = self.cmA.__exit__(None, None, None):
      else:
        suppress = self.cmA.__exit__(*args):
    return suppress

目前有一个初步建议 [3],在 with 语句中添加类似导入的语法,以允许在单个 with 语句中包含多个上下文管理器,而无需使用 contextlib.nested。在这种情况下,编译器可以选择在 AST 级别简单地发出多个 with 语句,从而允许准确地再现实际嵌套 with 语句的语义。但是,这样的更改会突出而不是缓解当前 PEP 旨在解决的问题:无法使用 contextlib.contextmanager 来可靠地分解此类 with 语句,因为它们将表现出与上述示例中的 combined() 上下文管理器完全相同的语义差异。

性能影响

实现新的语义需要将对 __enter____exit__ 方法的引用存储在临时变量中而不是堆栈上。这导致与 Python 2.6/3.1 相比,with 语句速度略有下降。但是,实现自定义的 SETUP_WITH 操作码将抵消两种方法之间的任何差异(以及通过消除十几个不必要的 eval 循环来显着提高速度)。

参考实现

修补程序附加到问题 5251 [1]。该修补程序仅使用现有的操作码(即没有 SETUP_WITH)。

致谢

James William Pye 既提出了问题,也提出了本 PEP 中描述的解决方案的基本轮廓。

参考文献


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

上次修改:2023-10-11 12:05:51 GMT