第十八章 with、match 和 else 块

        上下文管理器最终可能几乎与子程序本身一样重要。我们只是触及了它们的表面。 […]Basic 有一个 with 语句,很多语言都有 with 语句。但它们不做同样的事情,它们都做一些非常简单的事情,它们使您免于重复的点 号 查找属性,它们不会做事前准备和事后的清理。不要仅仅因为它的名字相同,就认为它是同一个东西。仅仅因为它的名字相同,不要认为它是同一个东西。 with 语句是一个非常重要的特性。

 

                                -------Raymond Hettinger, eloquent Python evangelist

本章是关于在其他语言中不太常见的控制流特性,因此在 Python 中往往被忽视或未充分利用。他们是:

  • with 语句和上下文管理器协议;
  • 使用match/case的模式匹配;
  • for、while 和 try 语句中的 else 子句。

with 语句会设置一个临时上下文,在上下文管理器对象的控制下,并可靠的清理上下文。这可以防止错误并减少样板代码,同时使 API 更安全、更易于使用。Python 程序员发现 with 块除了自动关闭文件之外还有很多用途。

我们在前面的章节中已经看到了模式匹配,但在这里我们将看到如何将语言的语法表示为序列模式。这一观察结果解释了为什么 match/case 是创建易于理解和扩展的语言处理器的有效工具。我们将为 Scheme 语言的一个虽小而功能很全的子集研究一个完整的解释器。可以应用相同的想法来开发模板语言或 DSL(领域特定语言),以在更大的系统中对业务规则进行编码。

else 子句没有特别强大的特性,但是当与 for、while 和 try 一起正确使用时,它确实有助于传达意图。

本章的新内容

“lis.py 中的模式匹配:案例研究”是一个新部分。我更新了“The contextlib Utilities”以涵盖自 Python 3.6 以来添加的 contextlib 模块的一些功能以及 Python 3.10 中引入的新的圆括号上下文管理器语法。

让我们从强大的 with 语句开始。

上下文管理器和with块

上下文管理器对象用来控制 with 语句,就像迭代器用来控制 for 语句一样

with 语句旨在简化 try/finally 的一些常见用法,它保证在代码块之后执行某些操作,即使该块因返回、异常或 sys.exit() 调用而终止。finally 子句中的代码通常会释放重要的资源或恢复一些临时更改的先前状态。

Python 社区正在为上下文管理器寻找新的、创造性的用途。标准库中的一些示例是:

上下文管理器接口由 __enter__ 和 __exit__ 方法组成。在 with 的顶部,Python 调用了上下文管理器对象的 __enter__ 方法。当 with 块因任何原因完成或终止时,Python 在上下文管理器对象上调用 __exit__ 。

最常见的示例是确保关闭文件对象。示例 18-1 是使用 with 关闭文件的详细演示。

例 18-1。演示文件对象作为上下文管理器

>>> with open('mirror.py') as fp:  1
...     src = fp.read(60)  2
...
>>> len(src)
60
>>> fp  3
<_io.TextIOWrapper name='mirror.py' mode='r' encoding='UTF-8'>
>>> fp.closed, fp.encoding  4
(True, 'UTF-8')
>>> fp.read(60)  5
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.
  1. fp 绑定到打开的文本文件,因为文件的 __enter__ 方法返回 self.
  2. 从 fp 读取 60 个 Unicode 字符。
  3. fp 变量仍然可用——与函数不同,with块并没有定义新的作用域
  4. 我们可以读取 fp 对象的属性。
  5. 但是我们无法从 fp 读取更多文本,因为在 with 块的末尾,调用了 TextIOWrapper.__exit__ 方法,并关闭了文件。

示例 18-1 中的第一个标注提出了一个不易察觉但至关重要的点:上下文管理器对象是对 with 之后的表达式求值的结果,但绑定到目标变量(在 as 子句中)的值是上下文管理器对象的 __enter__ 方法返回的结果。

碰巧 open() 函数返回一个 TextIOWrapper 的实例,而它的 __enter__ 方法返回 self。但是在不同的类中, __enter__ 方法也可能返回一些其他对象而不是上下文管理器实例本身。

当控制流以任何方式退出 with 块时,将在上下文管理器对象上调用 __exit__ 方法,而不是在 __enter__ 返回的对象上调用。

with 语句的 as 子句是可选的。对 open 来说,我们总是需要as子句来获取对文件的引用,以便我们可以调用文件的方法。但是一些上下文管理器返回 None, 因为他们没有有用的对象可以返回给用户。

示例 18-2 展示了一个没有任何作用的上下文管理器的操作,仅仅为了强调上下文管理器与其 __enter__ 方法返回的对象之间的区别。

