梅贾的窃魂卷(2/25)——Context Managers(with)
为什么需要Context Managers(Motivation)
先来看一个读写文件的例子。
1 | f = open('hello.txt', 'w') |
很明显,在执行f.write
和其他操作的时候可能会出错,这就会导致我们的f
无法被正常关闭。所以,对于诸如此类的资源释放问题,我们一般都会加上异常判断。
1 | f = open('hello.txt', 'w') |
但是这样会显得代码比较繁杂,降低了可读性。所以现在我们一般看到的读写文件的写法都是用with
来写的,它定义了一个Context Manager
。
1 | with open('hello.txt', 'w') as f: |
这种写法可以保证在任何情况下f
都可以被正常关闭,保持简洁同时代码的可读性大大提升。
不仅是读写文件,其他的诸如读写数据库,线程的释放等,都是需要做类似处理的。这就催生出了我们的with
, 它就是专门为简化这种try/finally
的写法而设计的,它保证在运行一段代码后我们总能进行一些操作,即使运行的那段代码出错也不影响。
Context Manager是什么
我们知道,Python的内部实现依赖Duck Type(“If it walks like a duck and it quacks like a duck, then it must be a duck”), 所以一般要实现某种行为,我们只需要对应实现一些必须的protocol
. 就像str(x)
对应__str__
, len(x)
对应__len__
, in
对应__contains__
这样,这里的Context Manager对应__enter__
和__exit__
, 其表现形式一般是一个类(class), 后面也会介绍用已有的装饰器工具和生成器(generator)来构造Context Manager的例子。
好了,我知道要实现这些protocol了,那么,所谓的Context Manager到底长什么样呢?下面就是一个很好的例子(来自Fluent Python)。
1 | class LookingGlass: |
我们先来看下这个Context Manager到底是用来干什么的,之后在具体解释其背后的运行机制。
1 | with LookingGlass() as what: |
可以看到,在with
内打印的内容全部是其真实内容的倒序,如Hello World
变成dlroW olleH
, ABCD
变成DCBA
。退出with
之后打印行为又恢复正常。下面我们来深入解释其背后的原理。
Context Manager运行机制
其实运行机制也非常简单,就是在with LookingGlass() as what
时,执行__enter__
做一些操作(比如这里更改打印行为),并将该函数的返回值赋给as
后面的what
。之后执行with
段的程序(即这里的两个print
)。执行完之后跳出with
段,同时调用__exit__
函数做一些操作(这里是将打印行为恢复正常)。
此外,注意__exit__
中的异常判断,在if exc_type is ZeroDivisionError:
中,我们返回True
表示该异常已经被处理。如果上述异常未触发,该处的__exit__
会默认返回None
,如果在with
段内执行的代码有其他类型的错误,即exc_type
并非ZeroDivisionError
那么错误将会被raise
出来。
创建自己的Context Manager
前面那种基于类的方法是一种可行自定义Context Manager的方法,就是自己定义好__enter__
和__exit__
方法.此外, Python还提供了一些库函数可以帮助我们更快地创建自己的Context Manager. contextlib
库提供了很多的帮助函数,这里我们专注于其中最重要也是最常用的**@contextmanager
装饰器**, 其可以十分方便地将生成器转化为一个Context Manager(此处也向我们展示了生成器不是只能用于迭代,也可以用于此处,以及后面可能会涉及的协程(coroutine)).
下面我们来看一些@contextmanager
的使用方法,先看一个例子,它用装饰器加生成器的方法实现之前基于类的Context Manager.
1 | import contextlib |
测试其行为是否和之前的实现一样.
1 | with looking_glass() as what: |
可以看到和之前基于类的方法调用是一样,且行为也正如我们预期的那样.
那么,上面的程序到底是怎么运行的呢?换句话说,我们是怎么通过生成器和库提供的装饰器结合来构造Context Manager的呢?
简言之,在生成器(此处是函数looking_glass
)中yield xxx
语句将整个函数体分割为三个部分,yield
之前的部分相当于函数__enter__
的内容, yield
之后的部分相当于函数__exit__
的内容, yield 'ABCD'
返回的ABCD
在执行 with looking_glass() as what
时被绑定到what
(相当于__enter__
中return
的值).
这样,在加上装饰器之后,整个函数被视作一个Context Manager, 在解释器调用__enter__
时,它就执行yield
之前的程序,然后将yield
产生的内容绑定到as
后的变量中(若with f() as xxx
, 即绑定到xxx
).之后执行with
段的程序(上面例子中的两个print
). 执行完毕后,解释器调用__exit__
方法,此时程序去执行yield
之后的部分.这就是使用装饰器和生成器来创建Context Manager的整个流程了.
需要注意的一点是,上面我们并为介绍生成器方法在此时并不完全等价于上面基于类的方法,关键在于**对异常的处理.**就内部细节来看,如果with
语句后的内容执行出错,程序会报错一次.之后由于我们的Context Manager的实现,会在生成器内重新报错一次,在生成器内部的报错会终止我们的程序,使得Context Manager失效,资源无法释放.所以我们必须要对该错误进行处理才可以使得其完全等价于基于类的实现.
1 | import contextlib |
这样修改过,才算是真正写好了一个Context Manager,其和之前基于类的方法是等价的.因为我们不知道用户会用我们写的Context Manager做什么,所以这种内部的异常处理是基于生成器写Context Manager必须付出的代价(Leonardo Rochael).
那么,之前不是说搞Context Manager出来就是为了简化try/finally
的写法吗,这里不是更加复杂了吗?
我个人感觉这类似封装的思想,我们将资源释放和异常处理统统加到我们的Context Manager里面,在调用的时候只需要一个with
语句,使得代码逻辑更加清晰,也更加容易维护.
具体应用
文件修改(in-place)
从前的叙述中我们可以看到Context Manager的应用好像总是和资源释放等有关,其实并非如此.在Fluent Python中,作者提到Martijn Pieter的一个妙用, 他使用Context Manager来完成文件的就地(in-place)修改.
先定义好Context Managerinplace
,之后可以通过简单的调用完成修改.
1 | import csv |
具体内容参考其博客
程序计时
我们知道装饰器可以用来函数的计时,我们也可以写一个Context Manager完成函数的计时,而且相对更加方便一点.
1 | import time |
输出:
1 | Spend 0.5587637424468994 s |
在梅贾的窃魂卷(1/25)——Decorator and Closure中我们了解到用装饰器计时大概有三个缺点:
1.仅仅可以对某函数计时,对程序块计时需要先将其定义为函数
2.对递归函数的计时需要进一步的处理
3.不灵活,一旦将函数"装饰"起来,一般不能去掉"装饰"
而上面基于Context Manager的计时方法更加灵活且友好.