geekdoc-python-zh/docs/realpython/python-with-statement.md

50 KiB
Raw Blame History

上下文管理器和 Python 的 with 语句

原文:https://realpython.com/python-with-statement/

*立即观看**本教程有真实 Python 团队创建的相关视频课程。和书面教程一起看,加深理解: 上下文管理器和 Python 的 with 语句

Python 中的with语句对于正确管理程序中的外部资源是一个非常有用的工具。它允许您利用现有的上下文管理器来自动处理安装和拆卸阶段,无论您何时处理外部资源或需要这些阶段的操作。

此外,上下文管理协议允许你创建自己的上下文管理器,这样你就可以定制处理系统资源的方式。那么,with语句有什么用呢?

在本教程中,您将学习:

  • Python with语句是做什么的以及如何使用
  • 什么是上下文管理协议
  • 如何实现自己的上下文管理器

有了这些知识,你就可以编写更有表现力的代码,避免程序中的资源泄露和 T2。with语句通过抽象它们的功能并允许它们被分解和重用,帮助您实现一些常见的资源管理模式。

免费下载: 从 Python 技巧中获取一个示例章节:这本书用简单的例子向您展示了 Python 的最佳实践,您可以立即应用它来编写更漂亮的+Python 代码。

用 Python 管理资源

你在编程中会遇到的一个常见问题是如何正确管理外部资源,比如文件,以及网络连接。有时,一个程序会永远保留那些资源,即使你不再需要它们。这种问题被称为内存泄漏,因为每次创建和打开给定资源的新实例而不关闭现有实例时,可用内存都会减少。

合理管理资源通常是一个棘手的问题。它需要一个安装阶段和一个拆卸阶段。后一个阶段需要你执行一些清理动作,比如关闭一个文件,释放一个锁,或者关闭一个网络连接。如果您忘记执行这些清理操作,那么您的应用程序将保持资源活动。这可能会危及宝贵的系统资源,如内存和网络带宽。

例如,当开发人员使用数据库时,一个常见的问题是程序不断创建新的连接而不释放或重用它们。在这种情况下,数据库后端可以停止接受新的连接。这可能需要管理员登录并手动终止这些过时的连接,以使数据库再次可用。

另一个常见的问题出现在开发人员处理文件的时候。将文本写入文件通常是一个缓冲操作。这意味着在文件上调用.write()不会立即导致将文本写入物理文件,而是写入临时缓冲区。有时,当缓冲区未满,开发人员忘记调用.close()时,部分数据可能会永远丢失。

另一种可能是你的应用程序遇到错误或异常,导致控制流绕过负责释放手头资源的代码。下面是一个使用 open() 将一些文本写入文件的例子:

file = open("hello.txt", "w")
file.write("Hello, World!")
file.close()

如果在调用.write()的过程中出现异常,这个实现不能保证文件会被关闭。在这种情况下,代码永远不会调用.close(),因此您的程序可能会泄漏一个文件描述符。

在 Python 中,可以使用两种通用方法来处理资源管理。您可以将代码包装在:

  1. 一个 tryfinally 构念
  2. 一个 with 构造

第一种方法非常通用,允许您提供安装和拆卸代码来管理任何类型的资源。但是,有点啰嗦。此外,如果您忘记了任何清理操作怎么办?

第二种方法提供了一种简单的方式来提供和重用安装和拆卸代码。在这种情况下,您将受到限制,即with语句仅适用于上下文管理器。在接下来的两节中,您将学习如何在代码中使用这两种方法。

Remove ads

try……finally接近

处理文件可能是编程中资源管理最常见的例子。在 Python 中,可以使用一个tryfinally语句来正确地处理打开和关闭文件:

# Safely open the file
file = open("hello.txt", "w")

try:
    file.write("Hello, World!")
finally:
    # Make sure to close the file after using it
    file.close()

在这个例子中,您需要安全地打开文件hello.txt,这可以通过在tryexcept语句中包装对open()的调用来实现。稍后,当你试图写file时,finally子句将保证file被正确关闭,即使在try子句中调用.write()的过程中出现异常。当您在 Python 中管理外部资源时,可以使用这种模式来处理安装和拆卸逻辑。

上例中的try块可能会引发异常,比如AttributeErrorNameError。您可以像这样在except子句中处理这些异常:

# Safely open the file
file = open("hello.txt", "w")

try:
    file.write("Hello, World!")
except Exception as e:
    print(f"An error occurred while writing to the file: {e}")
finally:
    # Make sure to close the file after using it
    file.close()

在本例中,您捕获了在写入文件时可能发生的任何潜在异常。在现实生活中,您应该使用特定的异常类型而不是通用的 Exception 来防止未知错误无声无息地传递。

with语句接近

Python with语句创建了一个运行时上下文,允许你在上下文管理器的控制下运行一组语句。 PEP 343 增加了with语句,以便能够分解出tryfinally语句的标准用例。