例 18-2。测试驱动 LookGlass 上下文管理器类

    >>> from mirror import LookingGlass
    >>> with LookingGlass() as what:  1
    ...      print('Alice, Kitty and Snowdrop')  2
    ...      print(what)
    ...
    pordwonS dna yttiK ,ecilA
    YKCOWREBBAJ
    >>> what  3
    'JABBERWOCKY'
    >>> print('Back to normal.')  4
    Back to normal.
  1. 上下文管理器是 LookGlass 的一个实例; Python 在上下文管理器上调用 __enter__ 并且结果绑定给what。
  2. 打印一个str,然后打印目标对象what。每次打印的输出都会颠倒过来。
  3. 现在 with 块执行完成。我们可以看到 __enter__ 返回的值,保存在 what 中,是字符串 'JABBERWOCKY'。
  4. 程序输出不再颠倒。

示例 18-3 是类 LookingGlass 的实现。

例 18-3。 mirror.py:LookingGlass 上下文管理器类的代码

import sys

class LookingGlass:

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

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

    def __exit__(self, exc_type, exc_value, traceback):  6
        sys.stdout.write = self.original_write  7
        if exc_type is ZeroDivisionError:  8
            print('Please DO NOT divide by zero!')
            return True  9
        10
  1. Python 调用 __enter__ ,除了 self 之外没有任何参数。
  2. 保留原来的 sys.stdout.write 方法,以便我们稍后恢复。
  3. 为 sys.stdout.write打猴子补丁,用我们自己的方法替换它。
  4. 返回 '​​JABBERWOCKY' 字符串,这样我们就可以存入目标变量what中。
  5. 我们将 sys.stdout.write 的参数替换为反转后的 text 并调用了原始实现。
  6. 如果一切顺利,Python 调用 __exit__ 的参数为 None, None, None;如果with块抛出异常,则三个参数将是异常的数据,如本示例后所述。
  7. 恢复原始方法sys.stdout.write。
  8. 如果有异常且异常的类型是 ZeroDivisionError,则打印一条消息……
  9. ...并返回 True 以告诉解释器异常已处理。
  10. 如果 __exit__ 返回 None 或非True的值,则将冒泡传播 with 块中抛出的任何异常。

TIP:

 在实际使用中,当应用程序接管标准输出时,可能暂时把sys.stdout替换为另一个类似文件的对象 ,然后切换回原来的版本。contextlib.redirect_stdout 上下文管理器正是这样做的:只需将代表 sys.stdout 的类文件对象传入给它。

解释器不传入任何参数调用 __enter__ 方法——除了隐式的self。传递给 __exit__ 的三个参数是:

exc_type

        异常类(例如 ZeroDivisionError)。

exc_value

        异常实例。有时,传递给异常构造函数的参数(例如错误消息)可以在 exc_value.args 中找到。

traceback

        一个traceback对象

要详细了解上下文管理器的工作原理,请参见示例 18-4,其中在 with 块之外使用了 LookingGlass,因此我们可以手动调用其 __enter__ 和 __exit__ 方法。

例 18-4。在没有使用with块的情况下测试 LookGlass

    >>> from mirror import LookingGlass
    >>> manager = LookingGlass()  1
    >>> manager  # doctest: +ELLIPSIS
    <mirror.LookingGlass object at 0x...>
    >>> monster = manager.__enter__()  2
    >>> monster == 'JABBERWOCKY'  3
    eurT
    >>> monster
    'YKCOWREBBAJ'
    >>> manager  # doctest: +ELLIPSIS
    >... ta tcejbo ssalGgnikooL.rorrim<
    >>> manager.__exit__(None, None, None)  4
    >>> monster
    'JABBERWOCKY'
  1. 实例化并检查上下文管理器示例

  2. 调用 manager 的 __enter__ 方法并将结果存储在 monster 中。

  3. monster 是字符串 'JABBERWOCKY'。 True 标识符是反转输出的,因为所有通过 stdout 的输出都经过我们在 __enter__ 中猴子补丁的 write 方法。

  4. 调用 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-statement 上下文的实用程序。也许你要构建的东西已经存在,或者有一个类或一些可调用的对象可以让你的工作更轻松。

除了在示例 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 部分的等效实用程序,但设计用于与第 21 章中介绍的新 async with 语句一起使用。这些实用程序中使用最广泛的是@contextmanager 装饰器,因此它值得更多关注。这个装饰器也很有趣,因为它与迭代无关,却使用了 yield 语句。

使用 @contextmanager

@contextmanager 装饰器是一个优雅实用的工具,它汇集了三个不同的 Python 特性:函数装饰器、生成器和 with 语句。

使用@contextmanager 减少了创建上下文管理器的样板的代码量:不需要使用 __enter__/__exit__ 方法编写整个类,只需实现一个具有一个 yield 的生成器,该生成器应该生成您希望 __enter__ 方法返回的任何内容。

在用@contextmanager 修饰的生成器中,yield 将函数体分为两部分:当解释器调用 __enter__ 时,yield 之前的所有内容都将在 with 块的开头执行;当在块的末尾调用 __exit__ 时,yield 之后的代码将运行。

这是一个例子。示例 18-5 用生成器函数替换了示例 18-3 中的lookingGlass 类。

