Python学习之路33-上下文管理器和else块

《流畅的Python》笔记。

本篇主要讨论Python用户常忽略掉的一些流程控制特性,包括上下文管理器和else块。内容包括else与非if关键字的搭配;Python中的上下文管理器,如何自定义上下文管理器,以及contextlib模块中@contextmanager装饰器的用法。

1. if语句之外的else块

else除了和if搭配之外,在Python中,它还能与forwhiletry搭配:

  • for:仅当for循环运行完毕时才运行else
  • while:仅当while循环因为条件为假而退出时才运行else
  • try:仅当try块中没有抛出异常时才运行else块,且else块中抛出的异常不会被前面的except子句处理
  • 在上述三个情况中,如果异常、returnbreakcontinue语句导致控制权跳到了复合语句的主块之外,else子句会被跳过。

在这些语句中使用else字块有事能让代码更易读,而且能省去一些麻烦,不用设置控制标志或者添加额外的if语句,尤其是在和try复合时。try块中的代码应该只含有预计会抛出异常的语句,以下是两种写法的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代码1.1,只有dangerous_all()可能会抛出异常
# 写法1
try:
dangerous_all()
after_call()
except OSError:
log("OSError...")

# 写法2,此写法比上述写法更明确
try:
dangerous_all()
except OSError:
log("OSError...")
else: # 但其实这么写也是多余的
after_call()

但是,并不建议大家在这些关键字后面加else,因为这很容易造成歧异,比如笔者第一眼看到for/else时的理解是:如果不能进入for块,则运行else中的内容,但实际刚好相反。在其他语言中,此时的else一般由关键字then代替,但Python的创建人非常讨厌添加新关键字,所以让else担起了这个职责。许多编程规范的书中也不建议在这些关键字后面添加else块。

补充:在Python中,try/except不仅用于错误处理,还和if/else一样,常用于控制流程,因此,这就形成了两种代码风格:

  • EAFP:“取得原谅比获得许可更容易”(Easier to Ask for Forgiveness than Permission),通俗讲就是“不管会不会抛异常,先运行再说,等抛出了异常再处理”,这种风格的特点就是代码中有很多try/except块;
  • LBYL:“三思而后行”(Look Before You Leap),这种风格就是显式测试前提条件,通俗讲就是“必须合规后才能运行”,这种风格的特点就是代码中有很多if/else块。

2. 上下文管理器和with块

说到上下文管理器,那首先就得说说什么是上下文。笔者第一次接触这个概念的时候很费解,笔者是按语文里的概念来理解的:不就是前一句话后一句话,前一段话后一段话吗,这有什么可管理的?虽然至今笔者也没看到关于“上下文”这个概念的准确定义,但用多了之后,大致能理解为:

某段代码B将整个程序分成了3段,从前到后分别为A,B,C。当运行代码段B时,程序运行环境的某些设定需要发生改变;当退出代码段B后,这些被改变的设置需恢复原样,即保持A和C的一致性。A和B,B和C就称之为上下文。由于某些原因(如程序员大意、抛出异常强制退出等),B中所改变的设置并不总能手动恢复回去,所以,通常将这些设置交由某些对象统一管理,这些对象就叫做上下文管理器

2.1 Python中的上下文管理器

上下文管理器采用的是鸭子类型技术,实现了__enter____exit__两个抽象方法的对象就是上下文管理器。

上下文管理器对象的存在目的是为了管理with语句,而with语句的目的是简化try/finally模式。

with块的经典用法之一就是读写文件:

1
2
3
4
5
6
# 代码2.1
>>> with open("text.txt") as fp: # 变量fp还有一个称呼,叫"句柄"
... pass
...
>>> fp
<_io.TextIOWrapper name="text.txt" mode="r" encoding="UTF-8">

解释

  • with后面的表达式(不包括as部分)得到的结果就是一个上下文管理器。此处open()函数返回了一个TextIOWrapper对象,Python解释器会临时保存这个对象,我们这里将其取名为a
  • with语句块中,Python得到上下文管理器后会首先调用它的__enter__方法,如果with后面跟了as关键字,则该方法的返回值会赋给as后面的变量。上述代码中,当Python得到了a后,调用它的__enter__方法,该方法返回a对象自身(return self),然后变量fp接收这个值。但请注意,并不是所有的上下文管理器的__enter__都返回实例自身
  • 当退出with块时,Python会调用上下文管理器__exit__方法,做最后处理。上述代码中,Python并不是调用fp.__exit__(),而是调用a.__exit__()
  • 与函数和模块不同,with块没有定义新的作用域,所以即便退出了with块,变量fp依然存在。

2.2 自定义上下文管理器

