galeo's blog

In the morning after, you begin to see the light.

Common Lisp中的变量

本篇文章大部分翻译自 Peter Seibel Practical Common Lisp 一书的 Variables 一章,不包含 常量赋值广义赋值 等后面几个小节的内容。我根据自己的理解对其中某些部分的内容做了增删改。

Common Lisp是一种词法作用域(lexically scope)的Lisp,它同时支持两种类型的变量: 词法(lexical)变量动态(dynamic)变量 1

(Common Lisp中)变量的基础知识

在Common Lisp中,一个变量可以保存任何类型的值,并且这些值带有用于运行期类型检查的类型信息2。Common Lisp是动态类型的——类型错误会被动态地检测到。另一方面,Common Lisp是一种 强类型strongly typed )语言,所有的类型错误都将被检测到——无法将一个对象作为其不属于的一种类型的实例来对待。3

Lisp中所有符号只有两种状态:绑定和未绑定。4我们定义一个变量的同时,也建立了一个绑定,例如:

CL-USER> (setf S 10)
10
CL-USER> S
10

上述过程图形化表示即是:

symbol-bounding-example.png

表达式 (setf S 10) 执行了下面两个步骤:

  1. 把符号 S 跟内存中的一存储单元建立起绑定。之后,符号 S 出现的地方就代表(引用)了这一存储单元。
  2. 把符号 S 所引用的存储单元的值设为整数10。我们接着在命令行中敲入 S 求值时,REPL会依据之前建立起的绑定找到那个存储单元并把值打印出来。

Common Lisp中所有的值,至少从概念上讲,都是对对象的引用(references)。5将一个变量赋予新值只会改变此变量将引用哪个对象,对其之前引用的对象没有影响。但是,如果一个变量保存了对一个可变对象的引用,你可以用这个引用来修改此对象,而且这种改动将作用于其他引用这个相同对象的代码。6

函数形参

引入新变量的一种方式是定义函数行参。当你用 DEFUN 定义一个函数,行参列表定义了用来保存当函数被调用时传给它的实参的变量。例如,下面的函数定义了三个变量—— xyz , 用来保存它的实参:

(defun foo (x y z) (+ x y z))

每当一个函数被调用时,Lisp会创建新的绑定来保存传递给此函数的实参。绑定是变量在运行期执行的行为。单个变量在程序运行期可以有多个不同的绑定,甚至可以同时有多个绑定,例如,一个递归函数的行参会在每一次函数被调用时被重新绑定。

和所有的Common Lisp变量一样,函数的行参可以保存对象的引用。7, 8因此,你可以在函数体内为一个函数行参赋予一个新值,这不会影响另一个对此函数调用所创建的绑定。9但是,如果传递给此函数的对象是一个可变对象而且你在函数体内改变了这个对象,那么这种改动对于函数调用者将可见,因为调用者和被调用者都在引用同一个对象。10

LET 形式

引入新变量的另一种方式是使用 LET 特殊操作符。一个 LET 形式的结构看起来像这样:

(let (variable*)
  body-form*)

其中,每一个 variable 都是一个变量初始化形式——要么是一个含有一个变量名和一个初始值的列表,要么是简单的一个将其初始值设为 NIL 的单独的变量名。下面的 LET 形式会绑定三个变量 xyz 到初始值10、20和 NIL 上:

(let ((x 10) (y 20) z)
  ...)

当这个 LET 形式被求值(evaluated)的时候,所有的初始值形式都将首先被求值。在形式体被执行之前新的绑定将被创建并初始化到适当的初始值上。在 LET 形式体中,变量名将引用新创建的绑定。在 LET 形式体执行结束后,这些变量名将重新引用在执行 LET 形式之前它们所引用的内容,如果有的话。

形式体中最后一个表达式的值将作为 LET 表达式的值返回。和函数行参一样,由 LET 所引入的变量将在每次进入 LET 时被重新绑定。11

绑定形式的作用域

函数形参和 LET 变量的作用域(变量名可以用来引用其变量绑定的程序区域)被限定在引入这些变量的形式之内。这个形式即函数定义或 LET ,被称为绑定形式。词法变量和动态变量这两种类型的变量使用稍有不同的作用域机制,但这两种情况下其作用域都被其绑定形式所限定。

如果嵌套地使用绑定形式引入了相同名字的变量,那么内层的变量绑定将覆盖外层的绑定。例如,当下面的函数被调用时,将为形参 x 创建一个绑定来保存其对应的函数实参。第一个 LET 创建了一个带有初始值2的新绑定,内层的 LET 创建了另一个绑定,其初始值为3。右面的竖线标记了每一个绑定的作用域。