例 18-5。 mirror_gen.py:用生成器实现的上下文管理器

import contextlib
import sys

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

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

    sys.stdout.write = reverse_write  4
    yield 'JABBERWOCKY'  5
    sys.stdout.write = original_write  6
  1. 应用上下文管理器装饰器。
  2. 保留原始 sys.stdout.write 方法。
  3. reverse_write 可以在调用外面的 original_write 。因为它在其闭包中可以访问original_write。
  4. 将 sys.stdout.write 替换为 reverse_write.
  5. 在 with 语句的 as 子句中目标变量的值绑定为yield产出的值。生成器在yield处暂停,而 with 的主体正在执行。
  6. 当控制退出 with 块时,在 yield 之后继续执行;这里恢复了原来的 sys.stdout.write。

示例 18-6是使用looking_glass 函数的例子。

例 18-6。测试驱动looking_glass上下文管理器函数

    >>> from mirror_gen import looking_glass
    >>> with looking_glass() as what:  1
    ...      print('Alice, Kitty and Snowdrop')
    ...      print(what)
    ...
    pordwonS dna yttiK ,ecilA
    YKCOWREBBAJ
    >>> what
    'JABBERWOCKY'
    >>> print('back to normal')
    back to normal
  1. 与示例 18-2 的唯一区别是上下文管理器的名称:looking_glass 而不是lookingGlass。

contextlib.contextmanager 装饰器会将函数包装成一个实现 __enter__ 和 __exit__ 方法的类

该类的 __enter__ 方法:

  1. 调用生成器函数来获取生成器对象——我们称之为 gen。
  2. 调用 next(gen) 将其驱动到 yield 关键字。
  3. 返回 next(gen) 产生的值,以允许用户将其绑定到 with/as 语句中的变量。

该类的 __enter__ 方法:

  1. 检查异常是否作为 exc_type 传递;如果是,则调用 gen.throw(exception),导致在生成器函数体内的 yield 行中抛出异常。
  2. 否则,调用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 = ''  1
    try:
        yield 'JABBERWOCKY'
    except ZeroDivisionError:  2
        msg = 'Please DO NOT divide by zero!'
    finally:
        sys.stdout.write = original_write  3
        if msg:
            print(msg)  4
  1. 为可能的错误信息创建一个变量;这是与示例 18-5 相关的第一个更改。
  2. 通过设置错误消息来处理 ZeroDivisionError。
  3. 撤消 sys.stdout.write 的猴子补丁。
  4. 打印错误消息(如果已设置错误消息)

回想一下 __exit__ 方法通过返回一个真值告诉解释器它已经处理了异常;在这种情况下,解释器会压制异常。另一方面,如果 __exit__ 没有明确返回值,解释器会得到通常的 None,并冒泡传播异常。使用@contextmanager时,默认行为是相反的:装饰器提供的 __exit__ 方法假定发送到生成器的任何异常都被处理并且应该被压制。

TIP:

使用@contextmanager时,把 yield 放在 try/finally(或 with 块)中是 不可避免,因为您永远不知道上下文管理器的用户将在 with 块内做什么。

关于@contextmanager 一个鲜为人知的特性是用它装饰的生成器也可以用作装饰器。 这是因为@contextmanager 是用 contextlib.ContextDecorator 类实现的。

这是示例 18-5 中用作装饰器的looking_glass 上下文管理器:

    >>> @looking_glass()
    ... def verse():
    ...     print('The time has come')
    ...
    >>> verse()  1
    emoc sah emit ehT
    >>> print('back to normal')  2
    back to normal
  1. looking_glass 在verse运行之前和之后完成它的工作。
  2. 这证实了原始的 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 源代码(在the post中列出),找到 yield 关键字:在它处理设置上下文之前的一切,这需要创建一个备份文件,然后打开并产生对可读和可写文件句柄的引用,这些句柄将由 __enter__ 返回。如果出现问题,则 __exit__ 处理关闭文件句柄并从备份中恢复文件。

我们对 with 语句和上下文管理器的概述到此结束。让我们在一个完整示例的上下文中转向 match/case。

lis.py 中的模式匹配:案例研究

在“解释器中的模式匹配序列”中,我们看到了从 Peter Norvig 的 lis.py 解释器的evaluate函数中提取的序列模式示例,移植到 Python 3.10。在本节中,我想更广泛地概述 lis.py 的工作原理,并探索evaluate函数的所有 case 子句,我会解释每种模式,还会解释解释器在每种case下的作用。

除了介绍模式匹配,我写这部分的原因有以下三点:

  1. Norvig 的 lis.py 是一个很好的 符合Python风格代码示例;
  2. Scheme的简洁性是大师级的语言设计;
  3. 了解解释器的工作原理让我对 Python 和其他编程语言(解释或编译)有了更深入的了解。

在查看 Python 代码之前,让我们先了解一下 Scheme,这样您就可以理解这个案例研究——如果之前没有见过 Scheme 或 Lisp。