与传统的tryfinally结构相比,with语句可以让你的代码更加清晰、安全和可重用。标准库中的很多类都支持with语句。一个经典的例子是 open() ,它允许你使用with来处理文件对象

要编写一个with语句,需要使用以下通用语法:

with expression as target_var:
    do_something(target_var)

上下文管理器对象是在with之后评估expression的结果。换句话说,expression必须返回一个实现上下文管理协议的对象。该协议包括两种特殊方法:

  1. .__enter__()with语句调用进入运行时上下文。
  2. .__exit__() 在执行离开with代码块时被调用。

as说明符是可选的。如果您提供一个带有astarget_var,那么在上下文管理器对象上调用.__enter__()返回值将被绑定到该变量。

**注意:**一些上下文管理器从.__enter__()返回None,因为它们没有有用的对象返回给调用者。在这些情况下,指定一个target_var没有意义。

下面是 Python 遇到with语句时该语句的处理方式:

  1. 调用expression来获取上下文管理器。
  2. 存储上下文管理器的.__enter__().__exit__()方法以备后用。
  3. 在上下文管理器上调用.__enter__(),并将其返回值绑定到target_var(如果提供的话)。
  4. 执行with代码块。
  5. with代码块完成时,调用上下文管理器上的.__exit__()

在这种情况下,.__enter__(),通常提供设置代码。with语句是一个复合语句,它启动一个代码块,类似于条件语句for循环。在这个代码块中,可以运行几条语句。通常,如果适用的话,您可以使用with代码块来操作target_var

一旦with代码块完成,.__exit__()就会被调用。这个方法通常提供拆卸逻辑或清理代码,比如在打开的文件对象上调用.close()。这就是为什么with语句如此有用。它使得正确获取和释放资源变得轻而易举。

下面是如何使用with语句打开hello.txt文件进行写入的方法:

with open("hello.txt", mode="w") as file:
    file.write("Hello, World!")

当您运行这个with语句时,open()返回一个 io.TextIOBase 对象。这个对象也是一个上下文管理器,所以with语句调用.__enter__(),并将其返回值赋给file。然后,您可以在with代码块中操作该文件。当块结束时,.__exit__()会自动被调用并为您关闭文件,即使在with块中出现异常。

这个with构造比它的tryfinally替代要短,但是也不那么通用,正如你已经看到的。您只能对支持上下文管理协议的对象使用with语句,而tryfinally允许您对任意对象执行清理操作,而无需支持上下文管理协议。

在 Python 3.1 和更高版本中,with语句支持多个上下文管理器。您可以提供任意数量的上下文管理器,用逗号分隔:

with A() as a, B() as b:
    pass

这类似于嵌套的with语句,但是没有嵌套。当您需要一次打开两个文件(第一个用于读取,第二个用于写入)时,这可能很有用:

with open("input.txt") as in_file, open("output.txt", "w") as out_file:
    # Read content from input.txt
    # Transform the content
    # Write the transformed content to output.txt
    pass

在这个例子中,您可以添加代码来读取和转换input.txt的内容。然后你在同一个代码块里把最终结果写到output.txt里。

然而,在一个with中使用多个上下文管理器有一个缺点。如果您使用这个特性,那么您可能会突破您的行长度限制。要解决这个问题,您需要使用反斜杠(\)来延续行,因此您可能会得到一个难看的最终结果。

with语句可以使处理系统资源的代码更具可读性、可重用性和简洁,更不用说更安全了。它有助于避免 bug 和泄漏,因为它让你在使用资源后几乎不可能忘记清理、关闭和释放资源。

使用with允许您抽象出大部分资源处理逻辑。不需要每次都写一个带有安装和拆卸代码的明确的tryfinally语句,with会为您处理这些并避免重复。

Remove ads

使用 Python with语句

只要 Python 开发人员将with语句整合到他们的编码实践中该工具已经被证明有几个有价值的用例。Python 标准库中越来越多的对象现在提供了对上下文管理协议的支持,因此您可以在with语句中使用它们。

在本节中,您将编写一些示例,展示如何在标准库中和第三方库中的几个类中使用with语句。

使用文件

到目前为止,您已经使用了open()来提供上下文管理器,并在with结构中操作文件。通常推荐使用with语句打开文件,因为它确保打开的文件描述符在执行流离开with代码块后自动关闭。

正如您之前看到的,使用with打开文件的最常见方式是通过内置的open():

with open("hello.txt", mode="w") as file:
    file.write("Hello, World!")

在这种情况下,由于上下文管理器在离开with代码块后关闭文件,一个常见的错误可能如下:

>>> file = open("hello.txt", mode="w")

>>> with file:
...     file.write("Hello, World!")
...
13

>>> with file:
...     file.write("Welcome to Real Python!")
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.

第一个with成功将"Hello, World!"写入hello.txt。注意,.write()返回写入文件的字节数,13。然而,当你试图运行第二个with时,你会得到一个ValueError,因为你的file已经关闭。

使用with语句打开和管理文件的另一种方法是使用 pathlib.Path.open() :

