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

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 点,并返回 yielded 值。
  • 帧返回;抛出 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 可以省略。)为了与 Python 3.3 之前的版本兼容,这可以通过显式的 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]

尽管这会带来一点功能上的损失,但这种功能常常以可读性为代价,就像 lambda 相对于 def 有限制一样,生成器表达式相对于生成器函数也有限制。在许多情况下,转换为完整的生成器函数将非常简单,并且可能会提高结构清晰度。

生成器、迭代器和 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 直接冒泡来实现。

单源 Python 2/3 代码在 3.7+ 的世界中也将受益,因为像 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

最后修改时间: 2025-02-01 08:59:27 GMT