0%

流畅的 Python 第 2 版(18):with、match 和 else 块

这篇文章学习一些在其他语言中不常见的控制流功能,包括 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
2
3
4
5
6
7
8
9
>>> with open('t.sh') as fp:
... src = fp.read(60)
...
>>> fp
<_io.TextIOWrapper name='t.sh' mode='r' encoding='UTF-8'>
>>> fp.closed
True
>>> fp.encoding
'UTF-8'
  • 这里需要注意,with 块后,fp 变量仍然可用,与函数不同,with 块不定义新的作用域

求解 with 后面的表达式得到的结果是上下文管理器对象,不过,绑定到目标变量(在 as 子句中)上的值是在上下文管理器对象上调用 __enter__ 方法返回的结果

  • 碰巧 open() 函数返回一个 TextIOWrapper 实例,而该实例的 __enter__ 方法返回 self
  • 不过,其他类的 __enter__ 方法可能会返回其他对象,而不返回上下文管理器实例
  • 不管控制流以哪种方式退出 with 块,都在上下文管理器对象上调用 __exit__ 方法,而不是在 __enter__ 方法返回的对象上调用
  • with 语句的 as 子句是可选的

如下是一个例子,它强调了上下文管理器与 __enter__ 方法返回的对象之间是有区别的

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
import sys

class LookingGlass:

def __enter__(self):
self.original_write = sys.stdout.write
sys.stdout.write = self.reverse_write
return 'JABBERWOCKY'

def reverse_write(self, text):
self.original_write(text[::-1])

def __exit__(self, exc_type, exc_value, traceback):
sys.stdout.write = self.original_write
if exc_type is ZeroDivisionError:
print('Please DO NOT divide by zero!')
return True

>>> with LookingGlass() as what:
... print("hello")
... print(what)
...
olleh
YKCOWREBBAJ

>>> print('hello')
hello
>>> print(what)
JABBERWOCKY
  • 这个上下文管理器是一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>>> from mirror import LookingGlass
>>> manager = LookingGlass()

>>> manager # doctest: +ELLIPSIS
<mirror.LookingGlass object at 0x...>

>>> monster = manager.__enter__()
>>> monster == 'JABBERWOCKY'
eurT

>>> monster
'YKCOWREBBAJ'

>>> manager # doctest: +ELLIPSIS
>... ta tcejbo ssalGgnikooL.rorrim<

>>> manager.__exit__(None, None, None)
>>> monster
'JABBERWOCKY'
  • manager 上调用 __enter__() 方法,把结果存储在 monster 中
  • monster 的值是字符串 JABBERWOCKY。打印出的 True 标识符是反向的,因为 stdout 的所有输出都经过 __enter__ 方法中打补丁的 write 方法处理
  • 调用 manager.__exit__,还原成之前的 stdout.write

Python 3.10 采用了新型解析器,比旧的 LL(1) 解析器更强大,可用的句法也多了。其中一个新句法是带括号的上下文管理器:

1
2
3
4
5
6
with (
CtxManager1() as example1,
CtxManager2() as example2,
CtxManager3() as example3,
):
...

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@asynccontextmanagerAsyncExitStack。它们的作用与名称中不带 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
2
3
4
5
6
7
8
9
10
11
12
13
import contextlib
import sys

@contextlib.contextmanager
def looking_glass():
original_write = sys.stdout.write

def reverse_write(text):
original_write(text[::-1])

sys.stdout.write = reverse_write
yield 'JABBERWOCKY'
sys.stdout.write = original_write

其实,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import contextlib
import sys

@contextlib.contextmanager
def looking_glass():
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)
1
2
3
4
5
6
7
8
9
10
11
>>> from mirror_gen import looking_glass
>>> with looking_glass() as what:
... print('Alice, Kitty and Snowdrop')
... print(what)
...
pordwonS dna yttiK ,ecilA
YKCOWREBBAJ
>>> what
'JABBERWOCKY'
>>> print('back to normal')
back to normal

之前说过,为了告诉解释器异常已经处理了,__exit__ 方法会返回一个真值,此时解释器压制(suppress)异常。然而,如果 __exit__ 方法没有显式返回一个值,那么解释器得到的是 None,此时异常向上冒泡。使用 @contextmanager 装饰器时,默认行为是相反的:装饰器提供的 __exit__ 方法假定发给生成器的所有异常都得到处理了,因此应该压制异常。

使用 @contextmanager 装饰器时,要把 yield 语句放在 try/finally 语句中​,这是不可避免的,因为我们永远不知道上下文管理器的用户会在 with 块中做什么

@contextmanager 还有一个鲜为人知的功能:它装饰的生成器也可用作装饰器。这是因为 @contextmanager 是由 contextlib.ContextDecorator 类实现的。

1
2
3
4
5
6
7
8
>>> @looking_glass()
... def verse():
... print('The time has come')
...
>>> verse()
emoc sah emit ehT
>>> print('back to normal')
back to normal

with 语句是非常了不起的功能。建议在实践中深挖这个功能的用途。使用 with 语句或许能做一些意义深远的事情。

案例分析:lis.py 中的模式匹配

与 Python 不同,Scheme 不区分表达式和语句,也没有中缀运算符,所有表达式都使用前置表示法。Scheme 和多数 Lisp 方言使用的表示法叫作 S 表达式。如下所示:

1
2
3
4
5
6
7
8
9
(define (mod m n)
(- m (* n (quotient m n))))