Scheme 语法

在 Scheme 中,表达式和语句之间没有区别,就像我们在 Python 中一样。所有表达式都使用像 (+ x 13) 这样的前缀符号而不是 x + 13。相同的前缀符号用于函数调用——例如(gcd x 13)——和特殊形式——例如(define x 13),在python中,我们编写为赋值语句 x = 13。

Scheme 和大多数 Lisp 方言使用的符号称为 S 表达式

这是 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) 的结果。

这是 Python 中的相同代码(比递归欧几里得算法的英文解释更短):

例 18-11。用 Python 编写与示例 18-10 相同的实现。

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没有像while或for这样的迭代控制流命令。迭代是通过递归完成的。请注意 Scheme 和 Python 示例中没有赋值。过度使用递归和尽量避免使用赋值是函数式编程的标志。

现在让我们回顾一下 lis.py 的 Python 3.10 版本的代码。带有测试的完整源代码位于 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,而不是由不同部分组成的复合结构,如列表。

Expression

Scheme 程序的构建块是由可能嵌套的Atom和列表组成的表达式。

解析器

Norvig 的解析器一共有 36 行代码,展示了 Python 在处理 S 表达式的简单递归语法方面的强大功能。没有字符串数据、注释、宏和其他使解析更加复杂的标准 Scheme 特性。

例 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 或一个可能包含更多atom和嵌套列表的列表。

Norvig 在tokenize中使用了一个巧妙的技巧:他在输入中的每个括号前后添加空格,然后将其拆分,从而生成一个包含 '(' 和 ')' 作为单独标记的句法标记列表。这个快捷方法是有效的,因为lis.py的Scheme中没有字符串类型,所以每个'('或')'都是一个表达式分隔符。递归解析代码位于 read_from_tokens 中,这是一个 14 行的函数,您可以在 fluentpython/example-code-2e 存储库中读取。我将跳过它,因为我想专注于解释器的其他部分。

以下是从 lispy/py3.10/examples_test.py 中提取的一些文档测试:

>>> 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 子集的解析规则很简单:

  1. 看起来像数字的标记被解析为float或int。
  2. 任何不是 '(' 或 ')' 的字符串都被解析为一个 Symbol——一个用作标识符的 str。这包括像 +、set! 和 make-counter 这样的源文本,它们是 Scheme 中的有效标识符,但在 Python 中不是。
  3. '(' 和 ')' 中的表达式被递归解析为包含原子的列表或可能包含原子和更多嵌套列表的嵌套列表。

使用 Python 解释器的术语,parse的输出是一个 AST(抽象语法树):将 Scheme 程序方便地表示为形成树状结构的嵌套列表,其中最外层的列表是主干,内层的列表是分支,atom是叶子。

Environment 

Environment 类扩展了 collections.ChainMap 添加了一个change方法来更新链式字典之一内的值,ChainMap 实例保存在映射列表中:self.maps 属性。需要 change 方法来支持 Scheme (set! ...) 形式,后面会介绍。

例 18-14。 lis.py:Environment类

class Environment(ChainMap[Symbol, Any]):
    "A ChainMap that allows changing an item in-place."

    def change(self, key: Symbol, value: Any) -> None:
        "Find where key is defined and change the value there."
        for map in self.maps:
            if key in map:
                map[key] = value  # type: ignore[index]
                return
        raise KeyError(key)

请注意,change 方法仅更新现有键。尝试更改未找到的键会引发 KeyError。

这个 doctest 展示了 Environment 是如何工作的:

>>> from lis import Environment
>>> inner_env = {'a': 2}
>>> outer_env = {'a': 0, 'b': 1}
>>> env = Environment(inner_env, outer_env)
>>> env['a']  1
2
>>> env['a'] = 111  2
>>> env['c'] = 222
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 1})
>>> env.change('b', 333)  3
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 333})
  1. 读取值时,Environment 用作 ChainMap:从左到右在嵌套映射中搜索键。这就是outer_env 中a 的值被inner_env 中的值遮蔽的原因。
  2. 用 [] 赋值总是在第一个映射覆盖或插入新项,在本例中为 inner_env。
  3. env.change('b', 333) 在outer_env 中寻找'b' 键并就地为其赋值。

接下来是standard_env() 函数,它构建并返回一个加载了预定义函数的环境,类似于始终可用的Python 的 __builtins__ 模块。

例 18-15。 lis.py:standard_env() 构建并返回全局的Environment

def standard_env() -> Environment:
    "An environment with some Scheme standard procedures."
    env = Environment()
    env.update(vars(math))   # sin, cos, sqrt, pi, ...
    env.update({
            '+': op.add,
            '-': op.sub,
            '*': op.mul,
            '/': op.truediv,
            # omitted here: more operator definitions
            '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:],
            # omitted here: more function definitions
            'number?': lambda x: isinstance(x, (int, float)),
            'procedure?': callable,
            'round': round,
            'symbol?': lambda x: isinstance(x, Symbol),
    })
    return env

