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

Python 增强提案

PEP 479 – 更改生成器内部 StopIteration 处理

作者:
Chris Angelico <rosuav at gmail.com>,Guido van Rossum <guido at python.org>
状态:
最终
类型:
标准跟踪
创建:
2014年11月15日
Python 版本:
3.5
历史记录:
2014年11月15日,2014年11月19日,2014年12月5日

目录

摘要

本 PEP 提出对生成器的更改:当在生成器内部引发 StopIteration 时,它将被替换为 RuntimeError。(更准确地说,当异常即将从生成器的堆栈帧中冒泡出来时,就会发生这种情况。)由于此更改与向后不兼容,因此该功能最初是使用 __future__ 语句引入的。

接受

本 PEP 已于 11 月 22 日获得 BDFL 的批准。由于从初稿到批准的时间非常短,因此在批准后提出的主要异议都得到了仔细考虑,并已反映在下面的“备选方案”部分中。但是,没有任何讨论改变了 BDFL 的想法,并且 PEP 的批准现已最终确定。(仍然欢迎提供澄清编辑的建议 - 与 IETF RFC 不同,PEP 的文本在批准后不会一成不变,尽管核心设计/计划/规范在批准后不应更改。)

基本原理

生成器和 StopIteration 之间的交互目前有些令人惊讶,并且可能会隐藏一些模糊的错误。意外的异常不应该导致行为发生细微变化,而应该导致出现易于调试的详细跟踪信息。目前,在生成器函数内部意外引发的 StopIteration 将被循环结构(驱动生成器)解释为迭代的结束。

该提案的主要目标是简化以下情况下的调试:在某个位置(可能在几个堆栈帧深处)出现不受保护的 next() 调用,它引发了 StopIteration 并导致由生成器控制的迭代静默终止。(而当引发其他异常时,会打印出跟踪信息,从而查明问题的原因。)

这与 PEP 380yield from 结构结合使用时尤其有害,因为它破坏了可以将子生成器从生成器中分解出来的抽象。该 PEP 指出了此限制,但指出“这些 [的] 用例很少甚至不存在”。不幸的是,虽然有意使用的情况很少见,但很容易意外地遇到这些情况

import contextlib

@contextlib.contextmanager
def transaction():
    print('begin')
    try:
        yield from do_it()
    except:
        print('rollback')
        raise
    else:
        print('commit')

def do_it():
    print('Refactored initial setup')
    yield # Body of with-statement is executed here
    print('Refactored finalization of successful transaction')

def gene():
    for i in range(2):
        with transaction():
            yield i
            # return
            raise StopIteration  # This is wrong
        print('Should not be reached')

for i in gene():
    print('main: i =', i)

这里,将 do_it 分解成子生成器引入了细微的错误:如果包装的块引发了 StopIteration,在当前行为下,此异常将被上下文管理器吞没;更糟糕的是,最终化会被静默跳过!类似的有问题的行为发生在 asyncio 协程引发 StopIteration 时,导致它静默终止,或者当 next 用于获取迭代器的第一个结果时,该迭代器意外地为空,例如

# using the same context manager as above
import pathlib

with transaction():
    print('commit file {}'.format(
        # I can never remember what the README extension is
        next(pathlib.Path('/some/dir').glob('README*'))))

在这两种情况下,yield from 的重构抽象在客户端代码中存在错误时就会失效。

此外,该提案减少了列表推导式和生成器表达式之间的差异,防止出现诸如引发本次讨论的意外情况 [2]。从现在开始,如果任一形式产生结果,则以下语句将产生相同的结果

a = list(F(x) for x in xs if P(x))
a = [F(x) for x in xs if P(x)]

在当前状态下,可以编写一个函数 F(x) 或谓词 P(x),使其导致第一种形式产生(截断的)结果,而第二种形式引发异常(即 StopIteration)。通过提议的更改,两种形式都会在此处引发异常(尽管在第一种情况下为 RuntimeError,在第二种情况下为 StopIteration)。

最后,该提案还澄清了如何终止生成器:正确的方法是 return,而不是 raise StopIteration

作为额外的好处,上述更改使生成器函数更符合常规函数。如果希望将作为生成器提供的代码段转换为其他内容,通常可以通过非常简单地将每个 yield 替换为对 print()list.append() 的调用来实现;但是,如果代码中存在任何裸 next() 调用,则必须注意它们。如果最初编写的代码没有依赖于 StopIteration 来终止函数,则转换将更容易。

背景信息

