本章讨论其他语言不常见的流程控制,用户可能会忽略这些特性:
with 语句会设置一个临时的上下文,交给上下文管理器对象控制,并负责清理上下文。这么做能避免错误并减少样板代码,因此 API 更安全,更易于使用。除了自动关闭文件之外,with 块还有很多用途
else 子句和 with 没关系,不过这两个都内容比较短,所以放到了一个逻辑
else 子句不仅能在 if 语句中使用,还能在 for,while,try 语句中使用
else 子句行为如下:
for: 仅当 for 循环运行完毕时(即 for 循环没有被 break 语句终止)才运行 else
try: 仅当 try 块中没有异常时候才运行 else 块,else 子句抛出的异常不会由前面的 except 子句处理
在所有情况下,如果异常或者 return, break 或 continue 语句导致控制权跳到了复合语句之外,else 也会被跳过
for 循环用 else 如下:
In [1]:
# for item in my_list:
# if item.flavor == 'banana':
# break
# else:
# raise ValueError('No banana flavor found!')
一开始你可能觉得没必要在 try/except 中使用 else 子句,毕竟下面代码中只有 dangerous_cal() 不抛出异常 after_call() 才会执行
In [2]:
# try:
# dangerous_call()
# after_call()
# except OSError:
# log('OSError...')
然而,after_call() 不应该放在 try 块中。为了清晰准确,try 块应该只抛出预期异常的语句,因此像下面这样写更好:
In [3]:
# try:
# dangerous_call()
# except OSError:
# log('OSError...')
# else:
# after_call()
现在很明确,try 为了捕获的是 dangerous_call() 的异常。
Python 中,try/except 不仅用于处理错误,还用于控制流程,为此,官方定义了几个缩略词:
EAFP: 取得原谅比获得许可容易(easier to ask for forgiveness than permission)。这是一种常见的 Python 编程风格,先假定存在有效 的键或属性,如果假定不成立,那么捕获异常。这种风格简单明 快,特点是代码中有很多 try 和 except 语句。与其他很多语言一 样(如 C 语言),这种风格的对立面是 LBYL 风格。
LBYL 三思而后行(look before you leap)。这种编程风格在调用函数 或查找属性或键之前显式测试前提条件。与 EAFP 风格相反,这种 风格的特点是代码中有很多 if 语句。在多线程环境中,LBYL 风 格可能会在“检查”和“行事”的空当引入条件竞争。例如,对 if key in mapping: return mapping[key] 这段代码来说,如果 在测试之后,但在查找之前,另一个线程从映射中删除了那个键, 那么这段代码就会失败。这个问题可以使用锁或者 EAFP 风格解 决。 如果选择使用 EAFP 风格,那就要更深入地了解 else 子句,并在 try/except 中合理使用
上下文管理器对象存在的目的是管理 with 语句,就像迭代器存在是为了管理 for 语句。
with 语句目的是为了简化 try/finally 模式。上下文管理器协议包含 __enter__
和 __exit__
方法,with 开始时,会调用 __enter__
方法,结束时候会调用 __exit__
方法
最常见的是打开文件:
In [4]:
with open('with.ipynb') as fp:
src = fp.read(60)
len(src)
Out[4]:
In [5]:
fp
Out[5]:
In [6]:
fp.closed, fp.encoding
Out[6]:
In [7]:
# fp 虽然可用,但不能执行 I/O 操作,
# 因为在 with 末尾,调用 TextIOWrapper.__exit__ 关闭了文件
fp.read(60)
with 的 as 子句是可选的,对 open 来说,必须加 as 子句,以便获取文件的引用。不过,有些上下文管理器会返回 None,因为没有什么有用的对象能提供给用户
下面是一个精心制作的上下文管理器执行操作,以此强调上下文管理器与 __enter__
方法返回的对象之间的区别
In [7]:
class LookingGlass:
def __enter__(self): # enter 只有一个 self 参数
import sys
self.original_write = sys.stdout.write # 保存供日后使用
sys.stdout.write = self.reverse_write # 打猴子补丁,换成自己方法
return 'JABBERWOCKY' # 返回的字符串讲存入 with 语句的 as 后的变量
def reverse_write(self, text): #取代 sys.stdout.write,反转 text
self.original_write(text[::-1])
# 正常传的参数是 None, None, None,有异常传如下异常信息
def __exit__(self, exc_type, exc_value, traceback):
import sys # 重复导入不会消耗很多资源,Python 会缓存导入模块
sys.stdout.write = self.original_write # 还原 sys.stdout.write 方法
if exc_type is ZeroDivisionError: # 如果有除 0 异样,打印消息
print('Please DO NOT divide by zero')
return True # 返回 True 告诉解释器已经处理了异常
# 如果 __exit__ 方法返回 None,或者 True 之外的值,with 块中的任何异常都会向上冒泡
In [2]:
with LookingGlass() as what:
print('Alice, Kitty and Snowdrop') #打印出的内容是反向的
print(what)
In [3]:
# with 执行完毕,可以看出 __enter__ 方法返回的值 -- 即存储在 what 变量中的值是 'JABBERWOCKY'
what
Out[3]:
In [4]:
print('Back to normal') # 输出不再是反向的了
在实际应用中,如果程序接管了标准输出,可能会把 sys.stdout 换成类似文件的其他对象,然后再切换成原来的版本。contextlib.redirect_stdout 上下文管理器就是这么做的
解释器调用 enter 方法时,除了隐式的 self 之外,不会传入任何参数,传给 __exit__
的三个参数如下:
exc_type: 异常类(例如 ZeroDivisionError)
exc_value: 异常实例。有时好有参数传给异常构造方法,例如错误消息,参数可以通过 exc_value.args 获取
traceback: traceback 对象
上下文管理器具体工作方式如下:
In [12]:
# In [2]: manager = LookingGlass()
# ...: manager
# ...:
# Out[2]: <__main__.LookingGlass at 0x7f586d4aa1d0>
# In [3]: monster = manager.__enter__()
# In [4]: monster == 'JABBERWOCKY'
# Out[4]: eurT
# In [5]: monster
# Out[5]: 'YKCOWREBBAJ'
# In [6]: manager.__exit__(None, None, None)
# In [7]: monster
# Out[7]: 'JABBERWOCKY'
上面在命令行执行的,因为在 jupyter notebook 的输出有时候有莫名其妙的 bug
自定义上下文管理器类之前,先看一下 Python 标准库文档中的 contextlib。除了前面提到的 redirect_stdout 函数,contextlib 模块中还有一些类和其它函数,实用范围更广
closing: 如过对象提供了 close() 方法,但没有实现 __enter__/__exit__
协议,可以实用这个函数构建上下文管理器
suppress: 构建临时忽略指定异常的上下文管理器
@contextmanager: 这个装饰器把简单的生成器函数变成上下文管理器,这样就不用创建类去实现管理协议了
ContextDecorator: 这是个基类,用于定义基于类的上下文管理器。这种上下文管理器也能用于装饰函数,在受管理的上下文中运行整个函数
ExitStack: 这个上下文管理器能进入多个上下文管理器,with 块结束时,ExitStack 按照后进先出的顺序调用栈中各个上下文管理器的 __exit__
方法。如果事先不知道 with 块要进入多少个上下文管理器,可以使用这个类。例如同时打开任意一个文件列表中的所有文件
这些工具中使用最广泛的是 @contextmanager 装饰器,因此要格外小心,这个装饰器也有迷惑人的一面,因为它与迭代无关,却使用 yield 语句,由此可以引出协程
@contextmanager 装饰器能减少创建上下文管理器的样板代码量,因为不用编写一个完整的类,定义 __enter__
和 __exit__
方法,而只需实现一个有 yield 语句的生成器,生成想让 __enter__
方法返回的值
在使用 @contextmanager 装饰器能减少创建上下文管理器的样板代码量,因为不用编写一个完整的类,定义 __enter__
和 __exit__
方法,而只需实现有一个 yield 语句的生成器,生成想让 __enter__
方法返回的值
在使用 @contextmanager 装饰器的生成器中,yield 语句的作用是把函数的定义体分成两个部分:yield 语句前面所有代码在 with 块开始时(即解释器调用 __enter__
方法时)执行,yield 语句后面的代码在 with 块结束时(即调用 __exit__
方法时)执行
In [1]:
import contextlib
@contextlib.contextmanager
def looking_glass():
import sys
original_write = sys.stdout.write
def reverse_write(text):
original_write(text[::-1])
sys.stdout.write = reverse_write
# 产生一个值,这个值会绑定到 with 语句的 as 子句后的目标变量上
# 执行 with 块中的代码时,这个函数会在这一点暂停
yield 'JABBERWOCKY'
# 控制权一旦跳出 with 块,继续执行 yield 语句后的代码
sys.stdout.write = original_write
In [2]:
with looking_glass() as what:
print('Alice, Kitty and Snowdrop')
print(what)
其实,contextlib.contextmanager 装饰器会把函数包装成实现 __enter__
和 __exit__
方法的类
这个类的 __enter__
作用如下:
with/as
语句中目标变量上with 块终止时,__exit__
方法会做以下几件事
检查有没有把异常传给 exc_type, 如果有,调用 gen.throw(exception), 在生成器函数定义体中包含 yield 关键字的那一行跑出异常
否则,调用 next(gen),继续执行生成器函数体中 yield 语句之后的代码
上面的例子其实有一个严重的错误,如果在 with 块中抛出了异常,Python 解释器会将其捕获,然后在 looking_glass 函数的 yield 表达式再次跑出,但是,那里没有处理错误的代码,因此 looking_glass 函数会终止,永远无法恢复成原来的 sys.stdout.write 方法,导致系统处于无效状态,下面添加了一些代码,用于处理 ZeroDivisionError 异常,这样就比较健壮了
In [3]:
import contextlib
@contextlib.contextmanager
def looking_glass():
import sys
original_write = sys.stdout.write
def reverse_write(text):
original_write(text[::-1])
sys.stdout.write = reverse_write
msg = ''
try:
yield 'JABBERWOCKY'
except ZeroDivisionError:
msg = 'Please DO NOT divide by zero'
finally:
sys.stdout.write = original_write
if msg:
print(msg)
前面说过,为了告诉解释器异常已经处理了,__exit__
方法返回 True,此时解释器会压制异常。如果 __exit__
方法没有显式返回一个值,那么解释器得到的是 None,然后向上冒泡异常。使用 @contextmanager 装饰器时,默认行为是相反的,装饰器提供的 __exit__
方法假定发给生成器的所有异常都得到处理了,因此应该压制异常。如果不想让 @contextmanager
压制异常,必须在装饰器的函数中显式重新跑出异常
把异常发给生成器的方式是使用 throw 方法,下章讲
这样的约定的原因是,创建上下文时,生成器无法返回值,只能产出值。不过现在可以返回值了,见下章
使用 @contextmanager 装饰器时,要把 yield 语句放到 try/finally 语句中(或者放在 with 语句中),这是无法避免的,因为我们永远不知道上下文管理器用户会在 with 块中做什么
除了标准库中举得例子外,Martijin Pieters 实现原地文件重写上下文管理器是 @contextmanager 不错的使用实例,如下:
In [4]:
# import csv
# with inplace(csvfilename, 'r', newline='') as (infh, outfh):
# reader = csv.reader(infh)
# writer = csv.writer(outfh)
# for row in reader:
# row += ['new', 'columns']
# writer.writerow(row)
inplace 函数是个上下文管理器,为同一个文件提供了两个句柄(这个示例中的 infh 和 outfh),以便同时读写同一个文件。这比标准库中的 fileinput.input 函数更易用
注意,在 @contextmanager 装饰器装饰的生成器中,yield 与迭代没有任何关系。在本节所举的示例中,生成器函数的作用更像是协程:执行到某一点时暂停,让客户代码运行,直到客户让协程继续做事。下章会全面讨论协程。