总而言之,env 映射加载了:

  • Python math模块中的所有函数;
  • 从 Python 的 op 模块中选择的运算符;
  • 使用 Python 的 lambda 构建的简单但功能强大的函数;
  • Python 内置函数重命名callable映射为procedure?或直接映射成原来的名字,如round。

The REPL

Norvig 的 REPL(Read-Eval-Print-Loop)很容易理解,但对用户不太友好。如果没有给 lis.py 提供命令行参数,则 repl() 函数由 定义在模块的末尾的main() 调用。在 lis.py> 提示符下,我们必须输入正确且完整的表达式——如果写错一个括号,lis.py 就会崩溃。

例 18-16。 REPL 函数

def repl(prompt: str = 'lis.py> ') -> NoReturn:
    "A prompt-read-eval-print loop."
    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:
    "Convert a Python object back into a Lisp-readable string."
    if isinstance(exp, list):
        return '(' + ' '.join(map(lispstr, exp)) + ')'
    else:
        return str(exp)

repl(prompt: str = 'lis.py> ') -> NoReturn

调用standard_env() 为全局环境提供内置函数,然后进入无限循环读取和解析每个输入行,在全局环境中对其进行计算并显示结果——除非输入是None。global_env 可以通过evaluate进行修改。例如,当用户定义一个新的全局变量或命名函数时,它存储在环境的第一个映射中——repl 第一行中Environment构造函数调用中的空字典。

lispstr(exp: object) -> str

parse 的反向函数:传入一个表示表达式的 Python 对象,parse 返回它的 Scheme 源代码。例如,给定 ['+', 2, 3],结果是 '(+ 2 3)'。

The Evaluator

现在我们可以欣赏到 Norvig 表达式评估器的美妙之处——用 match/case 让它变得更漂亮一点。示例 18-17 中的evaluate函数采用由 parse 和Environment构建的表达式。

evaluate函数的主体是一个单一的match语句,以表达式 exp 作为主题。case模式以惊人的清晰度表达了 Scheme 的语法和语义。

例 18-17。evaluate函数接受一个表达式并计算其值

KEYWORDS = ['quote', 'if', 'lambda', 'define', 'set!']

def evaluate(exp: Expression, env: Environment) -> Any:
    "Evaluate an expression in an environment."
    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))

让我们研究每个 case 子句及其作用。在某些情况下,我添加了显示 S 表达式的注释,当解析为 Python 列表时,它将匹配下面的模式。从 examples_test.py 中提取的 Doctests 演示了每个case。

Evaluating numbers

    case int(x) | float(x):
        return x

Subject:

int 或者 float的实例.

Action:

返回值本身

示例:

>>> from lis import parse, evaluate, standard_env
>>> evaluate(parse('1.5'), {})
1.5

Evaluating symbols

    case Symbol(var):
        return env[var]

Subject:

Symbol 的实例,即用作标识符的 str 。

Action:

在 env 中查找 var 并返回其值。

示例:

>>> evaluate(parse('+'), standard_env())
<built-in function add>
>>> evaluate(parse('ni!'), standard_env())
Traceback (most recent call last):
    ...
KeyError: 'ni!'

(quote …)

quote特殊形式将原子和列表视为数据而不是要执行的表达式。

    # (quote (99 bottles of beer))
    case ['quote', x]:
        return x

Subject:

以符号“quote”开头,后跟一个表达式 x的列表。
Action:

返回 x 而不执行它

示例:

>>> evaluate(parse('(quote no-such-name)'), standard_env())
'no-such-name'
>>> evaluate(parse('(quote (99 bottles of beer))'), standard_env())
[99, 'bottles', 'of', 'beer']
>>> evaluate(parse('(quote (/ 10 0))'), standard_env())
['/', 10, 0]

如果没有quote,测试中的每个表达式都会抛出错误:

  • no-such-name 将在Environent中查找,抛出KeyError异常;
  • (99 bottles of beer) 无法计算,因为数字 99 不是命名特殊形式、运算符或功能的Symbol
  • (/ 10 0) 会抛出 ZeroDivisionError。

为什么语言有保留关键字

虽然很简单,但quote不能作为函数来实现。它的特殊功能是防止解释器对表达式 (quote (f 10)) 中的 (f 10) 求值:结果只是一个带有 Symbol 和 int 的列表。相反,在像 (abs (f 10)) 这样的函数调用中,解释器在调用 abs 之前执行(f 10)。这就是为什么 quote 是一个保留关键字:它必须作为一种特殊形式处理。

一般来说,保留关键字是必需的:

  • 引入专门的求值规则,如quote 和 lambda——它们不会对任何子表达式进行求值;
  • 改变控制流,如 if 和函数调用——它们也有特殊的执行规则;
  • 管理环境,如define和set

这也是为什么 Python 和编程语言通常需要保留关键字的原因。想想 Python 的 def、if、yield、import、del 以及它们的作用。


