创建上下文管理器#

《Python 完全自学教程》的第 12 章中,以对文件的基本操作为例,简要说明了 with 语句和上下文管理器的应用,但由于该部分的重点在于讲解 open()read() 函数,所以没有对上下文管理器进行深入解释,本文对此给予补充。

1. 定义上下文管理器对象#

在 Python 中,关键词 with 能够调用上下文管理器(context manager),例如:

>>> with open("example.txt", "w") as file:
...     file.write("Hello, world!")
...
13
>>> file.closed
True

上下文管理器,在 Python 中也是对象,因此,可以编写类实现此对象,其中最主要的是 __enter____exit__ 两个方法,如下:

class Example:
    def __enter__(self):
        print("enter")

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("exit")

如果用 with 调用上面所定义的上下文管理器,进入语句块后,首先调用 __enter__ 方法,执行 print("enter") ,打印出字符串 enter ;而后执行语句块里面的语句;当退出 with 语句块的时候,要执行 __exit__ 方法。

>>> with Example():
...     print("Yay Python!")
...
enter
Yay Python!
exit

2. 应用举例#

在下面的示例中,创建上下文管理器,并利用它暂时修改环境变量的值。

import os

class SetEnvVar:
    def __init__(self, var_name, new_value):
        self.var_name = var_name
        self.new_value = new_value

    def __enter__(self):
        self.original_value = os.environ.get(self.var_name)
        os.environ[self.var_name] = self.new_value

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.original_value is None:
            del os.environ[self.var_name]
        else:
            os.environ[self.var_name] = self.original_value

在我所使用的计算机上,当前用户的是 qiwsir

>>> import os
>>> print("USER env var is", os.environ["USER"])
USER env var is qiwsir

现在使用上面所创建的上下文管理器 SetEnvVar ,用关键词 with 创建语句块,将环境变量 USER 的值暂时修改为 akin

>>> with SetEnvVar("USER", "akin"):
...     print("USER env var is", os.environ["USER"])
...
USER env var is akin

诚然,如果 with 语句块执行完毕,即退出之后,该环境变量的值还会恢复原值。

>>> print("USER env var is", os.environ["USER"])
USER env var is qiwsir

得到上述效果,其原因就是在 SetEnvVar 类中定义了 __enter____exit__ 两个方法,进入 with 语句块之后,执行 __enter__ 方法,暂时修改环境变量的值;语句块结束,执行 __exit__ 方法,将环境变量值恢复。

3. 关键词 as 的作用#

在使用上下文管理器的语句块中,有时候还会用到 as 关键词,例如:

>>> with SetEnvVar("USER", "akin") as result:
...     print("USER env var is", os.environ["USER"])
...     print("Result from __enter__ method:", result)
...
USER env var is akin
Result from __enter__ method: None

关键词 as 的作用是制定一个变量(上例中的 result),用于引用方法 __enter__ 的返回值。当然,在 SetEnvVar 类中,方法 __enter__ 没有 return 语句,也就是没有返回值,或者说默认返回值是 None ,故上面例子中打印出来的是 None

4. __enter__ 的返回值#

前面所创建的上下文管理器 SetEnvVar__enter__ 方法没有用 return 返回任何对象,下面看一个有返回值的例子。创建一个文件 timer.py ,其中的代码如下:

import time

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.stop = time.perf_counter()
        self.elapsed = self.stop - self.start

这个上下文管理器可以用量一个代码块的执行时间,在这里我们使用 with 发起代码块。

>>> t = Timer()
>>> with t:
...     result = sum(range(10_000_000))
...
>>> t.elapsed
0.28711878502508625

如果没有忘记前面说过的 as 关键词,还可以用它写一种更简洁的方式:将创建实例和变量引用放在一行:

>>> with Timer() as t:
...     result = sum(range(10_000_000))
...
>>> t.elapsed
0.3115791230229661

这是因为,在 Timer 类的 __enter__ 方法中,有 return self ,返回了当前所创建的实例,即 Timer() ;再通过 as 关键词,将此实例对象引用给变量 t

这种方法在许多上下文管理器中都会用到,所以,定义上下文管理器时,__enter__ 方法常常会 return self

5. __exit__ 的参数#

前面所定义的两个上下文管理器对象,其中的 __exit__ 方法都含有 exc_type, exc_val, exc_tb 三个形参,对它们应该提供什么实参?这个方法的返回值是什么?