(define (gcd m n)
(if (= n 0)
m
(gcd n (mod m n))))

(display (gcd 18 45))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import math
import operator as op
from collections import ChainMap
from itertools import chain
from typing import Any, TypeAlias, NoReturn

Symbol: TypeAlias = str
Atom: TypeAlias = float | int | Symbol
Expression: TypeAlias = Atom | list

def parse(program: str) -> Expression:
"从字符串中读取Scheme表达式。"
return read_from_tokens(tokenize(program))

def tokenize(s: str) -> list[str]:
"把字符串转换成词法单元列表。"
return s.replace('(', ' ( ').replace(')', ' ) ').split()

def read_from_tokens(tokens: list[str]) -> Expression:
"从一系列词法单元中读取表达式。"
# 排版需要,省略了很多解析代码

parse 输出的是抽象句法树(Abstract Syntax Tree,AST):方便起见,把 Scheme 程序表示成嵌套列表,用一种树状结构展示,最外层列表是树干,内层列表是树枝,原子结构则是树叶。

Environment 类扩展 collections.ChainMap 用于实现嵌套作用域,增加 change 方法,更新串联的某个字典中的值:

1
2
3
4
5
6
7
8
9
class Environment(ChainMap[Symbol, Any]):
"ChainMap的子类,允许就地更改项。"

def change(self, key: Symbol, value: Any) -> None:
for map in self.maps:
if key in map:
map[key] = value # type: ignore[index]
return
raise KeyError(key)
1
2
3
4
5
6
7
8
9
10
11
12
13
>>> from lis import Environment
>>> inner_env = {'a': 2}
>>> outer_env = {'a': 0, 'b': 1}
>>> env = Environment(inner_env, outer_env)
>>> env['a']
2
>>> env['a'] = 111
>>> env['c'] = 222
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 1})
>>> env.change('b', 333)
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 333})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def standard_env() -> Environment:
env = Environment()
env.update(vars(math)) # sin、cos、sqrt、pi等
env.update({
'+': op.add,
'-': op.sub,
'*': op.mul,
'/': op.truediv,
'abs': abs,
'append': lambda *args: list(chain(*args)),
'apply': lambda proc, args: proc(*args),
'begin': lambda *x: x[-1],
'car': lambda x: x[0],
'cdr': lambda x: x[1:],
'number?': lambda x: isinstance(x, (int, float)),
'procedure?': callable,
'round': round,
'symbol?': lambda x: isinstance(x, Symbol),
})
return env

REPL(read-eval-print loop,​“读取-求值-输出”循环)的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def repl(prompt: str = 'lis.py> ') -> NoReturn:
global_env = Environment({}, standard_env())
while True:
ast = parse(input(prompt))
val = evaluate(ast, global_env)
if val is not None:
print(lispstr(val))


def lispstr(exp: object) -> str:
if isinstance(exp, list):
return '(' + ' '.join(map(lispstr, exp)) + ')'
else:
return str(exp)

接下来则是求值函数 e​​valuate 的实现:

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
KEYWORDS = ['quote', 'if', 'lambda', 'define', 'set!']

def evaluate(exp: Expression, env: Environment) -> Any:
match exp:
case int(x) | float(x):
return x
case Symbol(var):
return env[var]
case ['quote', x]:
return x
case ['if', test, consequence, alternative]:
if evaluate(test, env):
return evaluate(consequence, env)
else:
return evaluate(alternative, env)
case ['lambda', [*parms], *body] if body:
return Procedure(parms, body, env)
case ['define', Symbol(name), value_exp]:
env[name] = evaluate(value_exp, env)
case ['define', [Symbol(name), *parms], *body] if body:
env[name] = Procedure(parms, body, env)
case ['set!', Symbol(name), value_exp]:
env.change(name, evaluate(value_exp, env))
case [func_exp, *args] if func_exp not in KEYWORDS:
proc = evaluate(func_exp, env)
values = [evaluate(arg, env) for arg in args]
return proc(*values)
case _:
raise SyntaxError(lispstr(exp))

下面的代码,则定义了闭包的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Procedure:
def __init__(
self, parms: list[Symbol], body: list[Expression], env: Environment
):
self.parms = parms
self.body = body
self.env = env

def __call__(self, *args: Expression) -> Any:
local_env = dict(zip(self.parms, args))
env = Environment(local_env, self.env)
for exp in self.body:
result = evaluate(exp, env)
return result

之前代码中出现的 case int(x) | float(x): 是 OR 模式。用 | 分隔的模式称为 OR 模式:只要有一个子模式匹配,整个模式就匹配。在 OR 模式中,所有子模式必须使用相同的变量。做出这个限制的目的是确保无论哪个子模式匹配,变量在看守表达式和 case 主体中均可用。

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

else 子句不仅能在 if 语句中使用,还能在 for、while 和 try 语句中使用。for/elsewhile/elsetry/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
2
3
4
5
for item in my_list:
if item.flavor == 'banana':
break
else:
raise ValueError('No banana flavor found!')

try/except 块中使用 else 子句有时也是必要的,对比如下两个代码:

1
2
3
4
5
try:
dangerous_call()
after_call()
except OSError:
log('OSError...')

after_call() 不应该放在 try 块中。为了清晰和准确,只应把可能抛出预期异常的语句放在 try 块中:

1
2
3
4
5
6
try:
dangerous_call()
except OSError:
log('OSError...')
else:
after_call()

现在很明确,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 语句中合理使用。