(if …)

    # (if (< x 0) 0 x)
    case ['if', test, consequence, alternative]:
        if evaluate(test, env):
            return evaluate(consequence, env)
        else:
            return evaluate(alternative, env)

Subject:

以“if”开头的列表,后跟三个表达式:test、results 和alternative。

Action:

计算test。。。

  • 如果为True,则计算consequence并返回其值;
  • 否则,计算alternative并返回其值。

Examples:

>>> evaluate(parse('(if (= 3 3) 1 0))'), standard_env())
1
>>> evaluate(parse('(if (= 3 4) 1 0))'), standard_env())
0

consequence和alternative分支必须是单个表达式。如果在一个分支中需要多个表达式,您可以将它们与 (begin exp1 exp2…) 组合,在 lis.py 中作为函数提供——参见示例 18-15。

(lambda …)

Scheme 的 lambda 形式定义了匿名函数。它不受 Python lambda 的限制:任何可以用 Scheme 编写的函数都可以使用 (lambda ...) 语法编写。

    # (lambda (a b) (/ (+ a b) 2))
    case ['lambda' [*parms], *body] if body:
        return Procedure(parms, body, env)

Subject:

列表以“lambda”开头,后跟:

  • 由零个或多个参数名称组成的列表;
  • body 中收集的一个或多个表达式(守卫确保 body 不为空)

Action:

创建并返回一个新的Procedure实例,其中包含参数名称、表达式列表作为主体和当前环境。

Example:

>>> expr = '(lambda (a b) (* (/ a b) 100))'
>>> f = evaluate(parse(expr), standard_env())
>>> f  # doctest: +ELLIPSIS
<lis.Procedure object at 0x...>
>>> f(15, 20)
75.0

Procedure 类实现了闭包的概念:一个可调用的对象,包含参数名称、函数体和对定义函数的环境的引用。稍后我们将研究 Procedure 的代码。

(define …)

define关键字以两种不同的句法形式使用。最简单的是:

    # (define half (/ 1 2))
    case ['define', Symbol(name), value_exp]:
        env[name] = evaluate(value_exp, env)

Subject:

列表以“define”开头,后跟一个符号和一个表达式。

Action:

计算表达式并将其值放入 env 中,使用name作为键。


示例:

>>> global_env = standard_env()
>>> evaluate(parse('(define answer (* 7 6))'), global_env)
>>> global_env['answer']
42

这种情况下的 doctest 创建一个 global_env 以便我们可以验证evaluate将answer放入该Environment中。

我们可以使用这种简单的define形式来创建变量或将名称绑定到匿名函数,使用 (lambda ...) 作为 value_exp。

标准Scheme提供了定义命名函数的快捷方式。这是第二种define形式:

    # (define (average a b) (/ (+ a b) 2))
    case ['define', [Symbol(name), *parms], *body] if body:
        env[name] = Procedure(parms, body, env)

Subject:

列表以“define”开头,后跟:

  • 以 Symbol(name) 开头的列表,后跟零个或多个收集到 parms 列表中的项;
  • body 中收集的一个或多个表达式(守卫确保 body 不为空)。

Action:

  • 使用参数名称、表达式列表作为主体和当前环境创建一个新的Procedure实例;
  • 将Procedure放入 env 中,使用name作为键。

例 18-18。定义一个名为 % 的函数来计算百分比。

>>> global_env = standard_env()
>>> percent = '(define (% a b) (* (/ a b) 100))'
>>> evaluate(parse(percent), global_env)
>>> global_env['%']  # doctest: +ELLIPSIS
<lis.Procedure object at 0x...>
>>> global_env['%'](170, 200)
85.0

doctest 再次创建一个 global_env。在调用evaluate之后,我们检查 % 是否绑定到一个带有两个数字参数并返回一个百分比的Procedure。

第二个define case的模式不强制 parms 中的项都是 Symbol 实例;在构建Procedure之前,我必须对此进行检查,但我没有——为了让代码像 Norvig 一样容易理解。

(set! …)

set!更改之前定义的变量的值

    # (set! n (+ n 1))
    case ['set!', Symbol(name), value_exp]:
        env.change(name, evaluate(value_exp, env))

Subject:

列表以 'set!' 开头,后跟一个 Symbol 和一个表达式。

Action:

使用计算表达式的结果更新 env 中 name 的值。

Environment.change 方法从本地到全局遍历链接的环境,并使用新值更新 第一次出现位置的name。如果我们没有实现set!关键字,我们可以在这个解释器的任何地方使用 Python 的 ChainMap 作为环境类型。


PYTHON 的Nonlocal和set!解决同样的问题

set! 形式 与 Python 中 nonlocal 关键字的使用有关:声明 nonlocal x 允许 x = 10 在本地范围之外更新先前定义的 x 变量。如果没有nonlocal x 声明,x = 10 将始终在 Python 中创建一个局部变量,正如我们在“非局部声明”中看到的那样。

