SICP:求值和环境模型(Python实现)
示例代码我已经上传到了GitHub仓库:SICP-Python(包括本书其它章节的示例代码),感兴趣的童鞋可以前往查看。
绪论
我们在第一章引进复合过程时,采用了求值的代换模型定义了将过程应用于实参(arguments)的意义:
- 将一个复合过程应用于一些实参,也就意味着用实参替换过程体里对应的形参(formal parameters)之后,求值这个过程体。
但正如我们在上一章博客《SICP:赋值和局部状态(Python实现)》中所讲的,一旦我们把赋值引入程序设计语言之后,这一定义就不再合适了。由于赋值的存在,变量已经不能再看作仅仅是某个值的名字,此时的变量必须以某种方式指定了一个“位置”(place),相应的值可以存储再那里。 在我们新求值模型里,这种位置将维持在称为环境的结构中。
一个环境就是帧(frame) 的一个序列,每个帧是包含着一些绑定(bindings) 的表格。这些绑定将一些变量名字关联于对应的值(在一个帧内,任何变量至多只有一个绑定)。
每个帧还包含一个指针,指向这个帧的外围环境(enclosing environment)。如果由于当前讨论的目的,将相应的帧看做是全局(global) 的,那么它将没有外围环境。一个变量相对于某个特定环境的值,也就是在这一环境中,包含着该变量的第一个帧里这个变量的绑定值。如果在帧序列中不存在这一变量的绑定,则称这个变量在特定环境下是未绑定(unbound) 的。
下图展示了一个简单的环境结构。其中包含了三个帧,分别用Ⅰ、Ⅱ、Ⅲ标记。
在这个图里,A、B、C和D都是环境指针,其中C和D指向同一个环境。变量z
和x
在帧Ⅱ里绑定,变量y
和x
在帧Ⅰ里绑定。x
在环境D里的值是3,x
相对于环境B的值也是3。后一种情况是因为我们先检测帧序列中的第一个帧(帧Ⅲ),在这里没有找到x
的绑定,因此继续前进到外围环境D并在帧Ⅰ里找到了相应的绑定。另一方面,x
在环境A中的值就是7,因为帧序列中第一个帧(帧Ⅱ)里包含x
与7的绑定。对于环境A,我们说在帧Ⅱ里x
与7的绑定遮蔽(shadow) 了帧1里x
与3的绑定(这里可以联想一下Python中局部变量对全局变量的遮蔽)。
环境对于求值是至关重要的,因为它确定了表达式求值的上下文(context)。实际上,我们完全可以说在一个程序设计语言里的一个表达式本身没有任何意义,因为即使像1+1
这样简单的表达式,其解释也要依赖于+
是表示加法符号的上下文。这样,在现在讨论的求值模型中,我们将总说某个表达式相对于某个环境的求值。为了描述与解释器的交互作用,我们将始终假定存在着一个全局环境,它只包含着一个帧(没有外围环境),这个环境里包含着所有关联于基本过程的符号值。例如,我们说+
是表示加法的符号,也就意味着符号+
在全局环境中被绑定到基本的加法过程。
3.2.1 求值规则
关于解释器如何求值一个组合式的问题,其整体描述仍然与我们在1.1.3节第一次介绍时完全一样。
在1.1.3节中的代换模型中,我们如果要对一个组合表达式求值,需要:
(1) 求值这一组合式里的各个子表达式。
(2) 将运算符(operator)子表达式的值应用于运算对象(operand)子表达式的值。
PS:赋值的存在给求值规则的步骤 (1) 引入了一个微妙问题,即以不同的顺序对组合式中各个子表达式求值,它们就会产出不同的值(想想在C语言中被
(++i)+(++i)
支配的恐惧[2])。然而,这种顺序应该看做是一个实现细节,我们永远不要去写依赖于特定顺序的程序。比如,如果一个复杂的编译器去做程序的优化,它完全可能改变其中各子表达式的求值顺序。
现在我们要用求值的环境模型代替求值的代换模型,在这一模型中我们将会讨论当定义一个复合过程以及当一个复合过程应用于实参究竟意味着什么。
我们来看一个例子。考虑在全局环境里求值下面的过程定义:
def square(x):
return x * x
下图展示的是在全局环境中求值这一def
表达式而产生的环境结构:
这里的过程对象是一个序对(pair),其代码部分描述的是一个带有形参x
的过程,过程体是return x * x
。过程对象的环境部分是一个指向全局环境的指针(因为这个过程的定义是在全局环境中求值的)。这个定义在全局帧中加入了一个新绑定,将上述过程对象绑定于符号square
。一般而言,用def
建立定义的方式(Python的话用=
也可表示变量定义)就是将新的绑定加入到帧中。
这样,过程对象创建的环境模型可总结定为:
- 对于一个给定环境求值一个过程的定义,将创建起一个过程对象,这个过程对象是一个序对,由该过程的正文和一个指向环境的指针组成,这一指针指向的就是创建这个过程对象时的环境。
接下来我们来描述过程对象的应用。环境模型说明,将一个过程对象应用于一组实参时,将会建立起一个新的环境,其中包含了将所有形参绑定到对应实参的一个帧。该帧的外围环境就是创建该过程对象时的环境(在这个例子中即全局环境),随后就在这个新环境下求值该过程的体。
下面我们来演示这一规则的实施情况,下图展示了在全局环境里对表达式square(5)
求值而创建起来的环境结构,其中square
即上图中生成的过程。这一过程应用的结果是创建了一个新环境E1。这个环境从一个帧开始,帧中包含着将这个过程的形参x
绑定到实参5。这个帧引出的指针说明这个帧的外围环境就是全局环境。现在我们要在E1里求值过程的体return x * x
。因为在E1里x
的值是5,所以求值结果是return 5 * 5
,也就是return 25
。
这样,过程对象的应用的环境模型可总结为:
- 将一个过程对象应用于一集实参,将造出一个新帧,其中将过程的形参绑定到调用时的实参,而后在构造起的这一新环境的上下文中求值过程体。这个新帧的外围环境就是创建该过程对象时的环境(这个例子中即全局环境)。
PS:所谓定义一个符号(包括用
def foo()
定义过程或用foo = 1
定义变量),也就是在当前环境frame里建一个绑定,并赋予这个符号指定的值。而赋值运算=
则会要求我们首先在环境中确定有关变量的绑定位置,然后再修改这个绑定,使之表示为这个新值。这也就是说,首先需要找到包括这个变量绑定的第一个帧,然后修改这个帧。如果该变量在环境中没有绑定,赋值将报一个错误。当然由于Python语法的缘故,x = ...
可同时表示变量定义和赋值,故此处注意例外。
此外,众所周知,Python的基础数据类型(如整形、字符串等)是不可变(immutable)的,故对基础数据类型而言,所谓赋值运算其实就等同于我们前面说的拿符号去绑定新的对象)。
3.2.2 简单过程的应用
在1.1.5节里介绍代换模型时,我们展示了在有下面过程的定义之后,组合式f(5)
。怎样求值得到135:
def square(x):
return x * x
def sum_of_squares(x, y):
return square(x) + square(y)
def f(a):
return sum_of_squares(a + 1, a * 2)
print(f(5)) # 136
现在我们用环境模型来分析同一个实例,下图中展示出在全局环境里对f
、square
和sum_of_squares
的定义求值后创建起的三个过程对象,每个过程对象都由一些代码和一个指向全局环境的指针组成。
而在下图中,我们看到的是对f(5)
求值创建起的环境结构。
对于f
的调用创建了一个新环境E1,它开始于一个帧,其中f
的形参a
被绑定到实参5
。我们需要在E1里求值f
的体:
return sum_of_squares(a + 1, a * 2)
求值sum_of_squares
这个组合式时,正如我们前面所说的,首先需要求值其中的子表达式。第一个子表达式sum_of_squares
以一个过程对象为值(请注意看这个值是如何找到的:首先在E1的第一个帧里找,这里没有包含sum_of_squares
的绑定。而后进入有关的外围环境,即全局环境,并在那里找到了创建过程对象时确立好的绑定)。对另外两个子表达式的求值是应用两个基本运算符+
和*
,通过求职组合式a + 1
和a * 2
分别得到6
和10
。
现在需要把过程对象sum_of_squares
应用于实参6
和10
,这时得到的是一个新环境E2
,形式参数x
和y
在其中绑定与其对应的实际参数6
和10
,然后继续在E2里求值组合式square(x) + square(y)
,以此类推。
这里需要注意的是,对square
的每个调用都会创建起一个包含着x
的绑定的新环境,事实上这就是通过不同的帧去维护所有名字为x
的局部变量互不相同。还请注意,由square
创建的每个帧都指向全局环境,因为square
过程对象需要从全局环境中找到。
各个子表达式求值后返回得到的值,对square
的两个调用产生的值被sum_of_squares
加起来,作为求值的结果返回。因为我们在这里关心的是环境结构,因此将不仔细考察这些返回值在调用之间传递的问题,留到第5章讨论(将会涉及到堆栈结构)。
3.2.3 将帧看作局部状态的存储库(repository)
现在可以从环境模型出发,看看怎样用过程和赋值表示带有局部状态的对象。作为一个例子,还是考虑取自3.1.1节的由调用下面过程创建的“提款处理器”:
def make_withdraw(balance):
def withdraw(amount):
nonlocal balance
if balance > amount:
balance = balance - amount
return balance
else:
return "Insufficient funds"
return withdraw
让我们仔细看看下式的求值:
W1 = make_withdraw(100)
而后做:
print(W1(50)) # 50
下图展示了在全局环境里定义make_withdraw
过程的结果。这一求值产生出一个过程对象,其中包含着一个指向全局环境的指针。
到目前为止,这个实例中还没出现于前面看过的实例不同的东西,除了过程体中内置一个闭包函数withdraw
之外。
计算中有趣的现象出现在将过程make_withdraw
应用于一个实参的时候:
W1 = make_withdraw(100)
与往常一样,我们在开始时设置了环境E1,其中将形参balance
绑定到实参100
。并接着在这一环境里求值make_withdraw
的体,也即内置闭包函数withdraw
的定义。然后有趣之处来了,这一求值构造起一个新过程对象,其代码由这个闭包函数所描述,而它的环境就是E1。这样做出过程对象被作为调用make_withdraw
的返回值,在全局环境里绑定于符号W1
,因为W1 = ...
这个变量定义本身的求值是在全局环境里进行的。下图显示出这样的结果得到的环境结构。
PS:上图
make_withdraw
中之所以要加nonlocal
,乃是在因为Python中想要修改外围环境中的自由变量,必须要加nonlocal
/global
将其先绑定到内层环境,而Lisp则不需要人工绑定。
Python的作用域规则和SML、Lisp一样,采用词法作用域(lexical scope)[3](也称静态作用域)规则。所谓词法作用域规则,即在过程中遇到自由变量(不是形参也不是函数内部定义的局部变量)时,要去引用外围过程定义中所出现的绑定,也即去本过程定义的环境中查询(这里的顺序即是著名的LEGB规则[4]:Local scopes -> Enclosing -> Global -> Built-in);与之相反的是动态作用域,即在过程中遇到自由变量时,去函数调用时的环境中查询。 欲了解更多词法作用域和动态作用域的知识,可参见知乎问题[5]。
现在让我们来分析将W1
应用于一个参数时所发生的情况:
print(W1(50)) # 50
此时首先要构造出一个帧,W1
的形参amount
在其中绑定到实参50
。需要注意的最关键的一点是,这个帧的外围环境并不是全局环境,而是环境E1,因为它才是由过程对象W1
所指定的外围环境。现在我们需要在这个新环境中求值下面的过程体:
nonlocal balance
if balance > amount:
balance = balance - amount
return balance
else:
return "Insufficient funds"
这样做得到的环境结构如下图所示。在被求值的表达式里引用了amount
和balance
,其中amount
在环境里的第一个帧中就能找到,而balance
则沿着外围环境指针向前在E1里找到。
在执行赋值运算=
时,位于E1里balance
的绑定就被修改了。对W1
的调用完成时,balance
是50
,而包含着这个balance
的帧仍由过程对象W1
指着。绑定amount
的那个帧(即执行修改balance
的代码的那个帧)现在已经无关紧要了,因为构造它的过程已经结束。在下次W1
被调用时,这一过程又会构造另一个帧,其中建立起amount
的一个新绑定,这个帧的外围环境还是E1。根据上面的分析,我们可以看到E1怎样起着保存过程对象的局部状态变量的“位置”的作用。下图展示的便是调用W1
之后的情景。
现在来看我们通过再次调用make_withdraw
,创建起第二个“提款”对象的情况:
W2 = make_withdraw(100)
这样做产生出的环境结构如下图所示。其中显示了W2
是另一个过程对象。通过调用make_withdraw
为W2
创建起的环境是E2
,它包含了一个帧,其中包含着它自己对balance
的局部绑定。在另一方面,W1
和W2
拥有相同的代码,也就是在make_withdraw
体内的那个闭包函数withdraw
所确定的代码(这里究竟W1
和W2
是共享计算机里保存的同一段物理代码,还是各自维持自己的一份拷贝,则完全是一种实现细节,我们在第4章实现的解释器里采用共享代码的方式)。这里对W1
调用引用的是保存在E1里的状态变量balance
,对W2
的调用引用的是在E2里的balance
,故 W1
和W2
在行为上是完全独立的对象。
3.2.4 内部定义
1.1.8节里我们介绍了过程可以有内部定义的思想,这样就引入了块结构(block structure),就像下面计算平方根的过程里的情况:
def sqrt(x):
def is_good_enough(guess):
return abs(guess**2 - x) < 0.001
def improve(guess):
return (guess + x/guess)/2
def sqrt_iter(guess):
if is_good_enough(guess):
return guess
else:
return sqrt_iter(improve(guess))
return sqrt_iter(1.0)
print(sqrt(2)) # 1.4142156862745097
这也是一个词法作用域的经典例子。现在我们可以利用上面的环境模型,去考察为什么这些内部定义具有所需要的行为。下图所示的是表达式sqrt(2)
求值中的一个时刻,此时内部过程is_good_enough
被第一次调用,其中的guess
等于1
.
注意这时的环境结构。sqrt
是全局环境里的一个符号,它被绑定到一个过程对象,与之关联的环境就是全局环境。在sqrt
被调用时,形成了一个新的环境E1,它将成为全局环境的下属。在E1中,参数x
被绑定到2,而后在E1里求值sqrt
的体。由于sqrt
体中的第一个表达式是:
def is_good_enough(guess):
return abs(guess**2 - x) < 0.001
对这一表达式在环境E1里求值并定义出过程is_good_enough
。更准确地说,符号is_good_enough
被加入到E1的第一个帧中,并被绑定于一个过程对象,其关联的环境是E1(注意这里过程对象和其符号不在全局环境中绑定,这点和我们之前讲的闭包有鲜明区别,在闭包中虽然闭包函数虽然有自己的外围环境,但闭包函数对象和其符号却仍然是在全局环境中绑定的)。与此类似,improve
和sqrt_iter
也在E1里定义为过程。为了简洁起见,在上图中只显示了绑定于is_good_enough
的过程对象。
在定义好各个局部过程对象之后,表达式sqrt_iter(1.0)
被求值,还是在环境E1里。因此,调用在E1里绑定于符号sqrt_iter
的过程对象时,我们以1
作为实参。然后这一调用创建了另一个环境E2,在其中sqrt_iter
的形参guess
被绑定到1
。sqrt_iter
转而(在E2里)以guess
作为实参调用is_good_enough
,这就建立了另一个环境E3
。此时虽然sqrt_iter
和is_good_enough
都有名字为guess
的形参,但它们是两个不同的局部变量,位于不同的帧中。与之相对地,E2和E3都以E1作为其外围环境,这样出现在sqrt_iter
和is_good_enough
体内部的符号x
都将引用出现在E1里x
的绑定,也就是原来sqrt
被调用时的那个x
值。
这样,环境模型已经解释清楚了以前局部过程定义作为程序化模块技术的两个关键性质:
-
局部过程的名字不会与它们外围过程之外的名字互相干扰。这是因为这些局部过程的名字都是在他们的外围过程运行时所创建的帧里绑定的,而不是在全局环境中绑定的。
-
局部过程只需要将它们外围过程的形参作为自由变量,就可以访问外围过程的实参。这是因为对于局部过程体的求值所在的环境是它们外围过程求值所在的环境的下属。
参考
-
[1] Abelson H, Sussman G J. Structure and interpretation of computer programs[M]. The MIT Press, 1996.
-
[2] 知乎:i=1,为什么 (++i)+(++i)=6?
-
[3] Stackoverflow:Does Python scoping rule fits the definition of lexical scoping? [duplicate]
-
[4] Real Python Tutorials: Python Scope & the LEGB Rule: Resolving Names in Your Code
-
[5] 知乎:动态作用域和词法域的区别是什么?