当由于 __next__()(或 send()throw())调用而(重新)启动生成器帧时,可能会发生以下三种结果之一

  • 到达一个 yield 点,并返回 yield 的值。
  • 从帧中返回;引发 StopIteration
  • 引发异常,该异常冒泡出来。

在后两种情况下,帧将被放弃(并且生成器对象的 gi_frame 属性将设置为 None)。

提案

如果 StopIteration 即将从生成器帧中冒泡出来,它将被替换为 RuntimeError,这会导致 next() 调用(调用了生成器)失败,并将该异常传递出去。从那时起,它就像任何其他旧异常一样。 [3]

这会影响上面列出的第三种结果,而不会改变任何其他效果。此外,它仅在引发的异常为 StopIteration(或其子类)时才会影响此结果。

请注意,提议的替换发生在异常即将从帧中冒泡出来的点,即在任何可能影响它的 exceptfinally 块退出后。从帧返回时引发的 StopIteration 不会受到影响(重点是 StopIteration 表示生成器“正常”终止,即它没有引发异常)。

一个微妙的问题是,如果调用方在捕获 RuntimeError 后再次调用生成器对象的 __next__() 方法会发生什么。答案是从此时起它将引发 StopIteration - 行为与生成器引发任何其他异常时相同。

该提案的另一个逻辑结果:如果有人使用 g.throw(StopIteration)StopIteration 异常抛入生成器,如果生成器没有捕获它(它可以使用 yield 周围的 try/except 来捕获),它将被转换为 RuntimeError

在过渡阶段,必须使用以下方法在每个模块中启用新功能

from __future__ import generator_stop

在该指令的影响下构造的任何生成器函数都将在其代码对象上设置 REPLACE_STOPITERATION 标志,并且设置了该标志的生成器将按照本提案的行为进行。一旦该功能成为标准,该标志可能会被删除;代码不应检查生成器的此标志。

已创建了一个概念验证补丁以方便测试。 [4]

对现有代码的影响

此更改将影响依赖于 StopIteration 冒泡的现有代码。 groupby 的纯 Python 参考实现 [5] 目前带有注释“在 StopIteration 上退出”,其中预期异常将传播然后被处理。这将是不寻常的,但并非未知,并且此类结构将失败。其他示例比比皆是,例如 [6][7]

(Alyssa Coghlan 评论:“””如果想要提取一个终止生成器的辅助函数,则必须执行“return yield from helper()”而不是仅仅“helper()”。“””)

还有一些生成器表达式的示例,它们依赖于表达式、目标或谓词(而不是 for 循环本身中隐含的 __next__() 调用)引发的 StopIteration

编写向后和向前兼容的代码

除了引发 StopIteration 以退出生成器表达式的技巧之外,很容易编写在旧版 Python 版本下和新语义下都能很好工作的代码。

这是通过将生成器主体中预期出现 StopIteration 的位置(例如,裸 next() 调用或某些情况下预期会引发 StopIteration 的辅助函数)放在一个 try/except 结构中来实现的,当引发 StopIteration 时,该结构会返回。 try/except 结构应该直接出现在生成器函数中;在本身不是生成器的辅助函数中这样做是无效的。如果 raise StopIteration 直接出现在生成器中,只需将其替换为 return

中断示例

显式引发 StopIteration 的生成器通常可以改为简单地返回。这将与所有现有的 Python 版本兼容,并且不会受到 __future__ 的影响。以下是标准库中的一些示例。

Lib/ipaddress.py

if other == self:
    raise StopIteration

变为

if other == self:
    return

在某些情况下,这可以与 yield from 结合使用以简化代码,例如 Lib/difflib.py

if context is None:
    while True:
        yield next(line_pair_iterator)

变为

if context is None:
    yield from line_pair_iterator
    return

(为了严格等效的转换,return 是必要的,尽管在这个特定文件中,没有其他代码,并且可以省略 return。)为了与 3.3 之前的 Python 版本兼容,可以使用显式的 for 循环编写

if context is None:
    for line in line_pair_iterator:
        yield line
    return

更复杂的迭代模式需要显式的 try/except 结构。例如,一个假设的解析器,如下所示

def parser(f):
    while True:
        data = next(f)
        while True:
            line = next(f)
            if line == "- end -": break
            data += line
        yield data

需要重写为

def parser(f):
    while True:
        try:
            data = next(f)
            while True:
                line = next(f)
                if line == "- end -": break
                data += line
            yield data
        except StopIteration:
            return