>>> import pathlib

>>> file_path = pathlib.Path("hello.txt")

>>> with file_path.open("w") as file:
...     file.write("Hello, World!")
...
13

Path 是一个表示你电脑中物理文件的具体路径的类。在指向物理文件的Path对象上调用.open()会像open()一样打开它。因此,Path.open()的工作方式类似于open(),但是文件路径是由您调用方法的Path对象自动提供的。

由于 pathlib 提供了一种优雅、简单且Python 化的方式来操作文件系统路径,您应该考虑在您的with语句中使用Path.open()作为 Python 中的最佳实践。

最后,每当你加载一个外部文件时,你的程序应该检查可能的问题,比如一个丢失的文件,读写访问,等等。以下是您在处理文件时应该考虑使用的一般模式:

import pathlib
import logging

file_path = pathlib.Path("hello.txt")

try:
    with file_path.open(mode="w") as file:
        file.write("Hello, World!")
except OSError as error:
    logging.error("Writing to file %s failed due to: %s", file_path, error)

在这个例子中,您将with语句包装在一个 tryexcept语句中。如果在执行with的过程中出现了 OSError ,那么您可以使用 logging 用一条用户友好的描述性消息来记录错误。

遍历目录

os 模块提供了一个名为 scandir() 的函数,该函数返回给定目录中条目对应的 os.DirEntry 对象的迭代器。这个函数是专门设计来在遍历目录结构时提供最佳性能的。

以给定目录的路径作为参数调用scandir()会返回一个支持上下文管理协议的迭代器:

>>> import os

>>> with os.scandir(".") as entries:
...     for entry in entries:
...         print(entry.name, "->", entry.stat().st_size, "bytes")
...
Documents -> 4096 bytes
Videos -> 12288 bytes
Desktop -> 4096 bytes
DevSpace -> 4096 bytes
.profile -> 807 bytes
Templates -> 4096 bytes
Pictures -> 12288 bytes
Public -> 4096 bytes
Downloads -> 4096 bytes

在本例中,您编写了一个将os.scandir()作为上下文管理器供应商的with语句。然后你遍历所选目录中的条目("."),然后在屏幕上打印出它们的名称和大小。在这种情况下,.__exit__()调用 scandir.close() 关闭迭代器,释放获取的资源。请注意,如果在您的机器上运行此命令,您将得到不同的输出,这取决于您当前目录的内容。

Remove ads

执行高精度计算

与内置的浮点数不同, decimal 模块提供了一种调整精度的方法,以便在涉及 Decimal 数字的给定计算中使用。精度默认为28位,但是您可以更改它以满足您的问题要求。使用decimallocalcontext() 进行自定义精度计算的快速方法是:

>>> from decimal import Decimal, localcontext

>>> with localcontext() as ctx:
...     ctx.prec = 42
...     Decimal("1") / Decimal("42")
...
Decimal('0.0238095238095238095238095238095238095238095')

>>> Decimal("1") / Decimal("42")
Decimal('0.02380952380952380952380952381')

这里,localcontext()提供了一个上下文管理器,它创建一个本地十进制上下文,并允许您使用自定义精度执行计算。在with代码块中,您需要将.prec设置为您想要使用的新精度,即上面示例中的42位置。当with代码块结束时,精度被重置回默认值,28位。

处理多线程程序中的锁

在 Python 标准库中有效使用with语句的另一个好例子是 threading.Lock 。这个类提供了一个原语锁,以防止多个线程在一个多线程应用程序中同时修改一个共享资源。

您可以在一个with语句中使用一个Lock对象作为上下文管理器来自动获取和释放一个给定的锁。例如,假设您需要保护一个银行账户的余额:

import threading

balance_lock = threading.Lock()

# Use the try ... finally pattern
balance_lock.acquire()
try:
    # Update the account balance here ...
finally:
    balance_lock.release()

# Use the with pattern
with balance_lock:
    # Update the account balance here ...

第二个例子中的with语句在执行流进入和离开语句时自动获取和释放一个锁。这样,您可以专注于代码中真正重要的东西,而忘记那些重复的操作。

在这个例子中,with语句中的锁创建了一个被称为临界区的受保护区域,它防止对账户余额的并发访问。

使用 pytest 测试异常

到目前为止,您已经使用 Python 标准库中可用的上下文管理器编写了几个示例。然而,一些第三方库包括支持上下文管理协议的对象。

假设你正在用 pytest 测试你的代码。您的一些函数和代码块在某些情况下会引发异常,您希望测试这些情况。为此,您可以使用 pytest.raises() 。此函数允许您断言代码块或函数调用会引发给定的异常。

因为pytest.raises()提供了一个上下文管理器,所以您可以像这样在with语句中使用它:

>>> import pytest

>>> 1 / 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

>>> with pytest.raises(ZeroDivisionError):
...     1 / 0
...

>>> favorites = {"fruit": "apple", "pet": "dog"}
>>> favorites["car"]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'car'

