第十八章:with、match 和 else 块
上下文管理器可能几乎与子例程本身一样重要。我们只是初步了解了它们。[…] Basic 有一个
with
语句,在许多语言中都有with
语句。但它们的功能不同,它们都只是做一些非常浅显的事情,它们可以避免重复的点式[属性]查找,但它们不进行设置和拆卸。仅仅因为它们有相同的名称,不要认为它们是相同的东西。with
语句是一件大事。Raymond Hettinger,Python 雄辩的传道者
本章讨论的是在其他语言中不太常见的控制流特性,因此往往在 Python 中被忽视或未充分利用。它们包括:
with
语句和上下文管理器协议- 使用
match/case
进行模式匹配 for
、while
和try
语句中的else
子句
with
语句建立了一个临时上下文,并可靠地在上下文管理器对象的控制下将其拆除。这可以防止错误并减少样板代码,同时使 API 更安全、更易于使用。Python 程序员发现 with
块除了自动关闭文件外还有许多其他用途。
我们在之前的章节中已经看到了模式匹配,但在这里我们将看到语言的语法如何可以表示为序列模式。这一观察解释了为什么 match/case
是创建易于理解和扩展的语言处理器的有效工具。我们将研究 Scheme 语言的一个小但功能齐全的子集的完整解释器。相同的思想可以应用于开发模板语言或在更大系统中编码业务规则的 DSL(领域特定语言)。
else
子句并不是一件大事,但在与 for
、while
和 try
一起正确使用时有助于传达意图。
本章新内容
“lis.py 中的模式匹配:案例研究” 是一个新的部分。
我更新了“contextlib 实用工具”,涵盖了自 Python 3.6 以来添加到 contextlib
模块的一些功能,以及 Python 3.10 中引入的新的带括号的上下文管理器语法。
让我们从强大的 with
语句开始。
上下文管理器和 with 块
上下文管理器对象存在以控制 with
语句,就像迭代器存在以控制 for
语句一样。
with
语句旨在简化一些常见的 try/finally
用法,它保证在代码块结束后执行某些操作,即使代码块由 return
、异常或 sys.exit()
调用终止。finally
子句中的代码通常释放关键资源或恢复一些临时更改的先前状态。
Python 社区正在为上下文管理器找到新的创造性用途。标准库中的一些示例包括:
- 在
sqlite3
模块中管理事务—参见“将连接用作上下文管理器”。 - 安全处理锁、条件和信号量,如
threading
模块文档中所述。 - 为
Decimal
对象设置自定义环境进行算术运算—参见decimal.localcontext
文档。 - 为测试修补对象—参见
unittest.mock.patch
函数。
上下文管理器接口由 __enter__
和 __exit__
方法组成。在 with
的顶部,Python 调用上下文管理器对象的 __enter__
方法。当 with
块完成或由于任何原因终止时,Python 调用上下文管理器对象的 __exit__
方法。
最常见的例子是确保文件对象会关闭。示例 18-1 是使用 with
关闭文件的详细演示。
示例 18-1。文件对象作为上下文管理器的演示
>>> with open('mirror.py') as fp: # ① ... src = fp.read(60) # ② ... >>> len(src) 60 >>> fp # ③ <_io.TextIOWrapper name='mirror.py' mode='r' encoding='UTF-8'> >>> fp.closed, fp.encoding # ④ (True, 'UTF-8') >>> fp.read(60) # ⑤ Traceback (most recent call last): File "<stdin>", line 1, in <module> ValueError: I/O operation on closed file.
①
fp
绑定到打开的文本文件,因为文件的__enter__
方法返回self
。
②
从fp
中读取60
个 Unicode 字符。
③
fp
变量仍然可用——with
块不像函数那样定义新的作用域。
④
我们可以读取fp
对象的属性。
⑤
但是我们无法从fp
中读取更多文本,因为在with
块结束时,调用了TextIOWrapper.__exit__
方法,它关闭了文件。
示例 18-1 中的第一个标注点提出了一个微妙但至关重要的观点:上下文管理器对象是在评估with
后的表达式的结果,但绑定到目标变量(在as
子句中)的值是上下文管理器对象的__enter__
方法返回的结果。
恰好open()
函数返回TextIOWrapper
的一个实例,其__enter__
方法返回self
。但在不同的类中,__enter__
方法也可能返回其他对象,而不是上下文管理器实例。
无论以何种方式退出with
块的控制流,__exit__
方法都会在上下文管理器对象上调用,而不是在__enter__
返回的任何对象上调用。
with
语句的as
子句是可选的。在open
的情况下,我们总是需要它来获得文件的引用,以便我们可以在其上调用方法。但是一些上下文管理器返回None
,因为它们没有有用的对象可以返回给用户。
示例 18-2 展示了一个完全轻松的上下文管理器的操作,旨在突出上下文管理器和其__enter__
方法返回的对象之间的区别。
示例 18-2. 测试LookingGlass
上下文管理器类
>>> 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.
①
上下文管理器是LookingGlass
的一个实例;Python 在上下文管理器上调用__enter__
,结果绑定到what
。
②
打印一个str
,然后打印目标变量what
的值。每个print
的输出都会被反转。
③
现在with
块已经结束。我们可以看到__enter__
返回的值,保存在what
中,是字符串'JABBERWOCKY'
。
④
程序输出不再被反转。
示例 18-3 展示了LookingGlass
的实现。
示例 18-3. mirror.py:LookingGlass
上下文管理器类的代码
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 # ⑨ # ⑩
①
Python 会以除self
之外没有其他参数调用__enter__
。
②
保留原始的sys.stdout.write
方法,以便稍后恢复。
③
用我们自己的方法替换sys.stdout.write
,进行 Monkey-patch。
④
返回'JABBERWOCKY'
字符串,这样我们就有了一些东西可以放在目标变量what
中。
⑤
我们替换了sys.stdout.write
,将text
参数反转并调用原始实现。
⑥
如果一切顺利,Python 会用None, None, None
调用__exit__
;如果引发异常,则三个参数将获得异常数据,如本示例后所述。
⑦
恢复原始方法sys.stdout.write
。
⑧
如果异常不是None
且其类型是ZeroDivisionError
,则打印一条消息…
⑨
…并返回True
以告诉解释器异常已被处理。
⑩
如果__exit__
返回None
或任何假值,则with
块中引发的任何异常都将传播。
提示
当真实应用接管标准输出时,它们通常希望将sys.stdout
替换为另一个类似文件的对象一段时间,然后再切换回原始状态。contextlib.redirect_stdout
上下文管理器正是这样做的:只需将它传递给将替代sys.stdout
的文件类对象。
解释器使用无参数—除了隐式的self
—调用__enter__
方法。传递给__exit__
的三个参数是:
exc_type
异常类(例如ZeroDivisionError
)。
exc_value
异常实例。有时,传递给异常构造函数的参数—如错误消息—可以在exc_value.args
中找到。
traceback
一个traceback
对象。²
要详细了解上下文管理器的工作原理,请参见示例 18-4,其中LookingGlass
在with
块之外使用,因此我们可以手动调用其__enter__
和__exit__
方法。
示例 18-4. 在没有with
块的情况下使用LookingGlass
>>> 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 中的括号上下文管理器
Python 3.10 采用了一个新的、更强大的解析器,允许新的语法超出旧的LL(1)解析器所能实现的范围。一个语法增强是允许括号上下文管理器,就像这样:
with ( CtxManager1() as example1, CtxManager2() as example2, CtxManager3() as example3, ): ...
在 3.10 之前,我们必须编写嵌套的with
块。
标准库包括contextlib
包,其中包含用于构建、组合和使用上下文管理器的方便函数、类和装饰器。
contextlib
实用程序
在自己编写上下文管理器类之前,请查看 Python 文档中的contextlib
—“用于with
语句上下文的实用程序”。也许您即将构建的内容已经存在,或者有一个类或一些可调用对象可以让您的工作更轻松。
除了在示例 18-3 之后提到的redirect_stdout
上下文管理器之外,Python 3.5 中还添加了redirect_stderr
—它的功能与前者相同,但用于指向stderr
的输出。
contextlib
包还包括:
closing
一个函数,用于从提供close()
方法但不实现__enter__/__exit__
接口的对象构建上下文管理器。
suppress
一个上下文管理器,用于临时忽略作为参数给出的异常。
nullcontext
一个什么都不做的上下文管理器,用于简化围绕可能不实现合适上下文管理器的对象的条件逻辑。当with
块之前的条件代码可能或可能不为with
语句提供上下文管理器时,它充当替代品—Python 3.7 中添加。
contextlib
模块提供的类和装饰器比刚才提到的装饰器更广泛适用:
@contextmanager
一个装饰器,让您可以从简单的生成器函数构建上下文管理器,而不是创建一个类并实现接口。参见“使用@contextmanager”。
AbstractContextManager
一个正式化上下文管理器接口的 ABC,并通过子类化使得创建上下文管理器类变得更容易——在 Python 3.6 中添加。
ContextDecorator
用于定义基于类的上下文管理器的基类,也可以用作函数修饰符,将整个函数在受控上下文中运行。
ExitStack
一个允许您进入可变数量上下文管理器的上下文管理器。当with
块结束时,ExitStack
以 LIFO 顺序(最后进入,最先退出)调用堆叠的上下文管理器的__exit__
方法。当您不知道在with
块中需要进入多少上下文管理器时,请使用此类;例如,当同时打开来自任意文件列表的所有文件时。
在 Python 3.7 中,contextlib
添加了AbstractAsyncContextManager
,@asynccontextmanager
和AsyncExitStack
。它们类似于名称中不带async
部分的等效实用程序,但设计用于与新的async with
语句一起使用,该语句在第二十一章中介绍。
这些实用程序中最常用的是@contextmanager
修饰符,因此它值得更多关注。该修饰符也很有趣,因为它展示了与迭代无关的yield
语句的用法。
使用@contextmanager
@contextmanager
修饰符是一个优雅且实用的工具,它将三个独特的 Python 特性结合在一起:函数修饰符、生成器和with
语句。
使用@contextmanager
减少了创建上下文管理器的样板代码:不需要编写一个具有__enter__/__exit__
方法的整个类,只需实现一个生成器,其中包含一个应该生成__enter__
方法返回的内容。
在使用@contextmanager
修饰的生成器中,yield
将函数体分为两部分:yield
之前的所有内容将在解释器调用__enter__
时在with
块的开头执行;yield
之后的代码将在块结束时调用__exit__
时运行。
示例 18-5 用生成器函数替换了示例 18-3 中的LookingGlass
类。
示例 18-5. mirror_gen.py:使用生成器实现的上下文管理器
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 # ⑥
①
应用contextmanager
修饰符。
②
保留原始的sys.stdout.write
方法。
③
reverse_write
稍后可以调用original_write
,因为它在其闭包中可用。
④
将sys.stdout.write
替换为reverse_write
。
⑤
产生将绑定到with
语句中as
子句的目标变量的值。生成器在此处暂停,而with
的主体执行。
⑥
当控制退出with
块时,执行继续在yield
之后;这里恢复原始的sys.stdout.write
。
示例 18-6 展示了looking_glass
函数的运行。
示例 18-6. 测试looking_glass
上下文管理器函数
>>> 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
①
与示例 18-2 唯一的区别是上下文管理器的名称:looking_glass
而不是LookingGlass
。
contextlib.contextmanager
修饰符将函数包装在一个实现__enter__
和__exit__
方法的类中。³
该类的__enter__
方法:
- 调用生成器函数以获取生成器对象——我们将其称为
gen
。 - 调用
next(gen)
来驱动它到yield
关键字。 - 返回由
next(gen)
产生的值,以允许用户将其绑定到with/as
形式中的变量。
当with
块终止时,__exit__
方法:
- 检查是否将异常作为
exc_type
传递;如果是,则调用gen.throw(exception)
,导致异常在生成器函数体内的yield
行中被引发。 - 否则,调用
next(gen)
,恢复yield
后生成器函数体的执行。
示例 18-5 存在一个缺陷:如果在with
块的主体中引发异常,Python 解释器将捕获它并在looking_glass
内的yield
表达式中再次引发它。但那里没有错误处理,因此looking_glass
生成器将在不恢复原始sys.stdout.write
方法的情况下终止,使系统处于无效状态。
示例 18-7 添加了对ZeroDivisionError
异常的特殊处理,使其在功能上等同于基于类的示例 18-3。
示例 18-7. mirror_gen_exc.py:基于生成器的上下文管理器实现异常处理,与示例 18-3 具有相同的外部行为
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) # ④
①
为可能的错误消息创建一个变量;这是与示例 18-5 相关的第一个更改。
②
处理ZeroDivisionError
,设置一个错误消息。
③
撤消对sys.stdout.write
的猴子补丁。
④
如果已设置错误消息,则显示错误消息。
回想一下,__exit__
方法告诉解释器已通过返回一个真值处理了异常;在这种情况下,解释器会抑制异常。另一方面,如果__exit__
没有显式返回一个值,解释器会得到通常的None
,并传播异常。使用@contextmanager
,默认行为被反转:装饰器提供的__exit__
方法假定任何发送到生成器中的异常都已处理并应该被抑制。
提示
在yield
周围有一个try/finally
(或with
块)是使用@contextmanager
的不可避免的代价,因为你永远不知道你的上下文管理器的用户会在with
块内做什么。⁴
@contextmanager
的一个鲜为人知的特性是,用它装饰的生成器也可以作为装饰器使用。⁵ 这是因为@contextmanager
是使用contextlib.ContextDecorator
类实现的。
示例 18-8 展示了从示例 18-5 中使用的looking_glass
上下文管理器作为装饰器。
示例 18-8. looking_glass
上下文管理器也可以作为装饰器使用
>>> @looking_glass() ... def verse(): ... print('The time has come') ... >>> verse() # ① emoc sah emit ehT >>> print('back to normal') # ② back to normal
①
looking_glass
在verse
主体运行之前和之后执行其工作。
②
这证实了原始的sys.write
已经恢复。
将示例 18-8 与示例 18-6 进行对比,在其中looking_glass
被用作上下文管理器。
@contextmanager
在标准库之外的一个有趣的现实生活示例是 Martijn Pieters 的使用上下文管理器进行原地文件重写。示例 18-9 展示了它的使用方式。
示例 18-9. 用于原地重写文件的上下文管理器
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
函数更容易使用(顺便说一句,它也提供了一个上下文管理器)。
如果您想研究 Martijn 的inplace
源代码(列在帖子中),请查找yield
关键字:在它之前的所有内容都涉及设置上下文,这包括创建备份文件,然后打开并生成可读和可写文件句柄的引用,这些引用将由__enter__
调用返回。yield
后的__exit__
处理关闭文件句柄,并在出现问题时从备份中恢复文件。
这结束了我们对with
语句和上下文管理器的概述。让我们转向完整示例中的match/case
。
lis.py 中的模式匹配:一个案例研究
在“解释器中的模式匹配序列”中,我们看到了从 Peter Norvig 的lis.py解释器的evaluate
函数中提取的序列模式的示例,该解释器已移植到 Python 3.10。在本节中,我想更广泛地介绍lis.py的工作原理,并探讨evaluate
的所有case
子句,不仅解释模式,还解释解释器在每个case
中的操作。
除了展示更多的模式匹配,我写这一部分有三个原因:
- Norvig 的lis.py是惯用 Python 代码的一个很好的例子。
- Scheme 的简单性是语言设计的典范。
- 了解解释器如何工作让我更深入地理解了 Python 和一般编程语言——无论是解释型还是编译型。
在查看 Python 代码之前,让我们稍微了解一下 Scheme,这样您就可以理解这个案例研究——如果您以前没有见过 Scheme 或 Lisp 的话。
Scheme 语法
在 Scheme 中,表达式和语句之间没有区别,就像我们在 Python 中所看到的那样。此外,没有中缀运算符。所有表达式都使用前缀表示法,如(+ x 13)
而不是x + 13
。相同的前缀表示法用于函数调用—例如,(gcd x 13)
—和特殊形式—例如,(define x 13)
,我们在 Python 中会写成赋值语句x = 13
。Scheme 和大多数 Lisp 方言使用的表示法称为S 表达式。⁶
示例 18-10 展示了 Scheme 中的一个简单示例。
示例 18-10. Scheme 中的最大公约数
(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))
示例 18-10 展示了三个 Scheme 表达式:两个函数定义—mod
和gcd
—以及一个调用display
,它将输出 9,即(gcd 18 45)
的结果。示例 18-11 是相同的 Python 代码(比递归欧几里德算法的英文解释更短)。
示例 18-11. 与示例 18-10 相同,用 Python 编写
def mod(m, n): return m - (m // n * n) def gcd(m, n): if n == 0: return m else: return gcd(n, mod(m, n)) print(gcd(18, 45))
在惯用 Python 中,我会使用%
运算符而不是重新发明mod
,并且使用while
循环而不是递归会更有效率。但我想展示两个函数定义,并尽可能使示例相似,以帮助您阅读 Scheme 代码。
Scheme 没有像 Python 中那样的迭代控制流命令,如while
或for
。迭代是通过递归完成的。请注意,在 Scheme 和 Python 示例中没有赋值。广泛使用递归和最小使用赋值是函数式编程的标志。⁷
现在让我们回顾一下 Python 3.10 版本的lis.py代码。完整的源代码和测试位于 GitHub 存储库fluentpython/example-code-2e的*18-with-match/lispy/py3.10/*目录中。
导入和类型
[示例 18-12 显示了lis.py的前几行。TypeAlias
和|
类型联合运算符的使用需要 Python 3.10。
示例 18-12. lis.py:文件顶部
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
定义的类型有:
Symbol
str
的别名。在lis.py中,Symbol
用于标识符;没有带有切片、分割等操作的字符串数据类型。⁸
Atom
一个简单的句法元素,如数字或 Symbol
—与由不同部分组成的复合结构相对,如列表。
表达式
Scheme 程序的构建块是由原子和列表组成的表达式,可能是嵌套的。
解析器
Norvig 的解析器是 36 行代码,展示了 Python 的强大之处,应用于处理 S-表达式的简单递归语法,没有字符串数据、注释、宏和标准 Scheme 的其他特性,这些特性使解析变得更加复杂(示例 18-13)。
示例 18-13. lis.py:主要解析函数
def parse(program: str) -> Expression: "Read a Scheme expression from a string." return read_from_tokens(tokenize(program)) def tokenize(s: str) -> list[str]: "Convert a string into a list of tokens." return s.replace('(', ' ( ').replace(')', ' ) ').split() def read_from_tokens(tokens: list[str]) -> Expression: "Read an expression from a sequence of tokens." # more parsing code omitted in book listing
该组的主要函数是 parse
,它接受一个 S-表达式作为 str
并返回一个 Expression
对象,如 示例 18-12 中定义的:一个 Atom
或一个可能包含更多原子和嵌套列表的 list
。
Norvig 在 tokenize
中使用了一个聪明的技巧:他在输入的每个括号前后添加空格,然后拆分它,结果是一个包含 '('
和 ')'
作为单独标记的句法标记列表。这个快捷方式有效,因为在 lis.py 的小 Scheme 中没有字符串类型,所以每个 '('
或 ')'
都是表达式分隔符。递归解析代码在 read_from_tokens
中,这是一个 14 行的函数,你可以在 fluentpython/example-code-2e 仓库中阅读。我会跳过它,因为我想专注于解释器的其他部分。
这里是从 lispy/py3.10/examples_test.py 中提取的一些 doctest:
>>> from lis import parse >>> parse('1.5') 1.5 >>> parse('ni!') 'ni!' >>> parse('(gcd 18 45)') ['gcd', 18, 45] >>> parse(''' ... (define double ... (lambda (n) ... (* n 2))) ... ''') ['define', 'double', ['lambda', ['n'], ['*', 'n', 2]]]
这个 Scheme 子集的解析规则很简单:
- 看起来像数字的标记被解析为
float
或int
。 - 其他任何不是
'('
或')'
的内容都被解析为Symbol
—一个可用作标识符的str
。这包括 Scheme 中有效但不在 Python 中的标识符的源文本,如+
、set!
和make-counter
。 '('
和')'
内的表达式被递归解析为包含原子的列表或可能包含原子和更多嵌套列表的嵌套列表。
使用 Python 解释器的术语,parse
的输出是一个 AST(抽象语法树):一个方便的表示 Scheme 程序的嵌套列表形成树状结构,其中最外层列表是主干,内部列表是分支,原子是叶子(图 18-1)。
图 18-1. 一个 Scheme lambda
表达式,表示为源代码(具体语法)、作为树和作为 Python 对象序列(抽象语法)。
流畅的 Python 第二版(GPT 重译)(十)(2)https://developer.aliyun.com/article/1484733