流畅的 Python 第二版(GPT 重译)(十)(1)https://developer.aliyun.com/article/1484732
环境
Environment
类扩展了 collections.ChainMap
,添加了一个 change
方法来更新链式字典中的值,ChainMap
实例将这些值保存在映射列表中:self.maps
属性。change
方法用于支持后面描述的 Scheme (set! …)
形式;参见 示例 18-14。
示例 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'] # ① 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})
①
在读取值时,Environment
的工作方式类似于 ChainMap
:键从左到右在嵌套映射中搜索。这就是为什么 outer_env
中 a
的值被 inner_env
中的值遮蔽。
②
使用 []
赋值会覆盖或插入新项,但始终在第一个映射 inner_env
中进行,本例中。
③
env.change('b', 333)
寻找 'b'
键并在 outer_env
中就地分配一个新值。
接下来是 standard_env()
函数,它构建并返回一个加载了预定义函数的 Environment
,类似于 Python 的 __builtins__
模块,始终可用(示例 18-15)。
示例 18-15. lis.py:standard_env()
构建并返回全局环境
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
REPL
Norvig 的 REPL(读取-求值-打印-循环)易于理解但不用户友好(参见 Example 18-16)。如果没有向lis.py提供命令行参数,则main()
会调用repl()
函数—在模块末尾定义。在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
构造函数调用中的空dict
中。
lispstr(exp: object) -> str
parse
的反函数:给定表示表达式的 Python 对象,parse
返回其 Scheme 源代码。例如,给定['+', 2, 3]
,结果是'(+ 2 3)'
。
评估器
现在我们可以欣赏 Norvig 的表达式求值器的美丽之处—通过match/case
稍微美化了一下。Example 18-17 中的evaluate
函数接受由parse
构建的Expression
和一个Environment
。
evaluate
的主体是一个带有表达式exp
的单个match
语句。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
。
评估数字
case int(x) | float(x): return x
主题:
int
或float
的实例。
操作:
原样返回值。
示例:
>>> from lis import parse, evaluate, standard_env >>> evaluate(parse('1.5'), {}) 1.5
评估符号
case Symbol(var): return env[var]
主题:
Symbol
的实例,即作为标识符使用的str
。
操作:
在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
主题:
以符号'quote'
开头的列表,后跟一个表达式x
。
操作:
返回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
,引发KeyError
(99 bottles of beer)
无法评估,因为数字 99 不是命名特殊形式、运算符或函数的Symbol
(/ 10 0)
会引发ZeroDivisionError
(if …)
# (if (< x 0) 0 x) case ['if', test, consequence, alternative]: if evaluate(test, env): return evaluate(consequence, env) else: return evaluate(alternative, env)
主题:
以'if'
开头的列表,后跟三个表达式:test
、consequence
和alternative
。
操作:
评估test
:
- 如果为真,则评估
consequence
并返回其值。 - 否则,评估
alternative
并返回其值。
示例:
>>> 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中的一个函数提供—参见 Example 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)
主题:
以'lambda'
开头的列表,后跟:
- 零个或多个参数名的列表。
- 一个或多个收集在
body
中的表达式(保证body
不为空)。
操作:
创建并返回一个新的Procedure
实例,其中包含参数名称、作为主体的表达式列表和当前环境。
例子:
>>> 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)
主题:
以'define'
开头的列表,后跟一个Symbol
和一个表达式。
操作:
评估表达式并将其值放入env
中,使用name
作为键。
例子:
>>> global_env = standard_env() >>> evaluate(parse('(define answer (* 7 6))'), global_env) >>> global_env['answer'] 42
此case
的 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)
主题:
以'define'
开头的列表,后跟:
- 以
Symbol(name)
开头的列表,后跟零个或多个收集到名为parms
的列表中的项目。 - 一个或多个收集在
body
中的表达式(保证body
不为空)。
操作:
- 创建一个新的
Procedure
实例,其中包含参数名称、作为主体的表达式列表和当前环境。 - 将
Procedure
放入env
中,使用name
作为键。
Example 18-18 中的 doctest 定义了一个名为%
的计算百分比的函数,并将其添加到global_env
中。
例 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'%' 85.0
调用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))
主题:
以'set!'
开头的列表,后跟一个Symbol
和一个表达式。
操作:
使用评估表达式的结果更新env
中的name
的值。
Environment.change
方法从本地到全局遍历链接的环境,并用新值更新第一次出现的name
。如果我们不实现'set!'
关键字,我们可以在解释器中的任何地方都使用 Python 的ChainMap
作为Environment
类型。
现在我们来到一个函数调用。
函数调用
# (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)
主题:
一个或多个项目的列表。
保护确保func_exp
不是['quote', 'if', 'define', 'lambda', 'set!']
中的一个——在 Example 18-17 中的evaluate
之前列出。
模式匹配任何具有一个或多个表达式的列表,将第一个表达式绑定到func_exp
,其余的绑定到args
作为列表,可能为空。
操作:
- 评估
func_exp
以获得函数proc
。 - 评估
args
中的每个项目以构建参数值列表。 - 用值作为单独参数调用
proc
,返回结果。
例子:
>>> evaluate(parse('(% (* 12 14) (- 500 100))'), global_env) 42.0
此 doctest 继续自 Example 18-18:假设global_env
有一个名为%
的函数。给定给%
的参数是算术表达式,以强调在调用函数之前对参数进行评估。
此case
中的保护是必需的,因为[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)
如果函数调用的 case
没有拒绝关键字的保护,(lambda is not like this)
表达式将被处理为函数调用,这将引发 KeyError
,因为 'lambda'
不是环境的一部分——就像 lambda
不是 Python 内置函数一样。
Procedure: 实现闭包的类
Procedure
类实际上可以被命名为 Closure
,因为它代表的就是一个函数定义和一个环境。函数定义包括参数的名称和构成函数主体的表达式。当函数被调用时,环境用于提供自由变量的值:出现在函数主体中但不是参数、局部变量或全局变量的变量。我们在“闭包”中看到了闭包和自由变量的概念。
我们学会了如何在 Python 中使用闭包,但现在我们可以更深入地了解闭包是如何在 lis.py 中实现的:
class Procedure: "A user-defined Scheme 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 # ⑦
①
当函数由 lambda
或 define
形式定义时调用。
②
保存参数名称、主体表达式和环境以备后用。
③
在 case [func_exp, *args]
子句的最后一行由 proc(*values)
调用。
④
构建 local_env
,将 self.parms
映射为局部变量名称,将给定的 args
映射为值。
⑤
构建一个新的合并 env
,将 local_env
放在首位,然后是 self.env
—即在函数定义时保存的环境。
⑥
迭代 self.body
中的每个表达式,在合并的 env
中对其进行评估。
⑦
返回最后一个表达式评估的结果。
在lis.py中的 evaluate
后有几个简单的函数:run
读取一个完整的 Scheme 程序并执行它,main
调用 run
或 repl
,取决于命令行——类似于 Python 的做法。我不会描述这些函数,因为它们没有什么新内容。我的目标是与你分享 Norvig 的小解释器的美丽之处,更深入地了解闭包的工作原理,并展示 match/case
如何成为 Python 的一个很好的补充。
结束这一关于模式匹配的扩展部分,让我们正式定义一下OR-pattern的概念。
使用 OR-patterns
一系列由 |
分隔的模式是一个OR-pattern:如果任何子模式成功,则它成功。“评估数字”中的模式就是一个 OR-pattern:
case int(x) | float(x): return x
OR-pattern 中的所有子模式必须使用相同的变量。这种限制是必要的,以确保变量在匹配的子模式中都是可用的,无论匹配的是哪个子模式。
警告
在 case
子句的上下文中,|
运算符有特殊的含义。它不会触发 __or__
特殊方法,该方法处理其他上下文中的表达式,如 a | b
,在这些上下文中,它被重载以执行诸如集合并集或整数按位或等操作,具体取决于操作数。
OR-pattern 不限于出现在模式的顶层。你也可以在子模式中使用 |
。例如,如果我们希望 lis.py 接受希腊字母 λ (lambda)¹² 以及 lambda
关键字,我们可以像这样重写模式:
# (λ (a b) (/ (+ a b) 2) ) case ['lambda' | 'λ', [*parms], *body] if body: return Procedure(parms, body, env)
现在我们可以转向本章的第三个也是最后一个主题:Python 中出现 else
子句的不寻常位置。
这样做,然后那样:else 块超出 if
这并不是什么秘密,但是这是一个被低估的语言特性:else
子句不仅可以用在if
语句中,还可以用在for
、while
和try
语句中。
for/else
、while/else
和try/else
的语义密切相关,但与if/else
非常不同。最初,else
这个词实际上阻碍了我对这些特性的理解,但最终我习惯了它。
这里是规则:
for
当for
循环完成时,else
块将运行一次(即,如果for
被break
中止,则不会运行)。
while
当while
循环退出时,else
块只会运行一次,因为条件变为假(即,如果while
被break
中止,则不会运行)。
try
当try
块中没有引发异常时,else
块将运行。官方文档还指出:“else
子句中的异常不会被前面的except
子句处理。”
在所有情况下,如果异常或return
、break
或continue
语句导致控制跳出复合语句的主块,则else
子句也会被跳过。
注意
我认为else
在除了if
之外的所有情况下都是一个非常糟糕的关键字选择。它暗示了一个排除性的替代方案,比如,“运行这个循环,否则做那个”。但是在循环中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
起初可能看起来是多余的。毕竟,在以下代码段中,只有当dangerous_call()
不引发异常时,after_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()
中。明确的是,只有在try
块中没有引发异常时,after_call()
才会执行。
在 Python 中,try/except
通常用于控制流,而不仅仅用于错误处理。甚至在官方 Python 术语表中都有一个缩略语/口号来记录这一点:
EAFP
宽恕比许可更容易。这种常见的 Python 编码风格假设存在有效的键或属性,并在假设被证明为假时捕获异常。这种干净且快速的风格的特点是存在许多 try 和 except 语句。这种技术与许多其他语言(如 C)常见的LBYL风格形成对比。
然后,术语表定义了 LBYL:
LBYL
三思而后行。这种编码风格在进行调用或查找之前明确测试前提条件。这种风格与EAFP方法形成对比,其特点是存在许多 if 语句。在多线程环境中,LBYL 方法可能会在“观察”和“跳转”之间引入竞争条件。例如,如果代码
if key in mapping: return mapping[key]
在测试后,但查找前,另一个线程从映射中删除了键,那么就会失败。这个问题可以通过使用锁或使用 EAFP 方法来解决。
鉴于 EAFP 风格,了解并善于使用try/except
语句中的else
块更有意义。
注意
当讨论match
语句时,一些人(包括我在内)认为它也应该有一个else
子句。最终决定不需要,因为case _:
可以完成相同的工作。¹³
现在让我们总结本章。
流畅的 Python 第二版(GPT 重译)(十)(3)https://developer.aliyun.com/article/1484734