为 class 拥有的 objects 编写上下文管理器的 Pythonic 方式

Pythonic way to compose context managers for objects owned by a class

某些任务通常需要多个 objects,其中有要显式释放的资源 - 例如,两个文件;当任务是使用嵌套 with 块的函数的本地任务时,或者 - 甚至更好 - 具有多个 with_item 子句的单个 with 块时,这很容易完成:

with open('in.txt', 'r') as i, open('out.txt', 'w') as o:
    # do stuff

OTOH,我仍然很难理解当这样的 object 不只是函数作用域的本地,而是属于 class 实例时它应该如何工作 - 换句话说,上下文管理器如何组合。

理想情况下,我想做类似的事情:

class Foo:
    def __init__(self, in_file_name, out_file_name):
        self.i = WITH(open(in_file_name, 'r'))
        self.o = WITH(open(out_file_name, 'w'))

并让 Foo 本身变成处理 io 的上下文管理器,这样当我做

with Foo('in.txt', 'out.txt') as f:
    # do stuff

self.iself.o 会像您期望的那样自动处理。

我修改了一些东西,比如:

class Foo:
    def __init__(self, in_file_name, out_file_name):
        self.i = open(in_file_name, 'r').__enter__()
        self.o = open(out_file_name, 'w').__enter__()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.i.__exit__(*exc)
        self.o.__exit__(*exc)

但是对于构造函数中发生的异常,它既冗长又不安全。搜索了一段时间后,我找到了 this 2015 blog post,它使用 contextlib.ExitStack 获得了与我所追求的非常相似的东西:

