跳转到内容

Python 编程/上下文管理器

来自维基教科书,开放书籍,开放世界


编程中的一个基本问题是资源管理资源是指任何有限供应的东西,特别是文件句柄、网络套接字、锁等,一个关键问题是确保这些资源在获取后被释放。如果它们没有被释放,你将遇到资源泄漏,系统可能会变慢或崩溃。更一般地说,除了释放资源之外,你可能希望始终执行清理操作。

Python 在with 语句中提供了用于此目的的特殊语法,该语句会自动管理封装在上下文管理器类型中的资源,或更一般地执行围绕代码块的启动和清理操作。你应该始终使用 with 语句进行资源管理。许多内置的上下文管理器类型,包括 File 的基本示例,并且编写自己的上下文管理器类型很容易。代码并不难,但概念有点微妙,很容易出错。

基本资源管理

[编辑 | 编辑源代码]

基本资源管理使用显式的 open()...close() 函数对,就像在基本的文件打开和关闭中一样。不要这样做,因为我们会解释原因

f = open(filename)
# ...
f.close()

这段简单代码的关键问题是,如果存在早期返回,无论是因为 return 语句还是异常(可能由被调用代码引发),代码都会失败。为了解决这个问题,确保在退出代码块时调用清理代码,可以使用 try...finally 子句

f = open(filename)
try:
    # ...
finally:
    f.close()

但是,这仍然需要手动释放资源,这可能会被遗忘,并且释放代码与获取代码相隔较远。可以通过使用 with 自动释放资源,它之所以有效是因为 File 是一种上下文管理器类型

with open(filename) as f:
    # ...

这会将 open(filename) 的值赋值给 f(这一点很微妙,并且在不同的上下文管理器之间有所不同),然后在退出代码块时自动释放资源,在本例中调用 f.close()

技术细节

[编辑 | 编辑源代码]

较新的对象是上下文管理器(正式的上下文管理器类型:子类型,因为它们实现了上下文管理器接口,该接口包含 __enter__()__exit__()),因此可以轻松地在 with 语句中使用(请参阅With Statement Context Managers)。

对于具有 close 方法但没有 __exit__() 的较旧文件式对象,可以使用 @contextlib.closing 装饰器。如果你需要自己编写,这非常容易,特别是使用 @contextlib.contextmanager 装饰器。[1]

上下文管理器通过在进入 with 上下文时调用 __enter__() 来工作,并将返回值绑定到 as 的目标,并在退出上下文时调用 __exit__()。在退出期间处理异常有一些微妙之处,但对于简单使用,你可以忽略它。

更微妙的是,__init__() 在创建对象时被调用,而 __enter__() 在进入 with 上下文时被调用。

__init__()/__enter__() 的区别对于区分一次性、可重用和可重入的上下文管理器非常重要。对于在 with 子句中实例化对象的常见用例,它不是一个有意义的区别,如下所示

with A() as a:
    ...

...在这种情况下,任何一次性上下文管理器都可以。

但是,总的来说,这是一个区别,特别是在区分可重用的上下文管理器和它所管理的资源时,如这里

a_cm = A()
with a_cm as a:
   ...

将资源获取放在 __enter__() 而不是 __init__() 中将提供一个可重用的上下文管理器

值得注意的是,File() 对象在 __init__() 中执行初始化,然后在进入上下文时只返回自身,如 def __enter__(): return self。如果你希望 as 的目标绑定到一个对象(并允许你使用像 open 这样的工厂作为 with 子句的来源),这样做是可以的,但如果你希望它绑定到其他东西,特别是句柄(文件名或文件句柄/文件描述符),你希望将实际对象包装在一个单独的上下文管理器中。例如

@contextmanager
def FileName(*args, **kwargs):
   with File(*args, **kwargs) as f:
       yield f.name

对于简单使用,你不需要做任何 __init__() 代码,只需要将 __enter__()/__exit__() 配对即可。对于更复杂的使用,你可以拥有可重入的上下文管理器,但这对于简单使用来说是不必要的。