>>> with pytest.raises(KeyError):
...     favorites["car"]
...

在第一个例子中,您使用pytest.raises()来捕捉表达式1 / 0引发的 ZeroDivisionError 。第二个示例使用函数来捕获当您访问给定字典中不存在的键时引发的 KeyError

如果您的函数或代码块没有引发预期的异常,那么pytest.raises()会引发一个失败异常:

>>> import pytest

>>> with pytest.raises(ZeroDivisionError):
...     4 / 2
...
2.0
Traceback (most recent call last):
  ...
Failed: DID NOT RAISE <class 'ZeroDivisionError'>

pytest.raises()的另一个很酷的特性是,您可以指定一个目标变量来检查引发的异常。例如,如果您想要验证错误消息,那么您可以这样做:

>>> with pytest.raises(ZeroDivisionError) as exc:
...     1 / 0
...
>>> assert str(exc.value) == "division by zero"

您可以使用所有这些pytest.raises()特性来捕获您从函数和代码块中引发的异常。这是一个很酷很有用的工具,您可以将它整合到您当前的测试策略中。

Remove ads

总结with陈述的优点

为了总结到目前为止您所学到的内容,这里列出了在代码中使用 Python with语句的一系列好处:

  • 使资源管理比其等价的tryfinally语句更安全
  • 封装了上下文管理器tryfinally语句的标准用法
  • 允许重用自动管理给定操作的设置拆卸阶段的代码
  • 帮助避免资源泄漏

一致地使用with语句可以提高代码的总体质量,并通过防止资源泄漏问题使代码更加安全。

使用async with语句

with语句也有异步版本, async with 。您可以使用它来编写依赖于异步代码的上下文管理器。在这种代码中很容易看到async with,因为许多 IO 操作都涉及安装和拆卸阶段。

例如,假设您需要编写一个异步函数来检查给定的站点是否在线。为此,您可以像这样使用 aiohttpasyncioasync with:

 1# site_checker_v0.py
 2
 3import aiohttp
 4import asyncio
 5
 6async def check(url):
 7    async with aiohttp.ClientSession() as session: 8        async with session.get(url) as response: 9            print(f"{url}: status -> {response.status}")
10            html = await response.text()
11            print(f"{url}: type -> {html[:17].strip()}")
12
13async def main():
14    await asyncio.gather(
15        check("https://realpython.com"),
16        check("https://pycoders.com"),
17    )
18
19asyncio.run(main())

下面是这个脚本的作用:

  • 第 3 行 导入 aiohttp,为asyncio和 Python 提供异步 HTTP 客户端和服务器。注意,aiohttp是一个第三方包,可以通过在命令行运行python -m pip install aiohttp来安装。
  • 第 4 行导入asyncio,它允许你使用asyncawait语法编写并发代码。
  • 第 6 行使用async 关键字check()定义为异步函数。

check()中,您定义了两个嵌套的async with语句:

  • 第 7 行定义了一个外部async with,它实例化aiohttp.ClientSession()以获得一个上下文管理器。它将返回的对象存储在session中。
  • 第 8 行定义了一个内部async with语句,使用url作为参数调用session上的.get()。这将创建第二个上下文管理器并返回一个response
  • 第 9 行打印手头url的响应状态码
  • 10 号线response上运行对.text()的唤醒调用,并将结果存储在html中。
  • 第 11 行打印站点url及其文件类型, doctype
  • 第 13 行定义了脚本的 main() 函数,也是一个协程
  • 第 14 行asyncio调用 gather() 。该函数按顺序同时运行个可应用对象。在这个例子中,gather()用不同的 URL 运行check()的两个实例。
  • 19 线运行main()使用 asyncio.run() 。该函数创建一个新的asyncio 事件循环,并在操作结束时关闭它。

如果您从命令行运行这个脚本,那么您将得到类似如下的输出:

$ python site_checker_v0.py
https://realpython.com: status -> 200
https://pycoders.com: status -> 200
https://pycoders.com: type -> <!doctype html>
https://realpython.com: type -> <!doctype html>

酷!您的脚本正常工作,并且您确认两个站点当前都可用。您还可以从每个站点的主页检索有关文档类型的信息。

**注意:**由于并发任务调度和网络延迟的不确定性,您的输出可能会略有不同。特别是,各行可以以不同的顺序出现。

async with语句的工作方式类似于常规的with语句,但是它需要一个异步上下文管理器。换句话说,它需要一个能够在其进入和退出方法中暂停执行的上下文管理器。异步上下文管理器实现特殊的方法 .__aenter__().__aexit__() ,它们对应于常规上下文管理器中的.__enter__().__exit__()

async with ctx_mgr构造隐式地在进入上下文时使用await ctx_mgr.__aenter__(),在退出上下文时使用await ctx_mgr.__aexit__()。这无缝地实现了async上下文管理器行为。

创建自定义上下文管理器

您已经使用过标准库和第三方库中的上下文管理器。open()threading.Lockdecimal.localcontext()或其他人没有什么特别或神奇的。它们只是返回实现上下文管理协议的对象。