类似地, (set! x 10) 更新可能在函数局部环境之外定义的 x。相比之下,变量 x in (define x 10) 始终是一个局部变量,在局部环境中创建或更新。

必需使用 nonlocal 和 (set! ...) 来更新闭包内变量中保存的程序状态。示例 9-13 演示了使用 nonlocal 来实现计算运行平均值的函数,在闭包中保存所有项的count和total。下面同样原理的实现,使用 lis.py 的 Scheme 子集:

(define (make-averager)
    (define count 0)
    (define total 0)
    (lambda (new-value)
        (set! count (+ count 1))
        (set! total (+ total new-value))
        (/ total count)
    )
)
(define avg (make-averager))  1
(avg 10)  2
(avg 11)  3
(avg 15)  4
  1. 使用由 lambda 定义的内部函数创建一个新的闭包,变量 count 和 total 初始化为 0;将闭包绑定到 avg。
  2. 返回10.0
  3. 返回10.5
  4. 返回12.0

上面的代码是 lispy/py3.10/examples_test.py 中的测试之一。


现在我们进入函数调用。

Function call

    # (gcd (* 2 105) 84)
    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)

Subject:

一个单个或者多个项组成的列表

守卫确保 func_exp 不是 ['quote', 'if', 'define', 'lambda', 'set!'] 之一——在示例 18-17 中的evaluate之前列出。

该模式匹配具有一个或多个表达式的任何列表,将第一个表达式绑定到 func_exp 并将其余表达式绑定到 args 作为列表,参数列表可能为空。

Action:

  1. 对func_exp求值得到一个函数proc;
  2. 计算 args 中的每个项以构建参数值列表;
  3. 使用作为单独参数的参数值列表中的值调用 proc,返回结果。

示例:

>>> evaluate(parse('(% (* 12 14) (- 500 100))'), global_env)
42.0

前面的 doctest 从示例 18-18 继续:它假设 global_env 有一个名为 % 的函数。提供给 % 的参数是算术表达式,以强调在调用函数之前先计算出参数。

在这种情况下需要一个守卫,因为 [func_exp, *args] 匹配具有一个或多个项的任何序列的主题。但是,如果 func_exp 是关键字,并且主题不匹配任何之前的情况,那么它确实是一个语法错误。

捕捉语法错误

如果主题 exp 与前面的任何情况都不匹配,则包含所有情况的case会抛出SyntaxError:

    case _:
        raise SyntaxError(lispstr(exp))

以下是报告为 SyntaxError 的格式错误 (lambda ...) 的示例:

>>> evaluate(parse('(lambda is not like this)'), standard_env())
Traceback (most recent call last):
    ...
SyntaxError: (lambda is not like this)

如果函数调用的情况没有那个守卫来过滤掉关键字,则(lambda is not like this)表达式将作为函数调用处理,这会抛出 KeyError 因为 'lambda' 不是环境的一部分——就像 lambda 不是 Python 内置函数一样。

Procedure:一个实现闭包的类

Procedure 类可以恰如其名的命名为 Closure,因为这就是它所代表的意思:一个函数定义和一个环境。函数定义包括参数的名称和构成函数体的表达式。调用函数时使用环境来提供自由变量的值:出现在函数体中但不是参数、局部变量或全局变量的变量。我们在“闭包”中看到了闭包和自由变量的概念。

我们学习了如何在 Python 中使用闭包,但现在我们可以更深入地了解闭包是如何在 lis.py 中实现的。

class Procedure:
    "A user-defined Scheme procedure."

    def __init__(  1
        self, parms: list[Symbol], body: list[Expression], env: Environment
    ):
        self.parms = parms  2
        self.body = body
        self.env = env

    def __call__(self, *args: Expression) -> Any:  3
        local_env = dict(zip(self.parms, args))  4
        env = Environment(local_env, self.env)  5
        for exp in self.body:  6
            result = evaluate(exp, env)
        return result  7
  1. 当函数由 lambda 或define形式定义时调用。
  2. 保存参数名称、函数体表达式和环境以供以后使用。
  3. 由 case [func_exp, *args] 子句最后一行中的 proc(*values) 调用。
  4. 构建 local_env 映射 ,其中self.parms 作为局部变量名称和args 作为值。
  5. 构建一个新的组合环境,首先放置 local_env,然后是 self.env——定义函数时保存的环境。
  6. 迭代 self.body 中的每个表达式,在组合的 env 中对其进行计算。
  7. 返回最后一个表达式的计算结果。

lis.py 中evaluate 后有几个简单的函数:run 读取完整的Scheme 程序并执行它,main 调用run 或repl 基于命令行——类似于Python 所做的。我不会描述这些功能,因为它们没有什么新东西。我的目标是与您分享 Norvig 的微型解释器的美妙之处,更深入地了解闭包的工作原理,并展示 match/case 如何成为 Python 的一个很好的补充。