如果在 with 语句块中发生了异常,__exit__ 方法的那个三个参数会分别引用异常类、异常实例和异常的 traceback 对象。但是,如果没有异常发生,三个参数都会引用 None

举个例子,就可以看到其中端倪。

创建名为 log_exception.py 的文件,并输入下面的代码:

import logging

class LogException:
    def __init__(self, logger, level=logging.ERROR, suppress=False):
        self.logger, self.level, self.suppress = logger, level, suppress

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            info = (exc_type, exc_val, exc_tb)
            self.logger.log(self.level, "Exception occurred", exc_info=info)
            return self.suppress
        return False

如果在 with 语句块中有异常,利用上面所创建的上下文管理器,可以记录异常信息(使用了 logging 模块)。

再创建名为 log_example.py 的文件,代码如下:

import logging
from log_exception import LogException

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("example")

with LogException(logger):
    result = 1 / 0  # This will cause a ZeroDivisionError
print("That's the end of our program")

显然,在上面的代码中,会发生异常。

$ python log_example.py
ERROR:example:Exception occurred
Traceback (most recent call last):
  File "/home/trey/_/log_example.py", line 8, in <module>
    result = 1 / 0  # This will cause a ZeroDivisionError
             ~~^~~
ZeroDivisionError: division by zero
Traceback (most recent call last):
  File "/home/trey/_/log_example.py", line 8, in <module>
    result = 1 / 0  # This will cause a ZeroDivisionError
             ~~^~~
ZeroDivisionError: division by zero

注意观察上面报错信息的第二行:ERROR:example:Exception occurred ,这说明已经执行了 __exit__ 方法。

并且,在上面看到了两行 Taceback 实例,第二个 Tracback 是由 Python 解释器自动生成的。

因为执行了 __exit__ ,使得程序已经终止,所以 log_example.py 的最后一行 print("That's the end of our program") 就没有执行。

6. __exit__ 的返回值#

在调用上下文管理器对象的语句中,如果提供参数 suppress=True (在 LogException 类中,令 suppress=False),结果会大相径庭。改写 log_example.py 文件中的代码:

import logging
from log_exception import LogException

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("example")

with LogException(logger, suppress=True):    # passed suppress=True to context manager
    result = 1 / 0  # This will cause a ZeroDivisionError
print("That's the end of our program")

再来执行 log_example.py ,观察效果:

$ python3 log_example.py
ERROR:example:Exception occurred
Traceback (most recent call last):
  File "/home/trey/_/_/log_example.py", line 8, in <module>
    result = 1 / 0  # This will cause a ZeroDivisionError
             ~~^~~
ZeroDivisionError: division by zero
That's the end of our program

现在看到,除了之前的结果之外,还将该程序执行完毕了。这就是 suppress 参数的作用效果,上下文管理器用它把“异常压住”了。

import logging

class LogException:
    ...

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            info = (exc_type, exc_val, exc_tb)
            self.logger.log(self.level, "Exception occurred", exc_info=info)
            return self.suppress
        return False

如果 __exit__ 方法返回了 True 或者其他布尔值为真的对象,那么原本正在抛出的异常实际上就会被压制了。

默认情况下,__exit__ 就像每个函数默认做的那样,返回 NoneNone 的布尔值是 False,这与上述 return False 是一样的,此时,__exit__ 方法就只会抛出异常,不会执行任何与默认不同的操作。

但是,如果最后是 return True,那么,异常将被压制。

7. contextmanager 装饰器#

除了用上面的方法定义上下文管理器,还可以用生成器函数定义。但是要使用一个装饰器,即来自 contextlib 模块的 contextmanager

from contextlib import contextmanager
import os


@contextmanager
def set_env_var(var_name, new_value):
    original_value = os.environ.get(var_name)
    os.environ[var_name] = new_value
    try:
        yield
    finally:
        if original_value is None:
            del os.environ[var_name]
        else:
            os.environ[var_name] = original_value

其实,在这个花哨的装饰器内部仍然涉及 __enter____exit__ 方法。不过,用这个装饰器,的确是一种非常简单地创建上下文管理器的方法。

参考资料#

[1]. 齐伟. Python 完全自学教程-编辑文件[DE/OL].[2023-08-18].https://lqpybook.readthedocs.io/en/latest/chapter12.html

[2]. Trey Hunner. Creating a context manager in Python[DE/OL].(2023-08-07)[2023-08-18].https://www.pythonmorsels.com/creating-a-context-manager/