或者可能

def parser(f):
    for data in f:
        while True:
            line = next(f)
            if line == "- end -": break
            data += line
        yield data

后一种形式通过声称使用 for 循环迭代文件来模糊迭代,但随后还在循环体中从同一个迭代器获取更多数据。但是,它确实清楚地区分了“正常”终止(StopIteration 而不是初始行)和“异常”终止(在内部循环中未能找到结束标记,这现在将引发 RuntimeError)。

StopIteration 的这种效果已被用于缩短生成器表达式,从而创建了一种 takewhile 的形式

def stop():
    raise StopIteration
print(list(x for x in range(10) if x < 5 or stop()))
# prints [0, 1, 2, 3, 4]

在当前提案下,这种非局部流控制形式不受支持,必须重写为语句形式

def gen():
    for x in range(10):
        if x >= 5: return
        yield x
print(list(gen()))
# prints [0, 1, 2, 3, 4]

虽然这会损失一点功能,但这种功能往往是以可读性为代价的,就像 lambdadef 相比有限制一样,生成器表达式与生成器函数相比也有限制。在许多情况下,转换为完整的生成器函数将非常容易,并且可以提高结构清晰度。

生成器、迭代器和 StopIteration 的解释

该提案不会改变生成器和迭代器之间的关系:生成器对象仍然是迭代器,但并非所有迭代器都是生成器。生成器有一些迭代器没有的额外方法,例如 sendthrow。所有这些都没有改变。生成器用户没有任何变化——只有生成器函数的作者可能需要学习一些新东西。(这包括依赖于通过条件中引发的 StopIteration 提前终止迭代的生成器表达式的作者。)

迭代器是一个具有 __next__ 方法的对象。与许多其他特殊方法一样,它可以返回一个值,或者引发一个特定的异常——在本例中为 StopIteration——以表示它没有值要返回。在这方面,它类似于 __getattr__(可以引发 AttributeError)、__getitem__(可以引发 KeyError)等等。可以编写一个迭代器的辅助函数来遵循相同的协议;例如

def helper(x, y):
    if x > y: return 1 / (x - y)
    raise StopIteration

def __next__(self):
    if self.a: return helper(self.b, self.c)
    return helper(self.d, self.e)

两种信号都将被传递:返回的值将被返回,异常会冒泡。辅助函数被编写为匹配调用函数的协议。

生成器函数是一个包含 yield 表达式的函数。每次(重新)启动时,它可以产生一个值,或者返回(包括“从末尾掉下来”)。还可以编写生成器的辅助函数,但它也必须遵循生成器协议

def helper(x, y):
    if x > y: yield 1 / (x - y)

def gen(self):
    if self.a: return (yield from helper(self.b, self.c))
    return (yield from helper(self.d, self.e))

在这两种情况下,任何意外异常都会冒泡。由于生成器和迭代器的性质,生成器内部的意外 StopIteration 将转换为 RuntimeError,但除此之外,所有异常都将正常传播。

过渡计划

  • Python 3.5:在 __future__ 导入下启用新的语义;如果 StopIteration 从未在 __future__ 导入下的生成器中冒泡,则发出静默弃用警告。
  • Python 3.6:非静默弃用警告。
  • Python 3.7:在任何地方启用新的语义。

备选方案

引发除 RuntimeError 之外的异常

与其使用泛型 RuntimeError,不如考虑引发一个新的异常类型 UnexpectedStopIteration。这有一个缺点,即隐式鼓励捕获它;正确的操作是捕获原始的 StopIteration,而不是链式异常。

提供在返回时引发的特定异常

Alyssa (Nick) Coghlan 建议了一种向生成器提供特定 StopIteration 实例的方法;如果引发了任何其他 StopIteration 实例,则这是一个错误,但如果引发了那个特定的实例,则生成器已正确完成。这个子提案已被撤回,转而采用更好的方案,但保留以供参考。

使返回触发的 StopIteration 明显化

对于某些情况,一个更简单且完全向后兼容的解决方案可能就足够了:当生成器返回时,它不是引发 StopIteration,而是引发 StopIteration 的特定子类(GeneratorReturn),然后可以检测到它。如果不是那个子类,它就是一个逃逸异常,而不是一个 return 语句。

这个备选提案的灵感来自 Alyssa 的观察 [8],即如果一个 asyncio 协程 [9] 意外地引发了 StopIteration,它目前会静默终止,这可能会给开发人员带来难以调试的谜团。主要提案将此类事故转换为清晰可区分的 RuntimeError 异常,但如果该提案被拒绝,则此备选提案将使 asyncio 能够区分 return 语句和意外引发的 StopIteration 异常。

