这篇文章学习一些在其他语言中不常见的控制流功能,包括 with 语句和上下文管理器协议,匹配模式的 match/case 以及 for、while 和 try 语句中的 else 子句。
上下文管理器
with 语句设置一个临时上下文,交给上下文管理器对象控制,并且负责清理上下文。这么做能避免错误并减少样板代码,因此 API 更安全、更易于使用。上下文管理器对象存在的目的是管理 with 语句,就像迭代器的存在是为了管理 for 语句一样。with 语句的目的是简化一些常用的 try/finally 结构。这种结构可以保证一段代码运行完毕后执行某项操作,即便那段代码由于 return 语句、异常或 sys.exit() 调用而终止,也执行指定的操作。
上下文管理器接口包含 __enter__ 和 __exit__ 两个方法:
- with 语句开始运行时,Python 在上下文管理器对象上调用
__enter__方法 - with 块运行结束,或者由于什么原因终止后,Python 在上下文管理器对象上调用
__exit__方法
最常见的例子是确保关闭文件对象:
1 | with open('t.sh') as fp: |
- 这里需要注意,with 块后,fp 变量仍然可用,与函数不同,with 块不定义新的作用域
求解 with 后面的表达式得到的结果是上下文管理器对象,不过,绑定到目标变量(在 as 子句中)上的值是在上下文管理器对象上调用 __enter__ 方法返回的结果:
- 碰巧
open()函数返回一个 TextIOWrapper 实例,而该实例的__enter__方法返回 self - 不过,其他类的
__enter__方法可能会返回其他对象,而不返回上下文管理器实例 - 不管控制流以哪种方式退出 with 块,都在上下文管理器对象上调用
__exit__方法,而不是在__enter__方法返回的对象上调用 - with 语句的 as 子句是可选的
如下是一个例子,它强调了上下文管理器与 __enter__ 方法返回的对象之间是有区别的:
1 | import sys |
- 这个上下文管理器是一个
LookingGlass实例 - Python 在上下文管理器上调用
__enter__方法,把返回结果绑定到 what 上
如果__exit__方法返回 None 或其他假值,那么 with 块抛出的任何异常都会向上冒泡。如果是返回 True,则告诉解释器异常已经被处理
如果一切正常,那么 Python 调用 __exit__ 方法时传入的参数是 None, None, None,如果抛出了异常,那么这 3 个参数是异常数据:
- exc_type:异常类(例如 ZeroDivisionError)
- exc_value:异常实例。有时会有参数传给异常构造函数,例如错误消息,这些参数可以通过
exc_value.args获取 - traceback:traceback 对象
上下文管理器的具体工作方式如下所示,我们在 with 块之外使用 LookingGlass 类,因此可以手动调用 __enter__ 和 __exit__ 方法:
1 | from mirror import LookingGlass |
- 在
manager上调用__enter__()方法,把结果存储在 monster 中 - monster 的值是字符串
JABBERWOCKY。打印出的 True 标识符是反向的,因为 stdout 的所有输出都经过__enter__方法中打补丁的 write 方法处理 - 调用
manager.__exit__,还原成之前的stdout.write
Python 3.10 采用了新型解析器,比旧的 LL(1) 解析器更强大,可用的句法也多了。其中一个新句法是带括号的上下文管理器:
1 | with ( |
contextlib 包中的实用工具
标准库中的 contextlib 包提供了一些函数、类和装饰器,方便构建、组合和使用上下文管理器。
- closing:如果对象提供了
close()方法,但没有实现__enter__/__exit__接口,则可以使用这个函数构建上下文管理器 - suppress:构建临时忽略指定异常的上下文管理器
- nullcontext:一个什么也不做的上下文管理器,可以简化未实现合适上下文管理器的对象周围的条件逻辑。当你不确定 with 块之前的条件代码有没有为 with 语句提供上下文管理器时,就可以使用 nullcontext 代替
@contextmanager这个装饰器把简单的生成器函数变成上下文管理器,免得创建类去实现上下文管理器协议AbstractContextManager确立上下文管理器接口的抽象基类。子类化该基类创建上下文管理器类会更容易一些ContextDecorator这个基类用于定义基于类的上下文管理器,这种上下文管理器也可用作函数装饰器,在受管理的上下文中运行整个函数ExitStack:这个上下文管理器能进入多个上下文管理器。with 块结束时,ExitStack 按照后进先出(LIFO)顺序调用栈中各个上下文管理器的__exit__方法。如果你事先不知道 with 块要进入多少个上下文管理器,则可以使用这个类。ExitStack 与 Go 语言中的 defer 语句(我认为这是 Go 语言最优秀的功能之一)作用相似,但是更灵活
Python3.7 还为 contextlib 模块增加了 AbstractAsyncContextManager、@asynccontextmanager 和 AsyncExitStack。它们的作用与名称中不带 async 的版本类似,只不过供新出现的 async with 语句。
使用 @contextmanager
@contextmanager 装饰器是一个巧妙且实用的工具,将 Python 的 3 个不同功能结合在一起:函数装饰器、生成器 和 with 语句。
使用 @contextmanager 能减少创建上下文管理器的样板代码,因为不用编写一个完整的类来定义 __enter__ 和 __exit__ 方法,而只需实现有一个含有 yield 语句的生成器,生成想让 __enter__ 方法返回的值。
在使用 @contextmanager 装饰的生成器中,yield 把函数的主体分成两部分
- yield 前面的所有代码在
with块开始时(解释器调用__enter__方法时)执行 - yield 产出一个值,这个值绑定到 with 语句中 as 子句的目标变量上。这个函数在该处暂停,with 块的主体开始执行
- yield 后面的代码在with块结束时(调用
__exit__方法时)执行
1 | import contextlib |
其实,contextlib.contextmanager 装饰器把函数包装成实现了 __enter__ 和 __exit__ 方法的类。那个类的 __enter__ 方法有以下作用:
- 调用生成器函数,获取生成器对象(姑且称之为gen)
- 调用
next(gen),驱动生成器对象执行到 yield 关键字所在的位置 - 返回
next(gen)产出的值,以便把产出的值绑定到with/as语句中的目标变量上
with 块终止时,__exit__ 方法做以下几件事:
- 检查有没有把异常传给
exc_type;如果有,则调用gen.throw(exception),在生成器函数主体中
yield 关键字所在的行抛出异常 - 否则,调用
next(gen),恢复执行生成器函数主体中 yield 后面的代码
从上面的解释来看,我们需要在 yield 处处理可能 with 语句主体内可能抛出的异常:
1 | import contextlib |
1 | from mirror_gen import looking_glass |
之前说过,为了告诉解释器异常已经处理了,__exit__ 方法会返回一个真值,此时解释器压制(suppress)异常。然而,如果 __exit__ 方法没有显式返回一个值,那么解释器得到的是 None,此时异常向上冒泡。使用 @contextmanager 装饰器时,默认行为是相反的:装饰器提供的 __exit__ 方法假定发给生成器的所有异常都得到处理了,因此应该压制异常。
使用 @contextmanager 装饰器时,要把 yield 语句放在 try/finally 语句中,这是不可避免的,因为我们永远不知道上下文管理器的用户会在 with 块中做什么。
@contextmanager 还有一个鲜为人知的功能:它装饰的生成器也可用作装饰器。这是因为 @contextmanager 是由 contextlib.ContextDecorator 类实现的。
1 | @looking_glass() |
with 语句是非常了不起的功能。建议在实践中深挖这个功能的用途。使用 with 语句或许能做一些意义深远的事情。
案例分析:lis.py 中的模式匹配
与 Python 不同,Scheme 不区分表达式和语句,也没有中缀运算符,所有表达式都使用前置表示法。Scheme 和多数 Lisp 方言使用的表示法叫作 S 表达式。如下所示:
1 | (define (mod m n) |
1 | import math |
parse 输出的是抽象句法树(Abstract Syntax Tree,AST):方便起见,把 Scheme 程序表示成嵌套列表,用一种树状结构展示,最外层列表是树干,内层列表是树枝,原子结构则是树叶。
Environment 类扩展 collections.ChainMap 用于实现嵌套作用域,增加 change 方法,更新串联的某个字典中的值:
1 | class Environment(ChainMap[Symbol, Any]): |
1 | from lis import Environment |
1 | def standard_env() -> Environment: |
REPL(read-eval-print loop,“读取-求值-输出”循环)的实现如下:
1 | def repl(prompt: str = 'lis.py> ') -> NoReturn: |
接下来则是求值函数 evaluate 的实现:
1 | KEYWORDS = ['quote', 'if', 'lambda', 'define', 'set!'] |
下面的代码,则定义了闭包的实现:
1 | class Procedure: |
之前代码中出现的 case int(x) | float(x): 是 OR 模式。用 | 分隔的模式称为 OR 模式:只要有一个子模式匹配,整个模式就匹配。在 OR 模式中,所有子模式必须使用相同的变量。做出这个限制的目的是确保无论哪个子模式匹配,变量在看守表达式和 case 主体中均可用。
先做这个,再做那个:if 语句之外的 else 块
else 子句不仅能在 if 语句中使用,还能在 for、while 和 try 语句中使用。for/else、while/else 和 try/else 的语义联系紧密,不过与 if/else 差别很大。
for/else:仅当 for 循环运行完毕时(即 for 循环没有被 break 语句中止)才运行 else 块while/else:仅当while循环因条件为假值而退出时(即 while 循环没有被 break 语句中止)才运行 else 块try/else:仅当 try 块没有抛出异常时才运行 else 块。官方文档还指出:else 子句抛出的异常不由前面的 except 子句处理
在所有情况下,如果异常或者 return、break 或 continue 语句导致控制权跳到了复合语句的主块之外,则 else 子句也被跳过。
在这些语句中使用 else 子句通常能让代码更易于阅读,而且能省去一些麻烦,不用设置控制标志或者添加额外的 if 语句。在循环中使用 else 子句的一般方式如下述代码片段所示:
1 | for item in my_list: |
try/except 块中使用 else 子句有时也是必要的,对比如下两个代码:
1 | try: |
after_call() 不应该放在 try 块中。为了清晰和准确,只应把可能抛出预期异常的语句放在 try 块中:
1 | try: |
现在很明确,try 块防守的是 dangerous_call() 可能出现的错误,而不是 after_call()。而且很明显,只有 try 块不抛出异常,after_call() 才会执行。
在 Python 中,try/except 不仅用于处理错误,还常用于控制流程。为此,Python 官方术语表还定义了一个缩略词(口号):取得原谅比获得许可容易(Easier to ask for forgiveness than permission):
- 这是一种常用的Python编程风格,先假定存在有效的键或属性,如果假设不成立,那么捕获异常
- 这种风格简单明快,特点是代码中有很多 try和 except 语句。
这种风格的对立面是 LBYL 风格:三思而后行(Look before you leap)。这种编程风格在调用函数或查找属性或键之前显式测试前提条件。与 EAFP 风格相比,这种风格的特点是代码中有很多 if 语句。在多线程环境中,LBYL 风格可能会在 检查 和 行事 的空隙引入条件竞争。
如果选择使用 EAFP 风格,那就要更深入地了解 else 子句,并在 try/except 语句中合理使用。