您可以通过在基于类的上下文管理器中实现.__enter__().__exit__()特殊方法来提供相同的功能。您还可以使用标准库中的 contextlib.contextmanager 装饰器和适当编码的生成器函数创建定制的基于函数的上下文管理器。

一般来说,上下文管理器和with语句并不局限于资源管理。它们允许您提供和重用常见的安装和拆卸代码。换句话说,使用上下文管理器,您可以执行任何一对需要在另一个操作或过程的之前之后完成的操作,例如:

  • 打开和关闭
  • 锁定并释放
  • 更改和重置
  • 创建和删除
  • 进入和退出
  • 开始和停止
  • 安装和拆卸

您可以提供代码来安全地管理上下文管理器中的任何一对操作。然后,您可以在整个代码的with语句中重用该上下文管理器。这可以防止错误并减少重复的样板代码。它也让你的API更安全、更干净、更用户友好。

在接下来的两节中,您将学习创建基于类和基于函数的上下文管理器的基础知识。

Remove ads

编码基于类的上下文管理器

要实现上下文管理协议并创建基于类的上下文管理器,您需要将.__enter__()__exit__()特殊方法添加到您的类中。下表总结了这些方法的工作原理、它们采用的参数以及可以放入其中的逻辑:

方法 描述
.__enter__(self) 该方法处理设置逻辑,并在进入新的with上下文时被调用。它的返回值被绑定到with目标变量。
.__exit__(self, exc_type, exc_value, exc_tb) 该方法处理拆卸逻辑,并在执行流离开with上下文时被调用。如果发生异常,那么exc_typeexc_valueexc_tb分别保存异常类型、值和回溯信息。

with语句执行时,它调用上下文管理器对象上的.__enter__(),表示您正在进入一个新的运行时上下文。如果您提供一个带有as说明符的目标变量,那么.__enter__()的返回值将被赋给该变量。

当执行流离开上下文时,.__exit__()被调用。如果with代码块中没有出现异常,那么.__exit__()的最后三个参数被设置为 None 。否则,它们保存与当前异常相关联的类型、值和回溯

如果.__exit__()方法返回True,那么with块中发生的任何异常都会被吞掉,并在with之后的下一条语句处继续执行。如果.__exit__()返回False,那么异常被传播到上下文之外。当方法不显式返回任何内容时,这也是默认行为。您可以利用这个特性在上下文管理器中封装异常处理。

编写一个基于类的上下文管理器示例

这里有一个基于类的上下文管理器示例,它实现了两种方法,.__enter__().__exit__()。它还展示了 Python 如何在一个with构造中调用它们:

>>> class HelloContextManager:
...     def __enter__(self):
...         print("Entering the context...")
...         return "Hello, World!"
...     def __exit__(self, exc_type, exc_value, exc_tb):
...         print("Leaving the context...")
...         print(exc_type, exc_value, exc_tb, sep="\n")
...

>>> with HelloContextManager() as hello:
...     print(hello)
...
Entering the context...
Hello, World!
Leaving the context...
None
None
None

HelloContextManager实现了.__enter__().__exit__()。在.__enter__()中,您首先打印一条消息,表示执行流正在进入一个新的上下文。然后你返回"Hello, World!"字符串。在.__exit__()中,您打印一条消息,表示执行流正在离开上下文。您还可以打印它的三个参数的内容。

with语句运行时Python 会创建一个新的HelloContextManager实例,并调用它的.__enter__()方法。你知道这一点是因为屏幕上印着Entering the context...

**注意:**使用上下文管理器的一个常见错误是忘记调用传递给with语句的对象。

在这种情况下,语句无法获得所需的上下文管理器,您会得到这样一个AttributeError:

>>> with HelloContextManager as hello:
...     print(hello)
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: __enter__

异常消息没有说太多,在这种情况下,您可能会感到困惑。所以,一定要调用with语句中的对象来提供相应的上下文管理器。

然后 Python 运行with代码块,将hello打印到屏幕上。注意,hello保存着.__enter__()的返回值。

当执行流退出with代码块时Python 调用.__exit__()。你知道这一点是因为你把Leaving the context...印在了你的屏幕上。输出的最后一行确认了.__exit__()的三个参数被设置为None

**注:**当你不记得.__exit__()的确切签名并且不需要访问它的参数时,一个常用的技巧是使用 *args**kwargs ,就像在def __exit__(self, *args, **kwargs):中一样。

现在,如果在执行with块的过程中出现异常,会发生什么?继续编写下面的with语句:

>>> with HelloContextManager() as hello:
...     print(hello)
...     hello[100]
...
Entering the context...
Hello, World!
Leaving the context...
<class 'IndexError'>
string index out of range
<traceback object at 0x7f0cebcdd080>
Traceback (most recent call last):
  File "<stdin>", line 3, in <module>
IndexError: string index out of range