在上面列出的三个结果中,有两个结果发生了变化

  • 如果到达一个 yield 点,则该值显然仍然会被返回。
  • 如果从该帧返回,则引发 GeneratorReturn(而不是 StopIteration)。
  • 如果将引发 GeneratorReturn 的实例,则改为引发 StopIteration 的实例。任何其他异常都会正常冒泡。

在第三种情况下,StopIteration 将具有原始 GeneratorReturnvalue,并在其 __cause__ 中引用原始异常。如果未捕获,这将清楚地显示异常的链。

这种替代方案不会影响生成器表达式和列表推导式之间的差异,但允许生成器感知代码(例如 contextlibasyncio 模块)可靠地区分上面列出的第二个和第三个结果。

但是,一旦存在依赖于 GeneratorReturnStopIteration 之间这种区别的代码,调用另一个生成器并依赖于后者的 StopIteration 冒泡的生成器仍然可能出错,具体取决于对这两种异常类型之间区别的使用方式。

在 next() 内部转换异常

Mark Shannon 建议 [10]next() 而不是在生成器函数的边界处解决此问题。通过让 next() 捕获 StopIteration 并改为引发 ValueError,可以防止所有意外的 StopIteration 冒泡;但是,向后不兼容问题远比当前提案严重得多,因为现在每个 next() 调用都需要重写以防止 ValueError 而不是 StopIteration——更不用说无法编写可在多个 Python 版本上可靠工作的代码块。(使用专用的异常类型,也许是 ValueError 的子类,可以帮助解决这个问题;但是,仍然需要重写所有代码。)

请注意,调用 next(it, default) 会捕获 StopIteration 并替换给定的默认值;此功能通常用于避免 try/except 块。

子提案:装饰器显式请求当前行为

Alyssa Coghlan 建议 [11] 可以通过装饰器来支持当前行为所需的情况

from itertools import allow_implicit_stop

@allow_implicit_stop
def my_generator():
    ...
    yield next(it)
    ...

这在语义上等效于

def my_generator():
    try:
        ...
        yield next(it)
        ...
    except StopIteration
        return

但速度更快,因为它可以通过简单地允许 StopIteration 直接冒泡来实现。

在 3.7+ 的世界中,单一来源的 Python 2/3 代码也将从中受益,因为像 six 和 python-future 这样的库可以简单地定义自己的“allow_implicit_stop”版本,该版本在 3.5+ 中引用新的内置函数,并在其他版本中实现为标识函数。

然而,由于所需的实现复杂性、产生的持续兼容性问题、装饰器效果的微妙性,以及它会鼓励“快速修复”解决方案(即简单地将装饰器应用于所有生成器,而不是正确修复有问题的代码)这一事实,该子提案已被拒绝。 [12]

批评

非官方且未经证实的统计数据表明,这种情况很少发生,甚至从未发生过。 [13] 确实存在依赖于当前行为的代码(例如 [3][6][7]),并且有人担心,为了实现微不足道的收益而进行不必要的代码改动。

Steven D’Aprano 在 comp.lang.python 上发起了一项非正式调查 [14];在撰写本文时,仅收到两条回复:一条赞成更改列表推导式以匹配生成器表达式(!), 另一条赞成本 PEP 的主要提案。

现有的模型已被比作其他每种异常具有特殊含义的情况中固有的完全可接受的问题。例如,在 __getitem__ 方法内部出现的意外 KeyError 将被解释为失败,而不是允许其冒泡。但是,这里存在差异。特殊方法使用 return 表示正常,使用 raise 表示异常;生成器使用 yield 表示数据,使用 return 表示异常状态。这使得显式引发 StopIteration 完全冗余,并且可能令人意外。如果其他特殊方法有专用的关键字来区分它们的返回路径,那么它们也可以将意外异常转换为 RuntimeError;它们无法做到这一点的事实不应该阻止生成器这样做。

为什么不修复所有 __next__() 方法?

在实现常规的 __next__() 方法时,指示迭代结束的唯一方法是引发 StopIteration。因此,在此处捕获 StopIteration 并将其转换为 RuntimeError 将违背目的。这提醒了生成器函数的特殊状态:在生成器函数中,引发 StopIteration 是冗余的,因为迭代可以通过简单的 return 终止。

参考文献


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

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