下面我们自定义一个上下文管理器来说明上述四条解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 代码2.2
class LookingGlass:
def __enter__(self): # 该方法只要self一个参数
import sys
self.original_write = sys.stdout.write # 保存原方法
sys.stdout.write = self.reverse_write # 猴子补丁,临时替换原本的方法
return "JABBERWOCKY" # 并不一定是返回self!

def reverse_write(self, text):
self.original_write(text[::-1]) # 反转text内容

def __exit__(self, exc_type, exc_val, exc_tb): # 该方法有4个参数!
import sys # 由于Python会缓存导入的模块,重复导入不会消耗很多资源
sys.stdout.write = self.original_write # 恢复到原本的方法
if exc_type is ZeroDivisionError:
print("Please DO NOT divide by zero!")
return True # 返回True,表示异常已经正常处理

# 控制台中运行
>>> from mirror import LookingGlass
>>> with LookingGlass() as what:
... print("Alice, Kitty and SnowDrop")
... print(what)
...
porDwonS dna yttiK ,ecilA
YKCOWREBBAJ
>>> what
'JABBERWOCKY'
>>> print("Back to normal!")
Back to normal!

解释:

  • __enter__方法只有一个参数,即隐式的self

  • __exit__有四个参数,第一个参数是self,其余三个参数主要用于处理with块运行期间发生的异常,分别是:

    • exc_type:异常
    • exc_val:异常实例with块中发生异常时抛出的对象。如果__exit__想要向上抛出异常,那么在创建异常对象时传入的某些参数可从exc_val.args中获取,比如错误信息。
    • exc_tbtraceback对象。

    如果with块中没有抛出异常,Python调用__exit__方法时传入的参数是三个None,否则传入异常数据。

  • with块中发生异常时:如果__exit__返回True,表示异常已正确处理,Python解释器会压制异常;如果返回的是其它值,with块中的任何异常都会向上冒泡。如果with块中没有发生异常,则不用关注__exit__的返回值。

2.3 contextlib模块

该模块包含了很多管理上下文的使用工具,下面列举出5个:

  • closing:如果对象提供了close()方法,但没有实现__enter__/__exit__协议,则可以使用这个函数构建上下文管理器
  • suppress:构建临时忽略指定异常的上下文管理器
  • @contextmanager这个装饰器很常用,它把简单的生成器函数变成上下文管理器,这样就不用创建类去实现管理器协议
  • ContextDecorator:这是个基类,用于定义基于类的上下文管理器。这种上下文管理器也能用于装饰函数,在受管理的上下文中运行整个函数
  • ExitStack:这个上下文管理器能保存多个上下文管理器。它是一个栈,with结束时,依次调用栈中各个上下文管理器的__exit__方法。如果事先不知道with块要进入多少个上下文管理器,可以使用这个类。例如,同时打开任意一个文件列表中的所有文件。

2.4 @contextmanager

@contextmanager装饰器能减少创建上下文管理器的样板代码量,不用编写一个完整的类,然后再实现__enter____exit__方法,而是只需实现一个仅含单个yield语句的生成器,生成想让__enter__方法返回的值。

在使用@contextmanager装饰的生成器中,yield语句的作用是把函数的定义体分成两部分:yield语句前面的所有代码在with块开始时(即解释器调用__enter__方法时)执行,yield之后的代码在with块结束时(即调用__exit__方法时)执行。

下面我们将之前的LookingGlass类改写为生成器版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 代码2.3
from contextlib import contextmanager

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

# 用法和之前的版本一样:
>>> with looking_glass() as what: # 这里是唯一的变化
... print("Alice, Kitty and SnowDrop")
... print(what)
...
porDwonS dna yttiK ,ecilA
YKCOWREBBAJ
>>> what
'JABBERWOCKY'

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

这个类的__enter__方法有如下作用:

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

它的__exit__方法有如下作用:

  • 检查有没有把异常传给exc_type;如果有,调用gen.throw(exception),在生成器函数定义体中yield所在行抛出异常
  • 否则,调用next(gen),将生成器函数中剩余代码执行完。

前面说到,对一般的上下文管理器,如果with中抛出了异常,Python解释器会根据__exit__的返回值来决定是否压制异常。但@contextmanager则不同:它提供的__exit__方法默认所有异常都得到了处理。如果不想让@contextmanager,必须在被装饰的函数中显式重新抛出异常。

3. 总结

本篇分为了两个部分,首先介绍了elseforwhile以及try的搭配用法(但并不建议这么做,只需要知道能这么用就行了);随后是上下文管理器的内容,介绍了什么是“上下文”,什么是“上下文管理器”,Python中的上下文管理器以及with块,然后我们自定义了一个上下文管理器,最后介绍了contextlib模块,并用其中的@contextmanager装饰器改写了自定义的上下文管理器。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~