在这种情况下,您尝试在字符串 "Hello, World!"中检索索引100处的值。这引发了一个IndexError,并且.__exit__()的参数设置如下:

  • exc_type 是例外类,IndexError
  • exc_value 是例外的实例。
  • exc_tb 是追溯对象。

当您想要在上下文管理器中封装异常处理时,这种行为非常有用。

Remove ads

在上下文管理器中处理异常

作为在上下文管理器中封装异常处理的一个例子,假设您希望在使用HelloContextManagerIndexError是最常见的异常。您可能希望在上下文管理器中处理该异常,这样就不必在每个with代码块中重复异常处理代码。在这种情况下,您可以这样做:

# exc_handling.py

class HelloContextManager:
    def __enter__(self):
        print("Entering the context...")
        return "Hello, World!"

    def __exit__(self, exc_type, exc_value, exc_tb):
        print("Leaving the context...")
        if isinstance(exc_value, IndexError):
            # Handle IndexError here...
            print(f"An exception occurred in your with block: {exc_type}")
            print(f"Exception message: {exc_value}")
            return True

with HelloContextManager() as hello:
    print(hello)
    hello[100]

print("Continue normally from here...")

.__exit__()中,你检查exc_value是否是IndexError的一个实例。如果是这样,那么您打印几条信息性消息,最后用True返回。返回一个真值使得在with代码块之后,可以吞下异常并继续正常执行。

在这个例子中,如果没有IndexError发生,那么该方法返回None并且异常传播出去。然而,如果你想更明确,那么你可以从if块外面返回False

如果您从命令行运行exc_handling.py,那么您会得到以下输出:

$ python exc_handling.py
Entering the context...
Hello, World!
Leaving the context...
An exception occurred in your with block: <class 'IndexError'>
Exception message: string index out of range
Continue normally from here...

HelloContextManager现在能够处理发生在with代码块中的IndexError异常。因为当一个IndexError发生时你返回True,执行流程在下一行继续,就在退出with代码块之后。

打开文件进行写入:第一版

既然您已经知道了如何实现上下文管理协议,那么您可以通过编写一个实际的例子来了解这一点。下面是如何利用open()来创建一个打开文件进行写入的上下文管理器:

# writable.py

class WritableFile:
    def __init__(self, file_path):
        self.file_path = file_path

    def __enter__(self):
        self.file_obj = open(self.file_path, mode="w")
        return self.file_obj

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file_obj:
            self.file_obj.close()

WritableFile实现了上下文管理协议,支持with语句,就像最初的open()一样,但是它总是使用"w"模式打开文件进行写入。以下是如何使用新的上下文管理器:

>>> from writable import WritableFile

>>> with WritableFile("hello.txt") as file:
...    file.write("Hello, World!")
...

运行这段代码后,您的hello.txt文件包含了"Hello, World!"字符串。作为一个练习,你可以写一个补充的上下文管理器来打开文件进行阅读,但是使用pathlib功能。去试试吧!

重定向标准输出

当您编写自己的上下文管理器时,需要考虑一个微妙的细节,即有时您没有从.__enter__()返回的有用对象,因此无法分配给with目标变量。在那些情况下,你可以显式返回 None 或者你可以只依赖 Python 的隐式返回值,也就是None

例如,假设您需要将标准输出 sys.stdout 临时重定向到磁盘上的给定文件。为此,您可以创建一个上下文管理器,如下所示:

# redirect.py

import sys

class RedirectedStdout:
    def __init__(self, new_output):
        self.new_output = new_output

    def __enter__(self):
        self.saved_output = sys.stdout
        sys.stdout = self.new_output

    def __exit__(self, exc_type, exc_val, exc_tb):
        sys.stdout = self.saved_output

这个上下文管理器通过它的构造函数获取一个文件对象。在.__enter__()中,您将标准输出sys.stdout重新分配给一个实例属性,以避免丢失对它的引用。然后重新分配标准输出,使其指向磁盘上的文件。在.__exit__()中,你只是将标准输出恢复到它的原始值。

要使用RedirectedStdout,您可以这样做:

>>> from redirect import RedirectedStdout

>>> with open("hello.txt", "w") as file:
...     with RedirectedStdout(file):
...         print("Hello, World!")
...     print("Back to the standard output...")
...
Back to the standard output...

本例中的外层with语句提供了一个文件对象,您将使用它作为新的输出,hello.txt。内部的with临时将标准输出重定向到hello.txt,因此对print()的第一次调用直接写入该文件,而不是在屏幕上打印"Hello, World!"。注意,当你离开内部的with代码块时,标准输出会回到它的初始值。

RedirectedStdout是一个上下文管理器的简单例子,它没有从.__enter__()返回有用的值。然而,如果您只是重定向print()输出,您可以获得相同的功能,而不需要编写上下文管理器。你只需要像这样给print()提供一个file参数:

>>> with open("hello.txt", "w") as file:
...     print("Hello, World!", file=file)
...

