0%

流畅的 Python(10):上下文管理器和 else 块

这里讨论 Python 用户往往会忽视或者没有充分使用的几个特性,包括:

  • with 语句和上下文管理器
  • for、while 和 try 语句的 else 子句

with 语句会设置一个临时的上下文,交给上下文管理器对象控制,并负责清理上下文。这样做能够避免错误并减少样板代码,因此 API 更安全且易于使用。

if 语句之外的 else 块

else 子句不仅能够在 if 语句中使用,还能在 for、while 和 try 语句中使用。for/else、while/else 和 try/else 的语义关系紧密,但是与 if/else 差别很大。else 子句的行为如下:

  • for:仅当 for 循环运行完毕时才运行 else 块(即 for 循环没有被 break 语句中止)
  • while:仅当 while 循环因为条件为假值而退出时,才运行 else 块(即 while 循环没有被 break 语句中止)
  • try:仅当 try 块中没有异常抛出时才运行 else 块。并且 else 子句抛出的异常不会由前面的 except 子句处理

在所有情况下,如果异常或者 return、break 或 continue 语句导致控制权跳转到了复合语句的主块之外,else 子句也会被跳过。

以上逻辑里,else 有 then 的含义。在这些语句里使用 else 子句通常能使代码更容易阅读,而且能省去一些麻烦,不用设置控制标志或者额外的 if 语句:

1
2
3
4
5
for item in my_list:
if item.flavor == "banana":
break
else:
raiseValue("No banana flavor found!")

在 try/except 块中使用 else 子句的场景为:

1
2
3
4
5
6
try:
dangerous_call()
except OSError:
log('OSError...')
else:
after_call()

这样很明显,只有 try 块中不抛出异常,才会执行 after_call()。在 Python 中,try/except 不仅用于处理错误,还常用于控制流程。在 Python 中这被称为 EAEP(easier to ask for forgiveness than permission)。这是一种常见的 Python 编程风格,先假定存在有效的键或属性,如果假定不存在,那么捕获异常。这种风格简单明快,特点是代码中有很多 try 和 except 语句。与这种风格对立的是 LBYL 风格(look before you leap):这种风格在调用函数或查找属性或键之前显式测试前提条件。这种风格的特点是代码中有很多 if 语句。在多线程环境中,LBYL 风格可能会在检查与动作之间的空当引入条件竞争。这种问题可以使用锁或者 EAEP 风格解决

如果使用了 EAEP 风格,那么就需要更深入地了解 else 子句,并在 try/except 语句中合理使用。

上下文管理器和 with 块

上下文管理器对象的目的是管理 with 语句。with 语句的目的是简化 try/finally 模式,该模式用于保证一段代码运行完毕后执行某段操作,即便这项代码由于异常、return 语句或者 sys.exit() 调用而中止,也会执行特定的操作。finally 子句中的代码通常用于释放重要的资源,或者还原临时变更的状态。

上下文管理器协议包含 __enter____exit__ 两个方法。with 语句开始运行时,会在上下文管理器对象上调用 __enter__ 方法。with 语句运行结束后,会在上下文管理器对象上调用 __exit__ 方法,以此扮演 finally 子句的角色。典型用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> with open("yield.py") as fp:
... src = fp.read(60)
...
>>> len(src)
60
>>> fp
<_io.TextIOWrapper name='yield.py' mode='r' encoding='UTF-8'>
>>> fp.closed
True
>>> fp.read(60)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.

执行 with 后面的表达式得到的的结果是上下文管理对象之后在上下文管理对象上调用 enter 方法,并把返回的结果绑定到目标变量上(as 子句)。open 函数返回 TextIOWrapper 类的实例,而该实例的 __enter__ 方法返回 self。__enter__ 方法除了返回上下文管理器之外,还能返回其他对象。不管流程以哪种方式退出 with 块,都会在上下文管理器对象上调用 __exit__ 方法,而不是在 __enter__ 方法返回的对象上调用。with 语句的 as 子句是可选的,有些上下文管理器的 __enter__ 会返回 None,因为没有什么对象能够提供给用。

如下实现了一个上下文管理器,以此强调上下文管理器与 __enter__ 方法返回的对象之间的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys


class LookingGlass:
def __enter__(self):
self.original_write = sys.stdout.write
sys.stdout.write = self.reverse_write
return "ABCDE"

def reverse_write(self, text):
self.original_write(text[::-1])

def __exit__(self, exc_type, exc_value, traceback):
sys.stdout.write = self.original_write
if exc_type is ZeroDivisionError:
print("Don't divide by zero!")
return True
1
2
3
4
5
6
7
8
9
>>> import looking_glass
>>> with looking_glass.LookingGlass() as what:
... print("This is test")
... print(what)
...
tset si sihT
EDCBA
>>> print(what)
ABCDE
  • 如果一切正常,Python 调用 __exit__ 方法传入的参数是 None,None,None。如果抛出了异常,则这三个参数是异常数据,分别是异常类、异常实例、traceback 对象。

我们也可以手动调用上下文管理对象的的 __enter____exit__ 方法,这样能更清楚地了解上下文管理器的工作方式。

1
2
3
4
5
6
7
8
9
10
11
>>> manager = looking_glass.LookingGlass()
>>> t = manager.__enter__()
>>> print(t)
EDCBA
>>> print("hello")
olleh
>>> manager.__exit__(None, None, None)
>>> print(t)
ABCDE
>>> print("hello")
hello

contextlib 模块中的实用工具

标准库的 contextlib 模块提供了一些实用工具:

  • closing:如果对象提供了 close() 方法,但没有实现 __enter__/__exit__ 协议,那么可以使用这个函数构建上下文管理器
  • suppress:构建临时忽略指定异常的上下文管理器
  • @contextmanager:这个装饰器会把简单的生成器函数变成上下文管理器,这样就不用创建类去实现管理器协议了
  • ContextDecorator:这是个基类,用于定义基于类的上下文管理器。这种上下文管理器也能用于装饰函数,在受管理的上下文中运行整个函数
  • ExitStack:这个上下文管理器能够进入多个上下文管理器,with 块结束时,ExitStack 按照后进先出的顺序调用栈中各个上下文管理器的 __exit__ 方法。如果事先不知道 with 块要进入多少个上下文管理器,可以使用这个类

使用 @contextmanager

@contextmanager 装饰器能够减少创建上下文管理器的样板代码量,因为不用编写一个完整的类,定义 __enter____exit__ 方法,而只需要实现一个 yield 语句的生成器,生成想让 __enter__ 方法返回的值。

在使用 @contextmanager 装饰的生成器中,yield 语句的作用是把函数的定义体分成两部分:

  • yield 语句前面的所有代码在 with 块开始时执行(即解释器调用 enter 方法时)执行
  • yield 语句后面的代码在 with 块结束时执行(即调用 exit 方法时)执行

如下使用 @contextmanager 装饰器重新实现 LookingGlass 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys


def looking_glass():
original_write = sys.stdout.write

def reverse_write(text):
original_write(text[::-1])

sys.stdout.write = reverse_write
yield "ABCDE"

sys.stdout.write = original_write

contextlib.contextmanager 装饰器会把函数包装成实现 __enter____exit__ 方法的类,这个类的 __enter__ 方法有如下作用:

  • 调用生成器函数,保存生成器对象
  • 调用 next(gen),执行到 yield 关键字所在的位置
  • 返回 next(gen) 产出的值,以便把产出的值绑定到 with/as 语句中的目标变量上

with 块终止时,__exit__ 方法会做以下事情:

  • 检查有没有异常,如果有异常,调用 throw(exception) 在生成器函数定义体中包含 yield 关键字的那一行抛出异常
  • 否则调用 next(gen) 继续执行生成器函数定义体中的 yield 语句之后的代码

如果在 with 块中抛出异常,Python 解释器会将其捕获,然后在 looking_glass 函数的 yield 表达式里再次抛出。因此,在 使用 @contextmanager 装饰器时,要把 yield 语句放在 try/finally 语句中。

@contextmanager 装饰的生成器中,yield 与迭代没有任何关系。生成器的作用更像是协程,执行到某一点暂停,让客户代码运行,直到客户让协程继续做事。