为了结束这个关于模式匹配的扩展部分,让我们形式化 OR 模式的概念

使用 OR 模式

由 | 分隔的一系列模式是一个 OR 模式:如果任何子模式成功,它就会成功。 “评估数字”中的模式是一个 OR 模式:

    case int(x) | float(x):
        return x

OR 模式中的所有子模式必须使用相同的变量。此限制是必要的,以确保变量可用于守卫表达式和 case 主体,而不管匹配的子模式是什么。

WARNING:

在 case 子句的上下文中, |运算符具有特殊含义。它不会触发在其他上下文中使用 __or__ 特殊方法来处理像 a| b这样的表达式,它被重载以执行诸如集合并或整数按位或之类的操作,具体取决于操作数。

OR 模式不限于出现在模式的顶层。

您也可以使用 |在子模式中。例如,如果我们希望 lis.py 接受希腊字母 λ (lambda)作为 lambda 关键字,我们可以像这样重写模式:

    # (λ (a b) (/ (+ a b) 2) )
    case ['lambda' | 'λ', [*parms], *body] if body:
        return Procedure(parms, body, env)

现在我们可以转到本章的第三个也是最后一个主题:在 Python 中 else 子句可能出现在不寻常位置。

先做这个,然后做那个: if语句外的else块

这不是什么秘密,但却是一个被低估的语言特性:else 子句不仅可以在 if 语句中使用,还可以在 for、while 和 try 语句中使用。

for/else、while/else 和 try/else 的语义密切相关,但与 if/else 非常不同。最初, else 这个词实际上阻碍了我对这些功能的理解,但最终我习惯了。

以下是规则:

for:

只有当 for 循环运行完成时,else 块才会运行(即,如果 for 因中断而中止,则不会执行)。

while:

只有当 while 循环因为条件变为假值而退出时,else 块才会运行(即,如果 while 被中断中止,则不会执行)。

try:

只有当 try 块中没有抛出异常时,else 块才会运行。官方文档还指出:“else 子句中的抛出异常不会由前面的 except 子句处理。

在所有情况下,如果异常或 return、break 或 continue 语句导致控制跳出复合语句的主块,则 else 子句也会被跳过。

Note:

我认为 除了 if以外else 在所有情况下对于关键字来说都是一个非常糟糕的选择。它暗示了一个排他性的选择,比如“运行这个循环,否则做那件事”,但循环中的 else 的语义是相反的:“运行这个循环,然后做那件事。”这表明使用 then 可以作为一个更好的关键字——这在 try 上下文中也说得通:“尝试这个,然后做那个。但是,添加新关键字是对语言的重大更改——不是一个容易做出的决定。

将 else 与这些语句一起使用通常会使代码更易于阅读,并省去了设置控制标志或编写额外 if 语句的麻烦。

在循环中使用 else 通常遵循此代码段的模式:

for item in my_list:
    if item.flavor == 'banana':
        break
else:
    raise ValueError('No banana flavor found!')

在 try/except 块的情况下,else 乍一看似乎是多余的。毕竟,以下代码段中的 after_call() 仅在dangerous_call()不抛出异常时才会运行,对吗?

try:
    dangerous_call()
    after_call()
except OSError:
    log('OSError...')

但是,这样做会无缘无故地将 after_call() 放在 try 块中。为了清晰和正确,try 块的主体应该只包含可能抛出预期异常的语句。因此,像下面写会更好:

try:
    dangerous_call()
except OSError:
    log('OSError...')
else:
    after_call()

现在很明显,try 块防守的是dangerous_call()而不是 after_call()中可能抛出的异常。同样明确的是 after_call() 只会在 try 块中没有抛出异常的情况下才会执行。

在 Python 中,try/except 通常用于控制流,而不仅仅是用于错误处理。甚至官方 Python 词汇表中还记录了一个首字母缩略词/口号:

EAFP

请求原谅比许可更容易(Easier to ask for forgiveness than permission.)。这种常见的 Python 编码风格先假设存在有效的键或属性,如果假定不成立时,那么捕获异常。这种风格简单明快,特点是代码存在许多 try 和 except 语句。该技术与许多其他语言(如 C)常见的 LBYL 风格形成对比。

词汇表随后定义了 LBYL:

LBYL

三思而后行(Look before you leap.)这种编码风格在调用或查找之前显式地测试先决条件。这种风格与 EAFP 方法相反,其特点是存在许多 if 语句。在多线程环境中,LBYL 方法可能会在“检查”和“行事”之间引入条件竞争。例如,如果在测试之后但在查找之前。另一个线程从映射中删除键,则代码 if key in mapping: return mapping[key] 可能会失败。这个问题可以通过锁或使用 EAFP 方法来解决。

如果选择使用 EAFP 风格,在 try/except 语句中了解和使用 else 块更有意义。

NOTE
当讨论 match 语句时,有些人(包括我)认为它也应该有一个 else 子句。最后决定不需要它,因为 case _: 做了相同的工作。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值