(defun foo (x)
  (format t "Parameter: ~a~%" x)      ; |<------ x为实参
  (let ((x 2))                        ; |
    (format t "Outer LET: ~a~%" x)    ; | |<---- x对应的值为2
    (let ((x 3))                      ; | |
      (format t "Inner LET: ~a~%" x)) ; | | |<-- x对应的值3
    (format t "Outer LET: ~a~%" x))   ; | |
  (format t "Parameter: ~a~%" x))     ; |

每一个对 x 的引用都将指向最小的封闭作用域中的绑定。一旦程序控制离开了一个绑定形式的作用域,其覆盖的最近的绑定形式就会被解除覆盖, x 将转而指向它。所以,调用 foo 将得到下面的输出结果:

CL-USER> (foo 1)
Parameter: 1
Outer LET: 2
Inner LET: 3
Outer LET: 2
Parameter: 1
NIL

这里也可以用绑定栈的概念来理解12,我们对上面的例子进行分析,当进入 Inner LET (即最内层的那个LET操作符)时,符号 x 的绑定栈如下图所示:

symbol-bounding-stack.png

符号 x 始终使用栈顶的绑定,当程序从 Inner LET 退出回到 Outer LET 时,绑定栈栈顶的 绑定3 被弹出, 绑定2 成了栈顶,于是在回到 Outer LET 时打印符号 x 的值便是2。

其他绑定形式

引入的新变量名只能用在其之内的其他构造也可以用来作为绑定形式。

例如, DOTIMES 循环,一种基本的计数循环。它引入了一个变量用来保存每次通过循环是递增的计数器的值。例如下面的循环,将打印数字0到9,它绑定了变量 x

(dotimes (x 10) (format t "~d " x))

另一种绑定形式是 LET 的变体 LET* 。二者的区别在于,在 LET 中,变量名只能用在 LET 形式体之内—— LET 形式中变量列表之后的那部分;但在一个 LET* 中,每个变量的初始值形式可以引用那些早先引入到变量列表中的变量。所以,你能写下面的代码:

(let* ((x 10)
       (y (+ x 10)))
  (list x y))

但不能这样:

(let ((x 10)
      (y (+ x 10)))
  (list x y))

不过你可以通过嵌套的 LET 达到相同的效果:

(let ((x 10))
  (let ((y (+ x 10)))
    (list x y)))

词法变量和闭包

默认情况下Common Lisp中所有的绑定形式引入的都是词法作用域变量。词法作用域变量只能被文本上位于绑定形式之内的代码所引用。

Common Lisp中将词法作用域和嵌套函数结合在一起时,按照词法作用域的规则,只有文本上位于绑定形式之内的代码可以引用词法变量,但是如果一个匿名函数包含了一个对于封闭区域内的词法变量的引用,例如下面的表达式:

(let ((count 0))
  #'(lambda ()
      (setf count (1+ count))))

依据词法作用域的规则, lambda 形式中对于 count 的引用是合法的。当这个含有引用的匿名函数作为 LET 形式的值被返回,通过 FUNCALL ,被不在 LET 作用域之内的代码所调用,这样会发生什么?正如你将看到的那样,如果 count 是一个词法变量,代码正常工作,控制流进入 LET 形式时所创建的 count 绑定根据需要将被尽可能长时间得保持下来,只要某处保留了一个对 LET 形式返回的函数对象的引用。这个匿名函数被称为一个 闭包 ,因为它封装了由 LET 创建的绑定。13

理解闭包的关键在于,被捕捉的是 绑定 而不是变量名所引用的对象的值。因此,一个闭包不仅能访问其封装的变量的值,还可以在闭包被不断调用时赋予其新值。例如,你可以将前面表达式所创建的闭包赋给一个全局变量:

(defparameter *fn* (let ((count 0))
                     #'(lambda ()
                         (setf count (1+ count)))))

然后,每一次你调用它时, count 的值将被加1:

CL-USER> (funcall *fn*)
1
CL-USER> (funcall *fn*)
2
CL-USER> (funcall *fn*)
3

单独一个闭包可以简单地通过引用来封装多个变量绑定。多个闭包也可以捕捉一个相同的绑定。例如,下面的表达式返回了一个由三个闭包组成的列表,一个闭包用来递增其所闭合的 count 绑定的值,另一个递减它,还有一个返回 count 绑定的当前的值:

(let ((count 0))
  (list
   #'(lambda () (incf count))
   #'(lambda () (decf count))
   #'(lambda () count)))

动态变量(Dynamic, a.k.a. Special, Variables)

词法绑定通过限制变量的作用域使代码易于理解。但是,有时候仍然需要一个全局变量——一个从程序的任何位置都可以引用到的变量。随意使用全局变量将使代码变得杂乱无章,就像毫无节制地使用 goto 那样,但全局变量有其合理的用途并以某种形式存在于几乎每一种程序语言当中。正如你即将看到的,Lisp的全局变量和动态变量,都更加有用并且更易于管理。

Common Lisp提供两种方式来创建全局变量: DEFVARDEFPARAMETER 。两种方式都接受一个变量名,一个初始值和一个可选的文档字符串。在被 DEFVARDEFPARAMETER 定义之后,该变量名将可以在任何地方使用来引用全局变量的当前绑定。全局变量习惯上被命名为以 * 开始和结束,遵守该命名约定非常重要。 DEFVARDEFPARAMETER 的示例如下:

(defvar *count* 0
  "Count of widgets made so far.")

(defparameter *gap-tolerance* 0.001
  "Tolerance to be allowed in widget gaps.")

这两种形式的区别在于 DEFPARAMETER 总是将初始值赋予定义的变量,而 DEFVAR 只会在变量未定义时才这样做。 DEFVAR 形式也可以不带初始值来使用,用于定义一个没有初始值的全局变量。这样的变量称为未绑定的( unbound )。

从实践上讲,应该使用 DEFVAR 来定义那些包含你想持久使用的数据的变量,即是你已经改动了使用此变量的代码。例如,假设前面定义的两个变量是一个用来控制工厂组件生产程序的一部分,使用 DEFVAR 来定义 *count* 变量就比较合适,因为到目前为止生产的组件的数量不会因为你改动了组件生产的代码就变得无效。14

另一方面,假设变量 *gap-tolerance* 对生产组件的代码的行为有影响。假如你觉着你需要一个或紧或松的容差值,然后改动了 DEFPARAMETER 形式中的值,那么就要在重新编译或者加载文件时使这一改变产生效果。

使用 DEFVARDEFPARAMETER 定义了一个变量之后,你就可以在任何地方来引用它。例如,可以定义下面的函数来递增已经生产的组件的数量:

(defun increment-widget-count ()
  (incf *count*))

全局变量的优势在于不用到处传递它们。

所有的全局变量都是动态变量。

从概念上说,为一个动态变量创建的新绑定会被压入一个为此变量设置的绑定栈中,对该变量的引用总是使用最新的绑定,当一个绑定形式返回时,它所创建的绑定会被从栈上弹出,从而暴露出前一个绑定。15

一个简单的揭示其如何工作的例子:

(defvar *x* 10)

(defun foo ()
  (format t "X: ~d~%" *x*))

上面这个 DEFVAR 为变量 *x* 创建了一个到数值10的绑定,函数 foo 中对 *x* 的引用将动态地查找其当前绑定,如果你从顶层调用 foo ,由 DEFVAR 创建的全局绑定就是唯一可用的绑定,所以它将打印出10:

CL-USER> (foo)
X: 10
NIL

但是你可以用 LET 创建临时的绑定来覆盖全局的绑定,这样 foo 将打印一个不同的值:

CL-USER> (let ((*x* 20)) (foo))
X: 20
NIL

现在不使用 LET 再调用 foo ,它将再次看到全局绑定:

CL-USER> (foo)
X: 10
NIL

现在定义另一个函数:

(defun bar ()
  (foo)
  (let ((*x* 20)) (foo))
  (foo))

注意中间的那个对函数 foo 的调用被包含在一个将 *x* 绑定到新值20的 LET 中,运行 bar 得到的结果如下:

CL-USER> (bar)
X: 10
X: 20
X: 10
NIL

可一看到,第一次对 foo 的调用看到的是全局绑定,其值为10。但中间的那个调用看到的却是新的绑定,其值为20。在 LET 之后, foo 再次看到了全局绑定。

和词法绑定一样,赋予新值仅仅影响当前绑定。为了理解这点,可以重定义包含一个对 *x* 赋值的 foo

(defun foo ()
  (format t "Before assignment~18tX: ~d~%" *x*)
  (setf *x* (+ 1 *x*))
  (format t "After assignment~18tX: ~d~%" *x*))

现在 foo 打印 *x* 的值,对其递增,然后再次打印它。如果只运行 foo ,将看到这样的结果:

CL-USER> (foo)
Before assignment X: 10
After assignment  X: 11
NIL

看起来挺正常,现在运行 bar

CL-USER> (bar)
Before assignment X: 11
After assignment  X: 12
Before assignment X: 20
After assignment  X: 21
Before assignment X: 12
After assignment  X: 13
NIL

注意 *x* 从11开始——之前的 foo 调用改变了全局的值。 bar 里面第一次调用 foo 将这个全局绑定的值递增到了12。中间的调用因为 LET 没有看到全局绑定。最后一个对 foo 的调用再次看到了全局绑定,并将其从12递增到了13。

那么它是怎样工作的? LET 是如何知道在它绑定 *x* 时打算创建的是动态绑定而不是词法绑定?这是因为该名称已经被声明为特殊的(special)了。16每一个由 DEFVARDEFPARAMETER 所定义的变量,其名字都将被自动声明为全局特殊的。这意味着无论何时你在一个绑定形式—— LET 或着函数形参或者其他任何创建了新的变量绑定的构造——中使用这样一个名字,其所创建的绑定都将是一个动态绑定。这就是为什么 *命名约定* 如此重要——如果你使用了一个你认为是词法变量而它却恰好是全局特殊的变量就很不好。一方面,你调用的代码可能在你意料之外改变此变量绑定的值;另一方面,你可能会覆盖一个由绑定栈的上一级代码所建立的绑定。如果你总是按照 * 命名约定来命名全局变量,你将永远不会意外地在你想要建立一个词法绑定的地方使用一个动态绑定。

也有可能将一个变量声明为局部特殊的。如果你在一个绑定形式中声明一个名字是特殊的,那么建立在这个变量上的绑定就是动态的而不是词法的。其他代码为了引用动态绑定可以局部地声明一个特殊的名字。但是,局部特殊变量相对使用得较少,所以你不需要担心它们。17

动态绑定使全局变量更易于管理,但重要的是要注意到它们仍然允许超距行为( action at a distance )。绑定一个全局变量具有两种超距效果——它可以改变下游代码的行为,也开启了下游代码可以为一个由栈的上一级所建立的绑定赋予新值的可能性。你应该只在需要利用这两个特性的时候才使用动态变量。

其他参考来源

  1. 《实用Common Lisp编程》-人民邮电出版社,第1版,田春译
  2. 《On Lisp中文版》 - 田春等译
  3. 静态绑定和动态绑定
  4. Common Lisp 变量作用域

Footnotes:

1

动态变量有时也称为特殊变量(special variable)。

2

前面的文章介绍了这点。

3

事实上,所有的类型错误都将被检测到这种说法并不是很准确——通过使用可选的声明来告诉编译器特定的变量将总会包含一种特殊类型的对象,从而关闭在一个特定代码区域内的运行时类型检查,这种做法是可能的。但是,这类声明通常用在代码开发和调试过后的优化当中,而不是在正常的开发期间。

4

同样请参考前文关于绑定的概念。

5

原文是 All values in Common Lisp are, conceptually at least, references to objects. 作为对特定类型的对象的一种优化,例如处于特定大小之下的整数以及字符,可能会被直接地表示在内存当中,而不是像其他对象那样会用一个指向实际对象的指针来代表。但是因为整数和字符都是不可修改的,所以是否存在引用同一个对象的多个不同的变量副本就无关紧要了。这就是形成 EQEQL 之间本质区别的原因——数字和字符的对象标识取决于特定Lisp平台的实现方式, EQL 可以保证当相同类型的两个不同对象用来表示相同的数字或者字符时二者是等价的。

6

译者注:注意为变量赋予新值和修改变量引用的对象是两个概念。

7

Common Lisp函数是“传值的”,但这些被传递的值是对对象的引用。这跟Java和Python的工作方式相似。

8

译者注:想要更好的理解,可以参考维基百科对evalution strategy的解释。

9

这里,函数行参变量具有词法作用域。

10

注意参考 脚注6

11

LET 形式和函数形参中的变量是以完全相同的机制创建的。事实上,在某些Lisp方言中,不是Common Lisp, LET 只是一个展开到一个匿名函数调用的宏。也就是说,在那些方言中, (let ((x 10)) (format t "~a" x)) 是一个展开到下列结果的宏: ((lambda (x) (format t "~a" x)) 10)

12

任何一个符号都拥有一个绑定栈。

13

译者注:可以理解为在Common Lisp的闭包中,一个变量绑定可以超过其作用域。

14

如果你特意想要重新设定一个由 DEFVAR 定义的变量,你可以直接使用 SETF 直接设定它,或者先使用 MAKUNBOUND 将其变成未绑定的,然后再使用 DEFVAR 形式重新求值。

15

标准并未指定如何在Common Lisp中使用多线程,但所有提供多线程的实践都遵循了由Lisp机所建立的原则,在每个线程的基础上创建动态绑定,一个对全局变量的引用将查找当前线程中最近建立的绑定,或是全局绑定。

16

这就是动态变量有时会被称为特殊变量的原因。

17

如果你一定想知道,你可以在 HyperSpec 上查找 DECLARESPECIALLOCALLY

Comments powered by Disqus