class Foo(contextlib.ExitStack):
    def __init__(self, in_file_name, out_file_name):
        super().__init__()
        self.in_file_name = in_file_name
        self.out_file_name = out_file_name

    def __enter__(self):
        super().__enter__()
        self.i = self.enter_context(open(self.in_file_name, 'r')
        self.o = self.enter_context(open(self.out_file_name, 'w')
        return self

这非常令人满意,但我对以下事实感到困惑:


一些额外的上下文:我主要在 C++ 中工作,block-scope 和object-scope 这个问题的案例,因为这种清理是在析构函数内部实现的(想想 __del__,但确定性地调用),并且析构函数(即使没有明确定义)会自动调用子的析构函数object秒。所以两者:

{
    std::ifstream i("in.txt");
    std::ofstream o("out.txt");
    // do stuff
}

struct Foo {
    std::ifstream i;
    std::ofstream o;

    Foo(const char *in_file_name, const char *out_file_name) 
        : i(in_file_name), o(out_file_name) {}
}

{
    Foo f("in.txt", "out.txt");
}

按照您通常的需要自动执行所有清理工作。

我正在 Python 中寻找类似的行为,但再次重申,恐怕我只是想应用来自 C++ 的模式,而潜在的问题有一个完全不同的解决方案我想不到。


所以,总结一下:什么是 Pythonic 解决问题的 object 谁拥有需要清理的 object 变成 context-manager 本身,正确调用其 children?

__enter__/__exit__

我认为使用助手更好:

from contextlib import ExitStack, contextmanager

class Foo:
    def __init__(self, i, o):
        self.i = i
        self.o = o

@contextmanager
def multiopen(i, o):
    with ExitStack() as stack:
        i = stack.enter_context(open(i))
        o = stack.enter_context(open(o))
        yield Foo(i, o)

用法接近原生open:

with multiopen(i_name, o_name) as foo:
    pass

好吧,如果你确实想处理文件处理程序,最简单的解决方案就是将文件处理程序直接传递给你的 class 而不是文件名。

with open(f1, 'r') as f1, open(f2, 'w') as f2:
   with MyClass(f1, f2) as my_obj:
       ...

如果您不需要自定义 __exit__ 功能,您甚至可以跳过 nested with。

如果您真的想将文件名传递给 __init__,您的问题可以这样解决:

class MyClass:
     input, output = None, None

     def __init__(self, input, output):
         try:
             self.input = open(input, 'r')
             self.output = open(output, 'w')
         except BaseException as exc:
             self.__exit___(type(exc), exc, exc.__traceback__)
             raise

     def __enter__(self):
         return self

     def __exit__(self, *args):
            self.input and self.input.close()
            self.output and self.output.close()
        # My custom __exit__ code

所以,这真的取决于您的任务,python 有很多选择可以使用。归根结底 - pythonic 方法是让你的 api 简单。

我认为 contextlib.ExitStack 是 Pythonic 和规范的,它是解决这个问题的合适方法。这个答案的其余部分试图展示我用来得出这个结论的链接和我的思考过程:

原始Python增强请求

https://bugs.python.org/issue13585

最初的想法 + 实现是作为 Python 标准库增强而提出的,同时具有推理和示例代码。 Raymond Hettinger 和 Eric Snow 等核心开发人员对此进行了详细讨论。关于这个问题的讨论清楚地表明了最初的想法成长为适用于标准库的东西,并且是 Pythonic。线程的尝试总结是:

nikratio最初提出:

I'd like to propose addding the CleanupManager class described in http://article.gmane.org/gmane.comp.python.ideas/12447 to the contextlib module. The idea is to add a general-purpose context manager to manage (python or non-python) resources that don't come with their own context manager

这引起了 rhettinger 的关注:

So far, there has been zero demand for this and I've not seen code like it being used in the wild. AFAICT, it is not demonstrably better than a straight-forward try/finally.

作为对此的回应,就是否需要这样做进行了长时间的讨论,导致 post 像这样来自 ncoghlan 的人:

TestCase.setUp() and TestCase.tearDown() were amongst the precursors to__enter__() and exit(). addCleanUp() fills exactly the same role here - and I've seen plenty of positive feedback directed towards Michael for that addition to the unittest API... ...Custom context managers are typically a bad idea in these circumstances, because they make readability worse (relying on people to understand what the context manager does). A standard library based solution, on the other hand, offers the best of both worlds: - code becomes easier to write correctly and to audit for correctness (for all the reasons with statements were added in the first place) - the idiom will eventually become familiar to all Python users... ...I can take this up on python-dev if you want, but I hope to persuade you that the desire is there...

然后稍后来自 ncoghlan 的消息:

My earlier descriptions here aren't really adequate - as soon as I started putting contextlib2 together, this CleanupManager idea quickly morphed into ContextStack [1], which is a far more powerful tool for manipulating context managers in a way that doesn't necessarily correspond with lexical scoping in the source code.

示例/食谱/博客 postExitStack 标准库源代码本身有几个示例和配方,您可以在添加此功能的合并修订版中看到:https://hg.python.org/cpython/rev/8ef66c73b1e1

还有一篇博客 post 来自最初的问题创建者 (Nikolaus Rath / nikratio),它以令人信服的方式描述了为什么 ContextStack 是一个很好的模式,还提供了一些使用示例:https://www.rath.org/on-the-beauty-of-pythons-exitstack.html

你的第二个例子是最直接的方法 Python(即大多数 Pythonic)。但是,您的示例仍然存在错误。如果 期间引发异常 第二个 open(),

self.i = self.enter_context(open(self.in_file_name, 'r')
self.o = self.enter_context(open(self.out_file_name, 'w') # <<< HERE

那么self.i不会在您期望的时候发布,因为 除非 Foo.__enter__() 成功,否则 Foo.__exit__() 不会被调用 returns。要解决此问题,请将每个上下文调用包装在 try-except 会在异常发生时调用 Foo.__exit__()

import contextlib
import sys

class Foo(contextlib.ExitStack):

    def __init__(self, in_file_name, out_file_name):
        super().__init__()
        self.in_file_name = in_file_name
        self.out_file_name = out_file_name

    def __enter__(self):
        super().__enter__()

        try:
            # Initialize sub-context objects that could raise exceptions here.
            self.i = self.enter_context(open(self.in_file_name, 'r'))
            self.o = self.enter_context(open(self.out_file_name, 'w'))

        except:
            if not self.__exit__(*sys.exc_info()):
                raise

        return self

如@cpburnz 所述,您的最后一个示例是最好的,但如果第二次打开失败,则确实包含错误。标准库文档中描述了如何避免此错误。我们可以很容易地改编 ExitStack documentation and the example for ResourceManager from 29.6.2.4 Cleaning up in an __enter__ implementation 中的代码片段,得到 MultiResourceManager class:

from contextlib import contextmanager, ExitStack
class MultiResourceManager(ExitStack):
    def __init__(self, resources, acquire_resource, release_resource,
            check_resource_ok=None):
        super().__init__()
        self.acquire_resource = acquire_resource
        self.release_resource = release_resource
        if check_resource_ok is None:
            def check_resource_ok(resource):
                return True
        self.check_resource_ok = check_resource_ok
        self.resources = resources
        self.wrappers = []

    @contextmanager
    def _cleanup_on_error(self):
        with ExitStack() as stack:
            stack.push(self)
            yield
            # The validation check passed and didn't raise an exception
            # Accordingly, we want to keep the resource, and pass it
            # back to our caller
            stack.pop_all()

    def enter_context(self, resource):
        wrapped = super().enter_context(self.acquire_resource(resource))
        if not self.check_resource_ok(wrapped):
            msg = "Failed validation for {!r}"
            raise RuntimeError(msg.format(resource))
        return wrapped

    def __enter__(self):
        with self._cleanup_on_error():
            self.wrappers = [self.enter_context(r) for r in self.resources]
        return self.wrappers

    # NB: ExitStack.__exit__ is already correct

现在你的 Foo() class 是微不足道的:

import io
class Foo(MultiResourceManager):
    def __init__(self, *paths):
        super().__init__(paths, io.FileIO, io.FileIO.close)

这很好,因为我们不需要任何 try-except 块——您可能只是在一开始就使用 ContextManagers 来摆脱它们!

然后你可以像你想的那样使用它(注意MultiResourceManager.__enter__ returns传递的acquire_resource()给出的对象列表):

if __name__ == '__main__':
    open('/tmp/a', 'w').close()
    open('/tmp/b', 'w').close()

    with Foo('/tmp/a', '/tmp/b') as (f1, f2):
        print('opened {0} and {1}'.format(f1.name, f2.name))

我们可以将 io.FileIO 替换为 debug_file,如下面的代码片段所示:

    class debug_file(io.FileIO):
        def __enter__(self):
            print('{0}: enter'.format(self.name))
            return super().__enter__()
        def __exit__(self, *exc_info):
            print('{0}: exit'.format(self.name))
            return super().__exit__(*exc_info)

然后我们看到:

/tmp/a: enter
/tmp/b: enter
opened /tmp/a and /tmp/b
/tmp/b: exit
/tmp/a: exit

如果我们在循环之前添加 import os; os.unlink('/tmp/b'),我们会看到:

/tmp/a: enter
/tmp/a: exit
Traceback (most recent call last):
  File "t.py", line 58, in <module>
    with Foo('/tmp/a', '/tmp/b') as (f1, f2):
  File "t.py", line 46, in __enter__
    self.wrappers = [self.enter_context(r) for r in self.resources]
  File "t.py", line 46, in <listcomp>
    self.wrappers = [self.enter_context(r) for r in self.resources]
  File "t.py", line 38, in enter_context
    wrapped = super().enter_context(self.acquire_resource(resource))
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/b'

可以看到/tmp/a正确关闭了。