将可迭代对象包含在生成器表达式中会更改上下文的清理顺序

Enclosing an iterable in a generator expression changes the order that contexts are cleaned up

提问人:Adam Tuft 提问时间:10/19/2023 最后编辑:Adam Tuft 更新时间:10/20/2023 访问量:68

问:

我有一个 Python 应用程序,它有两个嵌套块,其中一个发生在对象的方法中。我观察到,当引发异常时,将可迭代对象包装在生成器表达式中会改变这些块的最终顺序,我对此感到非常惊讶。with__iter__with

我正在使用 python 3.11.5,但在 3.8.17 中观察到相同的行为。

下面是一个演示器,它再现了观察到的行为以及预期的行为:

from contextlib import closing, contextmanager


class Reader:
    def close(self):
        print(f"CLOSE: {self.__class__.__name__}")

    @contextmanager
    def get_resource(self):
        try:
            yield 42
        finally:
            print(f"CLOSE: {self.__class__.__name__} releases resource")


class Container:
    def __init__(self, reader: Reader):
        self._reader = reader

    def __iter__(self):
        with self._reader.get_resource() as resource:
            for x in range(10):
                yield x


def my_code():
    with closing(Reader()) as reader:
        container = Container(reader)
        container_gen = (x for x in container)
        for thing in container_gen:
            assert False


def expected():
    with closing(Reader()) as reader:
        container = Container(reader)
        for thing in container:
            assert False


print(">>> What my code does:")
try:
    my_code()
except AssertionError:
    print("handle exception")

print()

print(">>> The context handling I expected:")
try:
    expected()
except AssertionError:
    print("handle exception")

这是我从这段代码中得到的输出:

>>> What my code does:
CLOSE: Reader
handle exception
CLOSE: Reader releases resource

>>> The context handling I expected:
CLOSE: Reader releases resource
CLOSE: Reader
handle exception

似乎将 包装在生成器表达式中会导致在异常处理完成后清理其方法中的块,尽管它嵌套在另一个块中,该块在引发异常时被清理。Containerwith__iter__with

为什么将可迭代对象包装在生成器表达式中会改变其方法中块在异常处理期间的最终确定方式?with__iter__

编辑:

多亏了@user2357112的回答,我看到省略绑定到生成器表达式的局部变量可以得到我期望的上下文处理:

def my_code_without_local():
    with closing(Reader()) as reader:
        container = Container(reader)
        for thing in (x for x in container):
            assert False

这将给出以下输出:

CLOSE: Reader releases resource
CLOSE: Reader
handle exception

这种行为对我来说真的很惊讶,因为我期望命名生成器表达式中的活动 -block 以与 中的活动 -block 相同的方式进行清理,而不是说它会徘徊,只有在本地名称超出范围时才会被清理。withcontainer_genwithmy_codecontainer_gen

Python 异常 生成器 可迭代

评论


答:

1赞 user2357112 10/19/2023 #1

你依赖于一个生成器,这些很尴尬,因为清理很容易像这样延迟。具体来说,我说的是你的方法,你把它写成一个生成器。不是 genexp - 我稍后会谈到。with__iter__

如果未完全循环生成器,则块清理仅在生成器为 d 时运行。像大多数人一样,您从未明确关闭过生成器。你可能从未想过。它唯一被关闭的时间是在生成器的方法中,当 Python 确定生成器无法访问时,就会调用该方法。__iter__withclose__del__

使用 时,对生成器的唯一引用是循环对其迭代器的内部引用。该引用在块退出之前死亡,并且由于 CPython 具有引用计数,因此解释器会立即检测到生成器无法访问,因此它会调用 ,从而触发您预期的清理。(在非引用计数实现(如 PyPy)上,此清理不会那么及时。expected__iter__forwith closing(Reader()) as reader:__del__

使用 ,您的生成器可以从 genexp 创建的生成器访问,该生成器可通过局部变量访问。该局部变量可通过异常回溯访问,因此在块完成且异常对象死亡之前,它不会被清除。(即使您的块没有子句,也是如此 - 在块期间,异常及其回溯仍可通过 sys.exc_info() 获得。my_code__iter__container_genexceptexceptasexcept

您的发电机最终的寿命比您预期的要长得多。由于您期望的清理仅由生成器的方法触发,因此清理延迟的时间也一样长。__del__

评论

0赞 Adam Tuft 10/20/2023
感谢您的详细回复!如果我理解正确的话,你是说 in 生成器,当包装在 genexp 中时,处理方式与 in 不同,因为 genexp 中的块只有在 genexp 关闭时才会最终确定,而我的 genexp 直到异常处理后才会关闭,因为有一个可以通过回溯访问的引用。这是对的吗?withwithmy_codewith
1赞 user2357112 10/20/2023
@AdamTuft:如果你完全循环了你的生成器,执行会自然地退出并触发清理。由于迭代永远不会完成,因此只有在生成器关闭时才会触发清理,并且由于回溯延长了变量的生存期,清理会严重延迟。withwithcontainer_gen
0赞 Adam Tuft 10/20/2023
也许我遗漏了一些东西,但为什么清理仅在命名的 genexp 关闭时触发,而不是由 genexp 迭代期间引发的异常触发?正如你所解释的,当 genexp 没有被命名时,会观察到预期的行为(如我在问题中添加的示例),这让我感到非常惊讶。with
1赞 user2357112 10/20/2023
@AdamTuft:发生异常时,生成器的堆栈帧不在堆栈上。异常永远不会通过生成器传播。它不像 Ruby .当 Ruby 调用一个块,向堆栈添加一个帧时,Python 会暂停一个生成器,从堆栈中删除其堆栈帧。yieldyieldyield
1赞 user2357112 10/20/2023
@AdamTuft:没有。暂停的发电机不参与异常展开过程。生成器只是在 中更早地死亡,因为没有局部变量让它保持活动状态。expected