在这个例子中,print()将您的hello.txt文件作为一个参数。这使得print()直接写入你磁盘上的物理文件,而不是将"Hello, World!"打印到你的屏幕上。

Remove ads

测量执行时间

就像其他类一样,上下文管理器可以封装一些内部的状态。下面的例子展示了如何创建一个有状态上下文管理器来测量给定代码块或函数的执行时间:

# timing.py

from time import perf_counter

class Timer:
    def __enter__(self):
        self.start = perf_counter()
        self.end = 0.0
        return lambda: self.end - self.start

    def __exit__(self, *args):
        self.end = perf_counter()

当你在一个with语句中使用Timer时,就会调用.__enter__()。该方法使用 time.perf_counter() 获取with代码块开头的时间,并存储在.start中。它还初始化.end并返回计算时间增量的 lambda函数。在这种情况下,.start保持初始状态或时间测量。

**注意:**要深入了解如何为代码计时,请查看 Python 计时器函数:三种监控代码的方法

一旦with块结束,.__exit__()就会被调用。该方法获取块结束时的时间,并更新.end的值,以便lambda函数可以计算运行with代码块所需的时间。

下面是如何在代码中使用这个上下文管理器:

>>> from time import sleep
>>> from timing import Timer

>>> with Timer() as timer:
...     # Time-consuming code goes here...
...     sleep(0.5)
...

>>> timer()
0.5005456680000862

使用Timer,你可以测量任何一段代码的执行时间。在这个例子中,timer保存了计算时间增量的lambda函数的一个实例,所以您需要调用timer()来获得最终结果。

创建基于功能的上下文管理器

Python 的生成器函数contextlib.contextmanager 装饰器提供了实现上下文管理协议的另一种便捷方式。如果您用@contextmanager修饰一个适当编码的生成器函数,那么您会得到一个基于函数的上下文管理器,它自动提供所需的方法.__enter__().__exit__()。这可以为您节省一些样板代码,让您的生活更加愉快。

使用@contextmanager和生成器函数创建上下文管理器的一般模式如下:

>>> from contextlib import contextmanager

>>> @contextmanager
... def hello_context_manager():
...     print("Entering the context...")
...     yield "Hello, World!"
...     print("Leaving the context...")
...

>>> with hello_context_manager() as hello:
...     print(hello)
...
Entering the context...
Hello, World!
Leaving the context...

在本例中,您可以在hello_context_manager()中识别两个可见部分。在yield语句之前,有 setup 部分。在那里,您可以放置获取托管资源的代码。当执行流进入上下文时,yield之前的一切都开始运行。

yield语句之后,有一个 teardown 部分,您可以在其中释放资源并进行清理。yield之后的代码运行在with块的末尾。yield语句本身提供了将被分配给with目标变量的对象。

这种实现和使用上下文管理协议的实现实际上是等效的。根据你觉得哪一个更有可读性,你可能会更喜欢其中一个。基于函数的实现的缺点是它需要理解高级 Python 主题,比如装饰器和生成器。

@contextmanager装饰器减少了创建上下文管理器所需的样板文件。不用用.__enter__().__exit__()方法写整个类,你只需要用一个yield实现一个生成器函数,它产生你想要.__enter__()返回的任何东西。

打开文件进行写入:第二版

您可以使用@contextmanager来重新实现您的WritableFile上下文管理器。下面是用这种技术重写后的样子:

>>> from contextlib import contextmanager

>>> @contextmanager
... def writable_file(file_path):
...     file = open(file_path, mode="w")
...     try:
...         yield file
...     finally:
...         file.close()
...

>>> with writable_file("hello.txt") as file:
...     file.write("Hello, World!")
...

在这种情况下,writable_file()是一个打开file进行写入的生成器函数。然后它暂时挂起自己的执行,让出资源,这样with可以将它绑定到它的目标变量。当执行流程离开with代码块时,函数继续执行并正确关闭file

Remove ads

嘲笑时间

作为如何使用@contextmanager创建定制上下文管理器的最后一个例子,假设您正在测试一段使用时间测量的代码。代码使用 time.time() 来获得当前的时间测量值并做一些进一步的计算。由于时间度量不同,您决定模仿time.time(),这样您就可以测试您的代码。

这里有一个基于函数的上下文管理器可以帮你做到这一点:

>>> from contextlib import contextmanager
>>> from time import time

>>> @contextmanager
... def mock_time():
...     global time
...     saved_time = time
...     time = lambda: 42
...     yield
...     time = saved_time
...

>>> with mock_time():
...     print(f"Mocked time: {time()}")
...
Mocked time: 42

>>> # Back to normal time
>>> time()
1616075222.4410584

mock_time()中,你使用一个 global语句来表示你将要修改全局名time。然后,您将原始的time()函数对象保存在saved_time中,这样您可以在以后安全地恢复它。下一步是使用一个总是返回相同值42lambda函数来猴子补丁 time()

裸露的yield语句指定这个上下文管理器没有有用的对象发送回with目标变量供以后使用。在yield之后,您将全局time重置为其原始内容。