注意事项

[编辑 | 编辑源代码]

try...finally

[编辑 | 编辑源代码]

请注意,try...finally 子句必要的,因为 @contextlib.contextmanager 不会捕获 yield 之后引发的任何异常,但在 __exit__()不是必要的,因为即使引发异常,也会调用 __exit__()

上下文,而非作用域

[编辑 | 编辑源代码]

上下文管理器的术语是经过精心选择的,特别是在与“作用域”形成对比时。Python 中的局部变量具有函数作用域,因此 with 语句的目标(如果有的话)在代码块退出后仍然可见,尽管 __exit__() 已经对上下文管理器(with 语句的参数)调用,因此通常没有用或无效。这是一个技术点,但值得区分 with 语句上下文与整个函数作用域。

生成器

[编辑 | 编辑源代码]

持有或使用资源的生成器有点棘手。

请注意,在 with 语句中创建生成器并在代码块之外使用它们不会起作用,因为生成器具有延迟计算,因此当它们被计算时,资源已经被释放。这在使用文件时最容易看到,例如以下生成器表达式用于将文件转换为行列表,并剥离行尾字符

with open(filename) as f:
    lines = (line.rstrip('\n') for line in f)

lines 随后被使用时,可以强制计算 list(lines),这将以ValueError: I/O operation on closed file 失败。这是因为文件在 with 语句结束时被关闭,但行直到生成器被计算时才会被读取。

最简单的解决方案是避免生成器,而是使用列表,例如列表推导。在这种情况(读取文件)下,这通常是合适的,因为人们希望最大限度地减少系统调用,并且一次性读取整个文件(除非文件非常大)

with open(filename) as f:
    lines = [line.rstrip('\n') for line in f]

如果希望在生成器中使用资源,则必须将资源保存在生成器中,如以下生成器函数所示

def stripped_lines(filename):
    with open(filename) as f:
        for line in f:
            yield line.rstrip('\n')

嵌套清楚地表明,文件在遍历它时一直保持打开状态。

要释放资源,必须使用 generator.close(), 显式关闭生成器,就像处理其他持有资源的对象一样(这是一种处置模式)。这可以通过将生成器变成上下文管理器来实现自动化,使用 @contextlib.closing, 如下所示

from contextlib import closing

with closing(stripped_lines(filename)) as lines:
    # ...

资源获取即初始化是一种资源管理的替代形式,特别是在 C++ 中使用。在 RAII 中,资源是在对象构造期间获取的,并在对象析构期间释放。在 Python 中,类似的函数是 __init__()__del__()(终结器),但 RAII适用于 Python,并且在 __del__() 中释放资源不起作用。这是因为不能保证 __del__() 会被调用:它只是为了内存管理器使用,而不是为了资源处理。

更详细地讲,Python 对象构造分两阶段:在 `__new__()` 中进行内存分配,在 `__init__()` 中进行属性初始化。 Python 使用引用计数进行垃圾回收,对象通过 `__del__()` 完成最终化(而不是销毁)。 然而,最终化是非确定性的(对象的生命周期是非确定性的),最终化器可能被调用得更晚,甚至根本不调用,尤其是在程序崩溃的情况下。 因此,使用 `__del__()` 进行资源管理通常会导致资源泄漏。

可以使用最终化器进行资源管理,但由此产生的代码依赖于实现(通常在 CPython 中有效,但在其他实现中无效,例如 PyPy),并且易受版本变更的影响。 即使进行了此操作,也需要非常小心地确保在所有情况下引用都降为零,包括:异常,如果捕获异常或以交互方式运行,则异常会在跟踪中包含引用;以及全局变量中的引用,这些引用会在程序终止之前一直存在。 在 Python 3.4 之前,循环中对象的最终化器也是一个严重的问题,但现在不再是问题了;但是,循环中对象的最终化顺序并非确定性的。

参考资料

[编辑 | 编辑源代码]
[编辑 | 编辑源代码]
华夏公益教科书