本章讨论其他语言不常见的流程控制,用户可能会忽略这些特性:

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

with 语句会设置一个临时的上下文,交给上下文管理器对象控制,并负责清理上下文。这么做能避免错误并减少样板代码,因此 API 更安全,更易于使用。除了自动关闭文件之外,with 块还有很多用途

else 子句和 with 没关系,不过这两个都内容比较短,所以放到了一个逻辑

先做这个,再做那个: if 之外的 else 块

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 块

上下文管理器对象存在的目的是管理 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]:
60

In [5]:
fp


Out[5]:
<_io.TextIOWrapper name='with.ipynb' mode='r' encoding='UTF-8'>

In [6]:
fp.closed, fp.encoding


Out[6]:
(True, 'UTF-8')

In [7]:
# fp 虽然可用,但不能执行 I/O 操作,
# 因为在 with 末尾,调用 TextIOWrapper.__exit__ 关闭了文件
fp.read(60)


---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-7-a91ac20aa9d8> in <module>()
      1 # fp 虽然可用,但不能执行 I/O 操作,
      2 # 因为在 with 末尾,调用 TextIOWrapper.__exit__ 关闭了文件
----> 3 fp.read(60)

ValueError: I/O operation on closed file.

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)


pordwonS dna yttiK ,ecilA
YKCOWREBBAJ

In [3]:
# with 执行完毕,可以看出 __enter__ 方法返回的值 -- 即存储在 what 变量中的值是 'JABBERWOCKY' 
what


Out[3]:
'JABBERWOCKY'

In [4]:
print('Back to normal') # 输出不再是反向的了


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

contextlib 模块中的实用工具

自定义上下文管理器类之前,先看一下 Python 标准库文档中的 contextlib。除了前面提到的 redirect_stdout 函数,contextlib 模块中还有一些类和其它函数,实用范围更广

closing: 如过对象提供了 close() 方法,但没有实现 __enter__/__exit__ 协议,可以实用这个函数构建上下文管理器

suppress: 构建临时忽略指定异常的上下文管理器

@contextmanager: 这个装饰器把简单的生成器函数变成上下文管理器,这样就不用创建类去实现管理协议了

ContextDecorator: 这是个基类,用于定义基于类的上下文管理器。这种上下文管理器也能用于装饰函数,在受管理的上下文中运行整个函数

ExitStack: 这个上下文管理器能进入多个上下文管理器,with 块结束时,ExitStack 按照后进先出的顺序调用栈中各个上下文管理器的 __exit__ 方法。如果事先不知道 with 块要进入多少个上下文管理器,可以使用这个类。例如同时打开任意一个文件列表中的所有文件

这些工具中使用最广泛的是 @contextmanager 装饰器,因此要格外小心,这个装饰器也有迷惑人的一面,因为它与迭代无关,却使用 yield 语句,由此可以引出协程

使用 @contextmanager

@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)


pordwonS dna yttiK ,ecilA
YKCOWREBBAJ

其实,contextlib.contextmanager 装饰器会把函数包装成实现 __enter____exit__ 方法的类

这个类的 __enter__ 作用如下:

  • 调用生成器函数,保存生成器对象(这里称为 gen)
  • 调用 next(gen),执行到 yield 关键字位置
  • 返回 next(gen) 产生的值,以便把产生的值绑定到 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 与迭代没有任何关系。在本节所举的示例中,生成器函数的作用更像是协程:执行到某一点时暂停,让客户代码运行,直到客户让协程继续做事。下章会全面讨论协程。