当执行进入with块时,任何对time()的调用都返回42。一旦离开with代码块,对time()的调用将返回预期的当前时间。就是这样!现在您可以测试与时间相关的代码了。

用上下文管理器编写好的 APIs】

上下文管理器非常灵活,如果您创造性地使用with语句,那么您可以为您的类、模块和包定义方便的 API。

例如,如果您想要管理的资源是某种报告生成器应用程序中的文本缩进级别该怎么办?在这种情况下,您可以编写如下代码:

with Indenter() as indent:
    indent.print("hi!")
    with indent:
        indent.print("hello")
        with indent:
            indent.print("bonjour")
    indent.print("hey")

这读起来几乎像是一种用于缩进文本的领域特定语言(DSL) 。另外,请注意这段代码如何多次进入和离开同一个上下文管理器,以便在不同的缩进级别之间切换。运行此代码片段会产生以下输出,并打印出格式整齐的文本:

hi!
    hello
        bonjour
hey

如何实现上下文管理器来支持这一功能?这可能是一个很好的练习,让你了解上下文管理器是如何工作的。因此,在您检查下面的实现之前,您可能需要一些时间,尝试自己解决这个问题,作为一个学习练习。

准备好了吗?下面是如何使用上下文管理器类实现此功能:

class Indenter:
    def __init__(self):
        self.level = -1

    def __enter__(self):
        self.level += 1
        return self

    def __exit__(self, exc_type, exc_value, exc_tb):
        self.level -= 1

    def print(self, text):
        print("    " * self.level + text)

在这里,每当执行流进入上下文时,.__enter__().level增加1。该方法还返回当前实例self。在.__exit__()中,你减少.level,这样每次退出上下文,打印的文本都后退一级。

这个例子中的关键点是从.__enter__()返回self允许您在几个嵌套的with语句中重用同一个上下文管理器。这将在每次进入和离开给定的上下文时改变文本的缩进级别。

此时,对您来说,一个很好的练习是编写这个上下文管理器的基于函数的版本。来吧,试一试!

创建异步上下文管理器

要创建异步上下文管理器,您需要定义.__aenter__().__aexit__()方法。下面的脚本是您之前看到的原始脚本site_checker_v0.py的重新实现,但是这次您提供了一个定制的异步上下文管理器来包装会话创建和关闭功能:

# site_checker_v1.py

import aiohttp
import asyncio

class AsyncSession:
    def __init__(self, url):
        self._url = url

    async def __aenter__(self):
        self.session = aiohttp.ClientSession()
        response = await self.session.get(self._url)
        return response

    async def __aexit__(self, exc_type, exc_value, exc_tb):
        await self.session.close()

async def check(url):
    async with AsyncSession(url) as response:
        print(f"{url}: status -> {response.status}")
        html = await response.text()
        print(f"{url}: type -> {html[:17].strip()}")

async def main():
    await asyncio.gather(
        check("https://realpython.com"),
        check("https://pycoders.com"),
    )

asyncio.run(main())

此脚本的工作方式与其先前的版本site_checker_v0.py相似。主要区别在于,在本例中,您提取了原始外部async with语句的逻辑,并将其封装在AsyncSession中。

.__aenter__()中,您创建一个aiohttp.ClientSession(),等待.get()响应,最后返回响应本身。在.__aexit__()中,您关闭会话,这对应于这个特定情况下的拆卸逻辑。注意.__aenter__().__aexit__()必须返回一个合适的对象。换句话说,您必须用async def来定义它们,这将返回一个根据定义可调用的协程对象。

如果您从命令行运行该脚本,那么您会得到类似如下的输出:

$ python site_checker_v1.py
https://realpython.com: status -> 200
https://pycoders.com: status -> 200
https://realpython.com: type -> <!doctype html>
https://pycoders.com: type -> <!doctype html>

太好了!您的脚本就像它的第一个版本一样工作。它同时向两个站点发送 GET请求,并处理相应的响应。

最后,在编写异步上下文管理器时,通常的做法是实现四种特殊的方法:

  1. .__aenter__()
  2. .__aexit__()
  3. .__enter__()
  4. .__exit__()

这使得您的上下文管理器可以与两种版本的with一起使用。

Remove ads

结论

Python with语句是管理程序中外部资源的强大工具。然而,它的用例并不局限于资源管理。您可以使用with语句以及现有的和定制的上下文管理器来处理给定流程或操作的设置和拆除阶段。

底层的上下文管理协议允许您创建定制的上下文管理器,并分解设置和拆卸逻辑,以便您可以在代码中重用它们。

在本教程中,您学习了:

  • Python with语句是做什么的以及如何使用
  • 什么是上下文管理协议
  • 如何实现自己的上下文管理器

有了这些知识,你就能写出安全、简洁、有表现力的代码。您还可以避免程序中的资源泄漏。

立即观看本教程有真实 Python 团队创建的相关视频课程。和书面教程一起看,加深理解: 上下文管理器和 Python 的 with 语句********