HOME BLOG

关于作用域的一个小结

1. 写在前面

我很早之前就想总结一下作用域相关的知识了,但是限于个人水平一直没有写。除开能力因素,另一个原因是没有找到动态作用域的语言,没有具体的例子来供我学习理解。通过 scheme 的 fulid-let 和 racket 的 parameterize 可以使用动态作用域,但我也不常用,一直以来也没什么印象。

直到 2021 年的 2 月份,我写(抄)下的一段 elisp 代码给我带来了直观的感觉:

(defun split-window-instead (way-func)
  (lambda ()
    (interactive)
    (let ((other-buffer (and (next-window) (window-buffer (next-window)))))
      (delete-other-windows)
      (funcall way-func)
      (when other-buffer
        (set-window-buffer (next-window) other-buffer)))))

(lazy-load-set-keys
  (("C-x |" . (split-window-instead 'split-window-horizontally))
   ("C-x _" . (split-window-instead 'split-window-vertically))))

上面代码的大意是实现水平/垂直分屏函数并设置快捷键,为了方便我使用了函数生成器的写法。但是当我按下快捷键时,出现了 void function way-func 的错误。思来想去,我才发现这是作用域的问题,闭包的有无是上面代码能否正确求值的关键。这一次尝试使我对动态作用域有了切身的体验。

好了,闲话少说,前言部分的主要目的是介绍文章的主要内容。本文的内容可以分为几个部分:

  • 一些相关名词的介绍和解释
  • 对作用域的介绍和解释
  • 对 elisp 中 lexical-binding 使用的介绍

最后一部分是 scheme 的 syntax-case 宏的使用介绍。要说的话和本文关系并不大,但是我也懒得再就它再水一篇文章了,更何况它和作用域问题也有点关系。希望该部分的几个例子能对你有所帮助。

本文充满了无聊的辩经而且长的一匹,看看你感兴趣的内容就行了。

2. 一些名词

本来我准备直接从作用域开始,但是查阅资料后我发现要弄清楚作用域还需要其他的一些概念。这一小节用来介绍与之相关的东西,比如名字绑定、上下文,环境等。

2.1. 什么是名字绑定(name binding)

名字绑定指实体与标识符之间的关联。标识符绑定到实体被称为引用该对象。最简单的,以下代码就建立了变量名 aint 型整数 19 的绑定:

int a = 19;

名字绑定并不限于变量,比如定义类型也算是名字绑定:

typedef int (*wocao)(int, int);

绑定与作用域有着很紧密的关系,因为作用域决定了哪些名字绑定到哪些对象。

绑定可以分为两类,即 动态绑定 (dynamic/late binding)和 静态绑定 (static/early binding)。动态绑定指名字绑定发生在程序运行时,而静态绑定指名字绑定发生在程序开始运行之前。【2】

与绑定相关的概念还有 重绑定 (rebinding)和 修改 (mutation),前者指变更标识符引用的对象,后者指对对象进行修改。后者的就是一般意义上的副作用(side effect)。在下面的代码中,前者是重绑定,而后者是修改:【2】

//the former
{
    int a = 1;
    {
        int a = 2;
    }
}
// the latter
{
    int a = 1;
    {
        a = 2;
    }
}

2.2. 什么是上下文(context)

“上下文”是一个非常宽泛的概念,撇开编程不管的话我们也会时不时碰到这个词语,例如出现在语文题目中的“联系上下文解释文中词语”。我查了查现代汉语词典,它的解释和例句如下:

上下文:指文章或说话中与某一词语或文句相连的上文和下文

例句:这个词的含义联系上下文不难理解

上文:书中或文章中某一段或某一句以前的部分

下文:(1)书中或文章中某一段或某一句以后的部分(2)指事情的发展或结果

相同的词语在不同的上下文中可能会表达不同的意思,例如“卧槽”,下面给出三个例子:

  • 网吧停电时的那一刻,人生第一次听到这么整齐的卧槽
  • 卧槽!维普54%,万方9%!!卧槽!
  • 卧槽!吓死人了好吗?!

闲话就到这里,接下来我们看看编程中的上下文指的是什么。

在维基百科【3】中是这样定义上下文的:

在计算机科学中,一个任务的 上下文 是被该任务(它可以是进程、线程或纤程)使用的数据的最小集合,它必须被保存来允许任务被中断,并在之后从中断点继续执行。上下文的概念在可中断任务中具有重要的意义,在任务被中断时,处理器将保存上下文并继续为中断程序提供服务。

概括一下,上下文是任务执行的最小数据集合,这个集合保存了任务执行所需的最小信息。

拿 8086 汇编来说的话,这个最小信息可以是 IP,CS 寄存器的值。CS 是代码段寄存器,存放段基址,IP 是指令指针寄存器,存放段内偏移地址。当出现中断时,CS 和 IP 会被压入堆栈保存起来,以保证中断结束后能够正常返回并继续执行。

Scheme 中的 call/cc 可以捕获 continuation,并一次或多次地回到这个捕获点继续执行。call/cc 捕获的正是程序执行的上下文。

宽泛一点来说, 上下文就是某件事要进行下去最少需要的东西 ,毕竟光凭“卧槽”两个字你是推断不出它里面包含的具体情感的。(话虽如此,我们也知道它一般用来表惊叹)

2.3. 什么是环境(environment)

”环境“也是一个很常见的概念,最近也发生了一件看上去对世界环境影响很大的环境事件呢 ( ^ω^)。我们先看看字典上对这个词的解释吧:

环境:(1)周围的地方(2)周围的情况和条件

例句:(1)环境保护(2)客观环境

对于生活中关于环境的例句如下:

  • 家庭环境,学习环境,生活环境以及社会环境都影响一个人的学习和成长
  • 几百万年的进化,人类适应了各种极端环境,不过都是自然条件下的极端环境。而静音房里的绝对安静,是人造的,不自然的环境
  • 心理环境是指某一时刻与个体有关的所有心理上的环境因素

我在网上找不到太多和编程相关的对环境的解释,环境貌似和上下文搁一起了,比如你在网上搜索“上下文和环境”,你会得到一堆关于 javascript 的“上下文环境”的搜索结果。以下关于环境的解释是我的个人理解,若您有更好的理解不妨与我交流交流。

Marx 说过“人是一切社会关系的总和”,根据这个句子仿写一下,对于某个个体而言,环境也可以是“周围一切事物的总和”。“周围”这个词用的很模糊,对于一个人来说,小到自己的房间,大到整个地球,甚至整个宇宙都可以作为“周围”。那么我是否可以这样认为:凡是可以与某个体交互的东西,不论主动或被动,它就属于该个体的环境的一部分。

上面说明了我对环境的认识:凡可以与某个体交互的东西都是该个体环境的一部分。那么,个体是否属于它的环境?下面说说我的理解:不具备主观能动性的个体不属于它的环境,具有主观能动性的个体属于它的环境。一个无意识质点是没办法对自身产生什么作用的,但是一个人扇自己一巴掌会疼。(主观能动性这个词我只是拿来用用,我不太清楚它的具体意思,这里我想表达的意思是能自己动)

自然,按照我的说法,宇宙除我之外的所有事物构成了我的环境,但是宇宙中的绝大多数东西与我的联系可谓是微乎其微,我可不在乎多少光年外向我飞过来的光。要想让环境这个概念变得更加有用,我们可以舍去大多数的次要部分,只关注主要部分。对于编程开发,我们有所谓的编程开发环境,程序运行有运行时环境,等等。

2.4. 总结

在我看来,上下文就是某件事要进行下去最少需要的东西,而环境就是与某个体有交互的所有东西构成的总体。按我这么理解的话,上下文也算是一种环境,借用集合的概念,它是某件事的环境的子环境。

当然了,这只是我的一种理解。 把上下文和环境看作同一事物也是一种理解 。下文不对两者进行明显的区分,可能会存在混用。

3. 什么是作用域(scope)

首先,我们需要知道作用域是用来描述什么的 —— 它描述的是名字绑定在程序中的可见性。 某个名字绑定的作用域就是程序中该绑定可见的那一部分

“作用”表示影响,而“域”表示影响范围,这么看的话有点像物理中的电磁波,只要存在发射源的话,除了有电磁屏蔽的部分,周围都会有电磁波存在。

“作用域” 也可以用来指在程序的某个部分或某个点所有可见的名字绑定,但是这个时候使用上下文或环境更加合适。毕竟作用域描述的是某个名字绑定的作用范围,而上下文描述的是某一点可见的所有名字。

3.1. 为什么要有作用域

这方面我也在网上几乎找不到什么资料,凡是搜索“作用域”都会不可避免的搜到 javascript 相关的东西,而且大多都是闭包啊,引入 let 块作用域啊之类的东西。所以,这一小节的内容纯粹就是我的个人发挥了,如果有不同意的地方,欢迎与我交流。

如果你学过一点简单的汇编的话,你应该知道有用来定义变量的伪指令。通过它定义的变量并没有什么作用域的概念,或者说它的作用域就是全局作用域,在汇编源文件的任何地方你都可以使用它。但这样以一来就不得不面对变量名冲突的问题,因为文件内不得存在两个名字相同的变量定义,在定义变量的时候还需要考虑是否存在重名。

这样自然是不利于模块化的,在编写一部分代码时还不得不考虑另一部分。考虑到不是所有的变量都要被所有人使用,给变量划分一个作用区域,让区域外的代码看不见该变量的话就 ok 了。作用域对各个变量的势力范围做了一个划分,有利于更好的模块化。维基百科【8】上这样写道:

It is considered good programming practice to make the scope of variables as narrow as feasible so that different parts of a program do not accidentally interact with each other by modifying each other's variables.

3.2. 各种各样的作用域

作用域可以龟缩于一个简单的表示式,也可以囊括整个程序,这取决于具体的作用域规则。在最小与最大的两者之间还存在许多其他的作用域。

最简单的作用域规则就是全局作用域 —— 在整个程序中所有的实体都是可见的。最基础的模块化作用域是一种两层作用域,它由全局作用域和函数局部作用域组成。

3.2.1. 表达式作用域(expression scope)

表达式作用域指名字绑定的作用域是一个表达式。许多语言中都有表达式作用域,尤其是提供了 let 表达式的函数式语言。下面的例子使用 elisp 说明了表达式作用域的范围,使用 ; 围成的小盒子就是 a 在 let 表达式中的作用域:

(let ((a 1))
  ;;;;;;;;;;;;;;;;;;;;;
  ;; (+ a            ;;
  ;;    (let ((b 2)) ;;
  ;;      (+ a b)))) ;;
  ;;;;;;;;;;;;;;;;;;;;;

在 C 语言中,函数原型中的变量名的作用域也是表达式作用域的,它的作用域仅限于函数原型,被称作 function protocol scope。这个作用域看起来很滑稽,它起到的作用也仅仅是给各参数一个名字,但它确实是一个作用域。

3.2.2. 块作用域(block scope)

名字绑定的作用域是一个块,这样的作用域就是块作用域。它几乎存在于所有的块结构化编程语言中,例如 C 系语言。大多数情况下块一般都在函数内。

块作用域一般用于控制流,比如 if,while 和 for 循环。但是拥有块作用域的语言一般都会允许使用“裸露”的块,这样就可以在块中定义辅助变量并使用,在块终结时销毁。

块可以用来隐藏名字绑定。如果在块的外面定义了 n,在块的里面也可以定义名叫 n 的变量,它会遮盖外面的 n。但是这样的风格一般被认为是不好的,因为它可能会导致潜在的作物。某些支持块作用域的语言不允许局部变量遮蔽另一局部变量。

下面是一些块作用域的例子,这些变量的作用域用注释方框标出来了:

for (i = 0; i < N; ++i) {
/******************************/
/* printf("%d \n", i);        */
/* i = i + 1;                 */
/******************************/ //scope of i
}


int n = 1;
{
    int n = 2;
    /********************/
    /* n = n + 1;       */
    /* n = n + 2;       */
    /* n = n + 3;       */
    /* printf("%d", n); */
    /********************/ // scope of inner n
}


int factor(int n)
{
    int i = 0;
    /**********************************/
    /* int res = 1;                   */
    /* for (i = 1; i < n; i++)        */
    /* {                              */
    /*     res = res * i;             */
    /* }                              */
    /* return res;                    */
    /**********************************/ // scope of i
}

在 elisp 中貌似不存在块作用域,不过 let 表达式作用域已经够用了。

3.2.3. 函数作用域(function scope)

名字绑定的作用域是整个函数的作用域就是函数作用域。几乎所有的语言中都有函数作用域,语言提供了一种在函数或子例程中创建局部变量的方法:它的作用域从函数头开始,在函数返回语句处结束。大多数情况下,它的生命周期就是函数调用的过程 —— 它在函数调用开始时被创建,在函数返回时被销毁。不过某些语言提供了静态局部变量(比如 C),它的生命周期贯穿程序的生命周期,但是它的作用域仅限于所在的函数。

由于这里我还没有提到动态作用域和静态作用域,关于函数作用域的例子我留到下面进行介绍。这里先用 C 语言的函数来作为例子,由于 C 语言的函数不允许嵌套,所以比较简单:

int add (int a, int b)
{
    /**********************************/
    /* int i = 0;                     */
    /* for (i = 0; i < a; i++)        */
    /* {                              */
    /*     b = b + 1;                 */
    /* }                              */
    /* return b + a - a;              */
    /**********************************/ //scope of a and b
}

3.2.4. 文件作用域(file scope)

名字绑定的作用域是一个文件目的作用域就是文件作用域。文件作用域这种叫法一般都是指 C 语言,在 C 语言中它被称为文件链接。在文件的 top level 定义的函数和变量的作用域就是从它们的声明处直到文件末尾。这也可以被看做是一种模块作用域。

3.2.5. 模块作用域(module scope)

名字的作用域是一个模块,这样的作用域就是模块作用域。模块作用域可以在模块化编程语言中使用,这些语言以模块作为复杂程序的单元,它们允许隐藏信息并暴露有限的接口。Python 是一个很典型的例子。

3.2.6. 全局作用域(global scope)

作用域是整个程序的作用域被称为全局作用域。一般来说,使用具有全局作用域的变量(即全局变量)被认为是坏习惯,因为它会可能导致名字冲突或不小心掩盖变量。全局作用域一般用于其他类型的名字,比如函数名,类名和数据类型名。

3.3. 作用域与名字解析(name resolution)

会不会存在两个名字相同的名字绑定的作用域部分重叠的情况呢?这样的代码很容易想出来,比如全局变量与块变量同名的情况,以及块嵌套中的重名变量:

// global variable and block varibale
int incf19 = 42;

int incp10(int a, int b)
{
    int incf19 = 43;
    printf("%d", incf19);
}

//block nest block
int yy1 = 1;
{
    int yy1 = 2;
    printf("%d", yy1);
}

这样一来,相同名字的绑定的作用域就存在重合,在重合区域内使用该名字的话会指向哪个实体呢?如果运行上面的代码的话,incp10 会打印 43,而第二段代码会输出 2。它们都使用了最里面的名字绑定。

我在 wikipedia 上面找到了一个描述这种特性的术语,即名字解析【7】。在编程语言中, 名字解析指通过程序表达式中的符号找到对应的实体 。对于变量而言,就是找到变量的值。名字解析规则并不只和作用域有关,微机百科上面有更加详细的描述,由于这里只关注作用域其他内容就从略了。

就像上面看到的,只有最里层的名字绑定起到了作用。大多数情况下名字解析都会尝试找到具有“最小”作用域的名字绑定,这样的规则被叫做遮蔽(shadowing)或名字掩盖(name masking),它在存在重叠作用域时起作用。

在没有了解名字解析这个概念之前,我一直以为名字遮盖是作用域的一部分,当两个变量重名时,里面的变量会把外面的变量的作用域“咬”掉一口,外面变量的作用域在里面变量的作用域那里是不存在的。现在看来,它们应该是独立的概念,毕竟我还可以修改规则为使用最外层的名字绑定,虽然看起来肯定很奇怪就是了。

最后插一句,名字解析也是有动态和静态之分的,动态名字解析即在运行时确定名字对应的实体,而静态名字解析是在运行前确定,这和作用域是动态还是静态有关。

3.4. 作用域与生存期(extent)

说到作用域就不得不谈另一个概念,那就是 extent。网上有人将其翻译为“生命周期”,但是这已经是 lifetime 的翻译了,我这里就翻译为“生存期”【5】与其区分。

如果说作用域描述的是名字绑定的空间范围的话,那么生存期描述的就是名字绑定的时间范围,它描述的是名字绑定的动态特性。在运行时,每个绑定都有它自己的生存期,绑定的生存期从绑定开始存在开始,到绑定消失而结束。绑定的生存期是程序执行时间的一部分,在此期间名字绑定不发生变化,名字总是引用相同的实体。

比较简单的例子就是 C 语言中的函数变量,它的作用域就是函数体,它的生存期从函数调用开始,在函数返回后结束。

如果编程语言没有垃圾回收机制的话,程序执行点离开变量作用域后,仍然存在的变量可能会导致内存泄漏,因为名字已经不可见了,为变量分配的内存不可能被释放了。这种情况下,变量的生存期是长于程序执行点停留在变量作用域内的时间的,比如 C 语言的静态变量和 Lisp 中的闭包。当程序再一次来到变量的作用域时,这个变量就又可以被访问了。

在参考资料【6】中列出了几种有用的作用域和生存期,我把它们列在下面。

对于作用域:

  • 词法作用域(lexical scope),对某个名字的引用只能出现在程序的某部分内,这部分程序被包含在某个名字绑定构造结构中。上面提到的表达式作用域、块作用域和函数作用域等都属于词法作用域。
  • 不定作用域(indefinite scope),对名字的引用可以出现在程序的任意位置。这就是全局作用域。

对于生存期:

  • 动态生存期(dynamic extent),在名字绑定建立到名字绑定瓦解期间可以对名字进行访问。带有动态生存期的绑定就像栈一样,最先绑定的最后瓦解。函数的参数变量就具有动态生存期。
  • 不定生存期(indefinite extent),只要还对名字存在引用,那么名字绑定就会一直存在。例如 C 语言里面的静态变量或全局变量。

【6】中还这样写道:

In addition to the above terms, it is convenient to define dynamic scope to mean indefinite scope and dynamic extent

动态作用域被定义为同时具有不定作用域和动态生存期的名字绑定。不过【6】中也提到,dynamic scope 是一个误称,因为它除了描述了作用域还描述了生存期,管它叫动态绑定(dynamic binding)可能更好些。

好了,有了上面诸多概念的介绍和有关例子,我们终于可以谈谈什么是 动态和静态作用域 了。

3.5. 什么是“静态作用域”

在开始具体的讨论之前,我觉得有必要对动态作用域和静态作用域这两个名字做一个说明。动态作用域和静态作用域的叫法是不太准确的,因为严格来说作用域描述的只是绑定的空间性质。管它们叫“动态作用域规则”和“静态作用域规则”可能更好一些。我会在下文中给所有的动态和静态作用域加上双引号,以此和纯粹的作用域相区分。

首先,什么是上下文?上下文指的是程序某一点所有可见的名字,某一点的词法上下文则是程序未运行时它所有可见名字,上下文可以根据程序代码文本推断出来。相比于词法上下文,动态上下文增加了调用栈信息。

在“静态作用域”中,一个名字总是指向它的词法上下文。因为名字解析只需要根据作用域规则分析静态的程序文本,所以它也被叫做“词法作用域”。

既然通过代码文本就可以确定名字的指向,那我自然可以这样认为:一个函数在定义的时候就确定了它的词法上下文,即确定了它能够引用的名字,并在运行时保持引用不变。

举个例子的话,比如在 Scheme 中的这样的一个函数:

(define add-gen
    (lambda (x)
      (lambda (y)
        (+ x y))))

当我们使用一个参数调用 add-gen ,我们会得到一个匿名函数,这个过程可以看成是完成了以 y 为形参的那个匿名函数的定义。根据“静态作用域”的规则,它里面的 x 就是 add-gen 接收的参数 x,所以它这个函数的功能就是接收一个参数然后与 add-gen 接收的 x 相加。由 add-gen 我们可以创建出许多不同的匿名函数,它们的不同之处在于它们的 body 内的 x 指向不同的 x,例如:

(define add1 (add-gen 1))
(define add10 (add-gen 10))
(add1 1)
=> 2
(add10 1)
=> 11

add-gen 创建的 x 具有函数作用域,但是它的生存期并不止于函数生存期,而是不定生存期,因为 add-gen 创建的匿名函数需要使用这个 x ,至少在匿名函数因为无用而被回收之前,对应于它的 x 一定仍然存在。静态绑定具有不定的生存期。

因为上下文在定义时就已经确定了,所以运行时添加的名字不会产生影响,这个名字对它作用域外的东西没有作用,例如:

(define x 1)
(define (add y) (+ x y))
(let ((x 2))
  (add 1))
=> 2

感觉所谓的“静态作用域”就是能够在程序运行前,根据不同名字的作用域来确定每个名字引用的具体位置。我有点感觉“静态作用域”和静态的名字解析是一回事,虽说名字解析需要用到作用域规则。

3.6. 什么是“动态作用域”

有了上面的关于“静态作用域”的总结,我们来看看什么是“动态作用域”。

上面我们说到动态上下文比静态上下文多了调用栈信息,也就是说在“动态作用域”下,对名字的解析需要用到动态上下文,这时的解析就是所谓的动态名字解析了。这样一来,在定义函数的时候就无法得知函数中使用的名字的指向,必须等到运行时才能确定。这也就是为什么它被叫做动态的原因。

让我们把上面的两个例子在使用动态作用域的 elisp 中重复一下:

(defun add-gen (x)
  (lambda (y) (+ x y)))
(fset 'add1 (add-gen 1))
(add1 1)
=> error
(let ((x 1))
  (add1 1))
=> 2

(setq x 1)
(defun add (y) (+ x y))
(let ((x 2))
  (add 1))
=> 3

可以看到, add-gen 在动态作用域中不能正常工作,因为它生成的函数中的 x 需要等到运行时才进行解析,由于没有定义全局的 x ,函数找不到 x 从而出现错误。在第二个例子中,即便定义 add 时已经定义了全局的 x ,函数调用时仍然取决于最近的 x 值,这里所说的“最近”应该理解为时间上的最近,这就跟调用栈可以扯上关系了。

在“动态作用域”中创建一个名字绑定就像是将绑定压入名字的全局栈中,并在退出这个名字的作用域后被弹出。动态作用域中的名字绑定 总是全局的 ,依靠这个 push/pop 机制可以让同一名字在不同的时间绑定不同的实体。根据以下代码你是区分不了到底是使用了动态还是静态作用域的:

(let ((a 1))
  (+ a (let ((a 2)) a)))
=> 3

如果上面的代码运行在“静态作用域”下,那么每个 let 中的 a 使用的都是表达式作用域,但如果上面的代码运行在动态作用域下,a 至始至终都是不定作用域,即全局作用域。“动态作用域”中是没有所谓的词法作用域的,只存在不定作用域。

3.7. 总结

这一节中我对作用域进行了一个比较详细的介绍。我介绍了作用域的作用,作用域的种类,以及名字解析这个概念。

关于“动态作用域”和“静态作用域”的区别,我在知乎上找到了一个不错的回答【9】,虽然有些不完整但是非常容易理解:

词法作用域的函数中遇到既不是形参也不是函数内部定义的局部变量的变量时,去函数定义时的环境中查询。

动态域的函数中遇到既不是形参也不是函数内部定义的局部变量的变量时,到函数调用时的环境中查。

我感觉“动态作用域”和“静态作用域”并不是一种作用域,而是名字解析的不同规则。

关于“动态作用域”和“静态作用域”各自的优缺点我没有提,详细的对比可以参考维基百科。

4. elisp 的 lexical-binding

啰里啰唆的说了那么多,总算是到本文的主要部分。(并不是)

在这一节中,我首先会介绍 elisp 文档中对动态作用域和静态作用域的描述,然后加上一些使用用例。

4.1. elisp 文档中的内容

每个局部绑定都有一定的作用域和生存期。作用域指源代码文本中可以访问绑定的位置。生存期指程序执行时绑定存在的时间。

默认情况下,Emacs 中创建的局部绑定都是动态绑定。这样的绑定具有动态作用域,这意味着程序的任意部分都可能访问到它。它也具有动态生存期,这意味着绑定只在绑定结构(比如 let 的 body 部分)被执行时才存在。

Emacs 也可以创建词法绑定,词法绑定具有词法作用域,这意味着对变量的任何引用都必须位于文本上的绑定结构内。它也具有不定生存期,这意味着在某些情况下绑定可以在绑定结构执行完成后仍然存在,这是通过叫做闭包的特殊对象做到的。

当变量具有动态绑定时,它在任意位置的当前绑定就是最近(时间意义上)创建的局部动态绑定,如果这样的局部绑定不存在则是全局绑定。动态绑定在 Emacs 中的实现方式非常简单。每个变量都有一个 value cell,它指向变量的当前动态值(也可以为空值)。当某个符号被赋予局部动态绑定时,Emacs 将 value cell 中的内容保存在一个栈中,把新的局部值放入 value cell。当绑定结构结束运行时,Emacs 会从栈中弹出旧值并放入 value cell。

动态绑定是一个强力的特性,它允许程序引用不在文本作用域中定义的变量。然而,如果不加限制的使用的话,它也会让程序变得难以理解。

词法绑定在 Emacs 24.1 版本作为可选特性引入。词法绑定更有利于优化,使用它的程序在未来的 Emacs 版本中可能会运行的更快。使用词法绑定时,每个绑定结构会定义一个词法环境(lexical environment,叫词法上下文应该也行),它指定了绑定构造内的变量及其值。当 Lisp 求值器想要获取变量的当前值时,它首先会在词法环境中寻找,如果没找到它会查找符号的 value cell,这里面存放的是动态值。

词法绑定具有不定生存期,即便绑定构造已经结束执行了,它的词法环境可以通过闭包对象被保存(keep around)。在定义有名或匿名函数时会创建闭包,闭包是一个包含词法环境的函数,这个环境在函数被定义时就存在了。当闭包被调用时,在函数定义内的所有词法变量会使用词法环境。

4.2. 特殊变量与 defvar

即便启用了词法绑定,某些变量仍然是动态绑定,它们被称作特殊变量(special variables)。使用 defvar,defcustom 和 defconst 定义的变量就是特殊变量。

例子如下:

(setq lexical-binding t)
(defvar a 1)
(defun b (x)
  (+ a x))
(let ((a 2))
  (b 1))
=> 3

4.3. lexical-binding 的使用

通过上面的例子可以看到,改变变量 lexical-binding 的值就可以确定到底是使用动态绑定还是静态绑定。设置 lexical-bindingnil 使用动态绑定,否则使用词法绑定。

这个设定不一定需要使用 setq ,在源文件的头部加上以下代码也可,这样在配置文件载入时里面所有东西都按静态绑定规则来求值:

;;; -*- lexical-binding: t -*-

一般来说,一致性地使用某一种绑定方式就可以了。以下代码只是做一个实验,观察一下在某一绑定方式下求值得到的东西在另一种绑定方式下的值。

在动态绑定下得到的函数放到词法绑定那里取求值:

(setq lexical-binding nil)
(setq y 1)
(defun yy (x)
  (+ x y))
(setq lexical-binding t)
(let ((y 2))
  (yy 1))
=> 3

在词法绑定下得到的函数放到动态绑定那里取求值

(setq lexical-binding t)
(setq y 1)
(defun yy (x)
  (+ x y))
(setq lexical-binding nil)
(let ((y 1))
  (yy 2))
=> 3

这两段代码的结果是一样的,第一段在调用 yy 时使用的是最近绑定的 y 也就是 let 中的那个,第二段使用的 y 是全局绑定的 y 也就是 (setq y 1)y ,但是在动态绑定环境下出现同名绑定时符号的 value cell 会被替换为最新的值,虽然调用 yy 时其中的 y 指向的是全局的 y ,但是使用的值仍是最新的 value cell 中的值。

上面我只提到了和函数有关的例子,有意思的是,elisp 中的宏在这两种绑定下有不同的行为:

(setq lexical-binding nil)
(defmacro yy1 (x)
  `(+ 1 ,x))
(symbol-function 'yy1)
=> (macro lambda (x) (list '+ 1 x))

(setq lexical-binding t)
(defmacro yy2 (x)
  `(+ 1 ,x))
(symbol-function 'yy2)
=> (macro closure (y yq yp ys yg ty stream-null stream-null yyy cc cc yao yyy t) (x) (list '+ 1 x))

可以看到,在动态绑定下定义的宏就是简单的宏,而在静态绑定下得到的宏还多了闭包。在 elisp 文档中举了这样一个例子:

(defmacro foo (a)
  (list 'setq (eval a) t))
(setq x 'b)
(foo x) → (setq b t)
;; but
(setq a 'c)
(foo a) → (setq a t)

上面的宏的问题在于,当宏参数名与实际参数名相同时,调用宏时得到的名字绑定 a -> a ,由于是动态绑定所以会影响全局的 a 值,从而对宏里面的 eval 产生影响,所以第二个例子出现了问题。文档里面也建议不要在宏里面求值。那么,上面的代码在词法绑定中会出现什么结果呢?

(setq lexical-binding t)
(defmacro foo (a)
  (list 'setq (eval a) t))
(setq a 'c)
(foo a)
c => t

这是由于词法作用域下宏调用不会修改全局的 a 的值,而 eval 又在空环境(即全局环境)下求值,所以能够得到正确的结果。

这只能说是个比较好玩的例子,在实际编写代码时应该尽量使用一种绑定方式。

5. Scheme 的 syntax-case

好了,本节是本文的最后一节,会对 Scheme 的 syntax-case 宏进行一个简单的介绍,也作为我学习 Scheme 的一个小结。如果您只对 syntax-case 感兴趣的话,您也只需要阅读本文的这一部分。

syntax-case 的发明者是 chez-scheme 的作者 Kent,据他所说,syntax-case 的功能比 syntax-rules 要强很多。强大自然也要有相应的代价,那就是很难写。参考资料【10】中是这样说的:

syntax-case 非常强大,既可以支持高级宏,也可以支持过程宏,并且可以处理卫生和不卫生。 但是强大的代价是这个宏系统非常复杂,理解,使用和实现上面都是。

这一节主要讨论的问题是宏的卫生性、什么是 syntax-case 以及 syntax-case 的简单用法。syntax-case 在 R6RS 中被引入 Scheme,但是在 R7RS 中又不见了,理由可能是太难实现了。为了使用 syntax-case,这里我是用的是 Kent 的 chez-scheme 以及 Racket,Racket 里面的 syntax-case 与 Chez 里面的有些区别,但是大体上还是非常相似的。syntax-case 的参考资料我主要使用 the scheme programming language (TSPL)以及 Racket 的官方文档。

5.1. 什么是卫生宏(Hygienic macro)

卫生宏保证展开时不会出现意外的名字捕获

那么什么是意外的名字捕获呢?下面的一段 C 代码【11】可以说明这个问题:

#define INCI(i) do { int a=0; ++i; } while (0)
int main(void)
{
    int a = 4, b = 8;
    INCI(a);
    INCI(b);
    printf("a is now %d, b is now %d\n", a, b);
    return 0;
}

经过预处理后,它变成了:

int main(void)
{
    int a = 4, b = 8;
    do { int a = 0; ++a; } while (0);
    do { int a = 0; ++b; } while (0);
    printf("a is now %d, b is now %d\n", a, b);
    return 0;
}

根据名字遮蔽规则,do while (0) 会使用里面定义的 a,这样就起不到对外面的 a 增加的效果,最后得到的结果就是 a is now 4, b is now 9

除了宏内部的名字对外部的影响,外面的名字绑定也可能对宏内部产生影响,比如:

(defmacro yy-unless (condit &rest exp)
  `(if (not ,condit)
       (progn ,@exp)
     nil))

(cl-letf (((symbol-function 'not) (lambda (x) x)))
  (yy-unless t (+ 1 2)))
=> 3

很明显,在动态作用域中宏里面的 not 指向了一个恒等函数,没有起到逻辑取反的作用。

从上面的例子中我们可以看到两个问题:

  • 宏里面的东西可能在不经意之间对外部造成影响,例如上面的内部定义 a 影响了外部定义的 a
  • 外面的一些名字可能对宏的内部造成影响,例如上面对 not 的重绑定影响了 yy-unless 宏的展开。关于捕获问题可以进一步参考 On Lisp,上面有更详细的分析。

卫生宏就是为了解决这两个问题而出现的,第一个问题可以通过对宏内部使用的名字进行重命名解决,它不同于我们普通的手动重命名,它应该是与任何的外部名字没有冲突的名字。后一个问题可以通过”静态作用域“解决,在定义宏的时候就确定宏内名字的指向。上面的例子中,我通过启用 lexical-binding 解决了这个问题。

对于卫生宏而言,上面两个问题是不存在的,只要你按照卫生的方法写代码的话就不会有名字捕获的问题,粗略的说就是里面不影响外面,外面不影响里面:

(define-syntax my-unless
  (syntax-rules ()
    ((_ condition body ...)
     (if (not condition)
         (begin body ...)))))

就解决意外的名字捕获这个问题而言,卫生宏只是一种解决方案而已。更多的解决方法可以参考【11】

5.2. 什么是 syntax-case

在这里我默认你已经学过了 syntax-rules,对 Scheme 的宏有了一定的了解,一些比较基础的东西我就不做过多解释了,我主要介绍与 syntax-case 相关的东西。这里我也不详细说明 syntax-case 的语法了,通过一些例子你就明白了,具体的语法可以参考书上的定义。

通过上面的 yy-unless 那一小段过程宏,你应该对宏的基本作用有了一定的了解,那就是从一个表达式变换得到另一个表达式。但是需要注意的是,经过 yy-unless 变换后我们得到的只是一个 list 而已,它并不包含任何的上下文信息,具体的上下文还有待在求值中确定。而 syntax-case 就不一样了,它接收的东西就已经是 被词法解析过的对象 ,你在宏变换的过程中可以对这些上下文信息进行修改,并得到 具有新的上下文的对象 ,再被拿去求值。

syntax-case 接收的语法对象有四种,分别是:

  • 非序对,非向量,非符号的值
  • 含语法对象的序对
  • 含语法对象的向量
  • 被包裹的对象(wrapped object),以下简称 WO

在上面的四种语法对象中,原子类型的只有标量值,原子值容易理解,比如数字、字符和字符串之类的东西,那么 WO 是个什么东西呢?它就是带有词法上下文信息的表达式,其中的表达式并不一定要是原子值。如果 WO 里面含有序对或者向量的话,它是可以拆分的。

那么,我们要怎样获得一个表达式以及所有名字对应的上下文呢?这里就要用到 define-syntax 这个特殊形式啦,和 define 定义的普通函数不一样,它使用特殊的方法来处理参数,以获得表达式及其上下文,就像这样:

(define-syntax showyy
  (lambda (x)
    (display x)
    1))


(showyy (lambda (x) (+ x 1)))
=> #<syntax (showyy (lambda (x) (+ x 1)))>1

上面得到的转换结果是一个被 #<syntax> 包裹的对象,以及数字 1,在最后留个数字是以数字作为转换的输出而不引发空输出错误: Exception: invalid syntax #<void> 。这样一来,我们就获得了一个包含上下文的表达式,虽然我现在还没对它做什么。

得到了 WO 后我们就可以开始拆分它了,syntax-case 就是我们的拆分工具,它使用模式匹配的方式将一整个 WO 拆成若干个小的 WO 便于我们进一步进行变换:

(define-syntax apart-1
  (lambda (x)
    (syntax-case x ()
      [(_ b) #'b])))

上面的宏的 syntax-case 的模式(pattern)是 (_ b) ,它表示匹配一个表,这个表有两个元素,而且忽略掉了第一个元素。因为第一个元素就是宏的名字,我们是不需要的,所以忽略掉了。 b 一般被称为模式变量(pattern variable),

上面的方括号里面除了模式 (_ b) 外还有一个看起来很奇怪的东西 #'b ,它是 syntax-case 的输出表达式,其中的 b 是一个简单的模板(template)。就像我们上面提到的那样,syntax-case 的输出也是带上下文的表达式,也就是一个 WO。 #'b 的正规写法其实是 (syntax b)syntax 是一个特殊形式,它接受一个模板并将出现在模板中的模式变量插入模板中,输入表达式和模板中的上下文信息被保留至输出的表达式中,以此维持“静态作用域”。

上面这句话绕的很,我用 elisp 中相似的代码来举个例子:下面代码的功能是将表中的 3 号和 4 号元素换个位置并返回原表

(defmacro tr-1 (a b c d)
  `(list ,a ,b ,d, c))

(define-syntax tr-2
   (lambda (x)
    (syntax-case x ()
      [(_ a b c d) #'(list a b d c)])))

#' 的功能还是挺方便的,接受参数是表的话就直接把表里面的所有模式变量都给替换然后返回新的表。但是在 Racket 中你还需要加一层 syntax-e,不然只能得到一整个 WO,而不是由多个 WO 组成的 list。)

上面两段代码在替换上意思是差不多的,都是把模板中的模式变量替换成对应的语法对象了,不过在 Scheme 中还保留了相应的上下文信息。在上面说到上下文信息时还特别强调了信息的来源,即输入的表达式和模板表达式的作用之和,之所以两者都提到是因为在 syntax-case 宏的模板中是可以对原上下文进行修改的。(修改了的话可能宏就不怎么卫生了)

就像存在 quote ,就有 quasiquoteunquoteunquote-splicing' and ` and , and ,@ )一样,在宏中除了 syntax ,还有 quasisyntaxunsyntaxunsyntax-splicing#' and #` and #, and #,@ )。在一般求值时, `, 以及 ,@ 配合起来可以实现表内局部求值,就像这样:

`(1 2 ,(+ 2 3) ,@(list 1 2 3))
=> (1 2 5 1 2 3)

` , ,@ 相似,使用 #` #, #,@ 可以实现宏的输出表达式的局部计算,通过这个机制可以方便地实现宏的递归扩张,以下代码的功能是把一连串的函数套起来调用,函数功能和宏是一样的:

(define func-series
  (lambda funs
    (lambda (x)
      (let f ([x x] [flist funs])
    (cond
     ((null? flist) x)
     (else
      (f ((car flist) x) (cdr flist))))))))
((func-series 1+ 1+ 1+) 1)
=> 4

(define-syntax lambda-series
  (lambda (x)
    (syntax-case x ()
      [(_ e1 e2 ...)
       #`(lambda (x)
         #,(let f ([rest-func #'(e1 e2 ...)])
             (if (null? (cdr rest-func))
                 (syntax-case rest-func ()
                   [(f) #'(f x)])
                 #`(e1 #,(f (cdr rest-func))))))])))

(expand '(lambda-series 1+ 1+ 1+))
=> (lambda (x) (1+ (1+ (1+ x))))

((lambda-seires list list list list) 1)
=> ((((1))))

((lambda-series 1+ 1+ 1+ 1+) 1)
=> 4

上面宏的 let f 绑定的地方的 #'(e1 e2 ...) 其实和 (list #'e1 #'e2 ...) 是等价的。这个点我在上面提了一下。还需要注意的就是 #, 里面的表达式最终的返回值要是一个 WO。

通过 #' #^ #, 我实现了上述功能, #,@ 的功能和 #, 类似,但是它还会剥掉一层 list,就像 ,@ 一样。看了上面的宏代码,是不是感觉有点恶心?恶心就对了,这一小段代码写了我 15 分钟。我尝试使用 syntax-rules 来实现相同的功能,然后发现以我的能力貌似做不到,如果你想出来请一定告诉我。

接下来要介绍的两个函数是非常厉害的,通过它们可以在宏展开过程中进行一些计算和改变默认的静态作用域规则,来达到一些奇妙的效果。

syntax->datum 接受一个语法对象作为参数,它会“脱掉”参数所有的上下文信息,并取得对应的“数据”。对于标识符参数, syntax->datum 会得到标识符对应的符号。

以下的宏接受一个函数名和一个数字来确定函数的调用次数:

(define-syntax funn
  (lambda (x)
    (syntax-case x ()
      [(_ f n)
       #`(lambda (x)
       #,(let g ([n1 (syntax->datum #'n)])
           (if (zero? (- n1 1))
           #'(f x)
           #`(f #,(g (- n1 1))))))])))

((funn 1+ 10) 1)
=> 11

这个宏只能接受数字来指定迭代次数,如果接受非简单值表达式的话, syntax->datum 会将其变为表或标识符,而不是数字,从而得不到你所期望的结果。这里我们可以在变换后使用 eval 来获取值,但是这样会存在一个问题,那就是转换之后原有的上下文信息全部都丢掉了,只能在空环境中求值,这样可能会带来意想不到的后果。

datum->syntaxsyntax->datum 是反过来的,它将一个“数据”转变为语法对象。

syntax->datum 是把上下文信息给剥掉, datum->syntax 把上下文信息加上来。那么,拿什么来作为上下文信息的提供者呢?那自然是带有上下文信息的模板标识符了。这里的模板标识符可以是模式变量,也可以是模板中生成的或变换过的标识符。经过变换后,数据就好像是塞到了模板标识符的环境中一样。模板标识符一般是输入的一个关键字,而待转换的对象一般是一个标识符。

“数据被塞到模板标识符的环境中”这句话有两点需要注意,一个就是这个数据具有了和模板标识符一样的上下文,另外还需要注意的是这个数据的作用域。 datum->syntax 只是给它填充了上下文,而没有对它的作用域做什么工作。如果你它什么也不做的话,它的作用域和模板标识符是一样的,把它包到某些构造结构中的话,它就具有与之匹配的作用域了。

datum->syntax 允许转换器(也就是 syntax-case)通过创建就像是出现在输入形式中的 隐含标识符 (implicit identifiers)来“掰弯”词法作用域。这也就允许宏在输出的语法形式中引入没有显式出现在输入形式中的绑定或引用。

需要注意的是,模板标识符的环境是指模板标识符 被引入时 的环境,这一点很重要。

C 语言的宏是“动态作用域”的,你看:

#define Add_a(x) (x + a)
int a = 2;
int f (int x)
{
    int a = 3;
    return Add_a(x);
}
int g (int x)
{
    int a = 4;
    return Add_a(x);
}

在不同的地方有不同的 a 值,宏展开后在运行时会使用不同的 a 值,真是十分动态。

在 Scheme 能不能做到这一点呢?如果只有 syntax-rules 是做不到的,因为它只能是静态作用域的,宏在定义的时候就知道 a 指向哪一个变量了。但是使用 syntax-case 可以做到这一点,通过 datum->syntax 把 宏里面的 a 塞到和外面的 a 相同的上下文环境中就 Ok 啦:

(define-syntax Add_a
  (lambda (x)
    (syntax-case x ()
      [(_ x place)
       (syntax-case (datum->syntax #'place 'a) ()
         [a #'(+ a x)])])))


(let ((a 1))
  (let ((k 2))
    (let ((a 2))
      (Add_a 1 k))))
=> 3

上面确实实现了一定的“动态作用域”,看上去这里借用的是 k 的上下文,按道理来说它应该是看不到它内层的 a 啊。上面我强调了:加入的上下文其实是模板标识符被引入时的环境,在宏展开的时候,模板标识符 k 已经处于最里面的环境了,所以不论以什么名字作为“锚点”都会以 3 作为结果,因为“锚点”名字和外面并没有什么关系。上面的宏其实可以做一些简化,其实根本就不需要 place 来作为“锚点”,使用宏的名字来作为上下文提供者即可:

(define-syntax Add_a2
  (lambda (x)
    (syntax-case x ()
      [(k x)
       #`(+ x #,(datum->syntax  #'k 'a))])))

经过 datum->syntax 变换后,被变换的对象的上下文就改变了,它就不可能再被 Scheme 改名字了,它可以用来把宏变得不卫生,也就是宏里面的名字可以影响到外面。

到了这里,我就已经介绍完了 syntax-case 的基本功能与基本的使用方式,以及如何使用它来修改作用域和实现展开时计算。但是这个介绍并不是很全,某些必要的函数还没有介绍。这就是下一节的工作了。

5.3. 与 syntax-case 相关的函数与宏

首先,与 syntax-case 最相关的就是 syntax-rules 啦,毕竟 syntax-case 是从它上面出来的。syntax-rules 是可以用 syntax-case 定义出来的,TSPL 上面是这样写的:

(define-syntax syntax-rules
   (lambda (x)
     (syntax-case x ()
       [(_ (i ...) ((keyword . pattern) template) ...)
         #'(lambda (x)
             (syntax-case x (i ...)
                [(_ . pattern) #'template] ...))])))

其次是关于标识符判定的一些函数,即 identifier?free-identifier=?bound-identifier=?

identifier? 用来判定对象是不是标识符,如果是的话返回 #t ,否则返回 #f

free-identifier=?bound-identifier=? 用来判断两个标识符是否是相同的标识符,它们的判断依据不同, free 用来判断两个自由引用是否相同,而 bound 用来在给定的上下文中的两个标识符是否相同。具体的例子可以参考 TSPL。

with-syntaxlet 的语法很像,不过它作用的对象是语法对象而不是值。它能够像 syntax-case 一样以模式匹配的方式将模式变量与语法对象进行绑定。TSPL 上面是这样描述它的:

It is sometimes useful to construct a transformer's output in separate pieces, then put the pieces together. with-syntax facilitates this by allowing the creation of local pattern bindings.

它也可以使用 syntax-case 定义出来:

(define-syntax with-syntax
  (lambda (x)
    (syntax-case x ()
      [(_ ((p e) ...) b1 b2 ...)
       #'(syntax-case (list e ...) ()
           [(p ...) (let () b1 b2 ...)])])))

make-variable-transformer 用来定义不带参数的宏,我对这个的兴趣不是很大,但是它在进行名字替换时是十分有力的工具。

generate-temporaries 用来生成不产生冲突的名字,和 elisp 中的 make-symbol 和 gensym 有点像,不过具体的实现原理是否相似我就不知道了。

以上的函数全部来自 TSPL,Racket 上面可能会定义一些更加好用的函数和宏,想要了解的话可以前往官网文档学习。

5.4. Scheme 宏的一些使用用例

这里是我对宏的一些整理,它的一部分来自 TSPL,一部分来自Racket 文档,一部分来自网上的代码,一部分来自我的一点脑洞,宏的难度和复杂程度大概呈递增规律,希望这些例子能有助于你的学习。大部分宏都使用 syntax-case,为了简便少部分也使用 syntax-rules,毕竟它都可以用 syntax-case 定义出来了。

5.4.1. 递归转换器

功能:通过普通的匿名函数得到一个递归函数。

实现:没有宏的话也可以用所谓的 y 组合子,但是使用 letrec 足矣。

(define-syntax rec
 (syntax-rules ()
   [(_ x e) (letrec ([x e]) x)]))

使用例:

(define length-yy (rec f (lambda (x)
                           (if (null? x) 0
                              (+ 1 (f (cdr x)))))))
(length-yy '(1 23))
=> 2

5.4.2. 特殊形式 and 和 or

功能:实现短路逻辑

实现:借助嵌套的 if 来实现功能

(define-syntax and
  (syntax-rules ()
    [(_) #t]
    [(_ e) e]
    [(_ e1 e2 e3 ...)
     (if e1 (and e2 e3 ...) #f)]))

(define-syntax or
  (syntax-rules ()
    [(_) #f]
    [(_ e) e]
    [(_ e1 e2 e3 ...)
     (let ([t e1])
       (if t t (or e2 e3 ...)))]))

使用例:

(and 1 (or #t #f) (or #f #f))
=> #f

5.4.3. 特殊形式 when 和 unless

功能:当条件为真/假时,顺序执行给定的表达式,不需要加 begin

实现:借助 if 和 begin

(define-syntax when
  (lambda (x)
    (syntax-case x ()
      ((_ e0 e1 e2 ...) (syntax (if e0 (begin e1 e2 ...)))))))

(define-syntax unless
  (lambda (x)
    (syntax-case x ()
      ((_ e0 e1 e2 ...) (syntax (when (not e0) e1 e2 ...))))))

使用例:

(when #t (display 1) (display 2) (display 3))
=> 123

5.4.4. loop 循环

功能:实现带有 break 功能的循环

实现:借助 call/cc 进行跳转,需要把关键字 break 插入到表达式所处的环境中

这里的 datum->syntax 就是告诉 Scheme 不要给 break 重命名,好让 loop 表达式里面的 break 看到它

(define-syntax loop
  (lambda (x)
    (syntax-case x ()
      [(k e ...)
       (with-syntax ([break (datum->syntax #'k 'break)])
            #'(call/cc
               (lambda (break)
                 (let f () e ... (f)))))])))

使用例:

(let ([n 3] [ls '()])
  (loop
   (if (= n 0) (break ls))
   (set! ls (cons 'a ls))
   (set! n (- n 1))))
=> (a a a)

5.4.5. letcc

功能:不用写 call/cc 中接受的函数,少些点字

实现:借用 call/cc

(define-syntax letcc
  (lambda (x)
    (syntax-case x ()
      [(k name e ...)
       #'(call/cc (lambda (name) e ...))])))

使用例:

(letcc wocao
       (let ((a 1)
             (b 2))
         (+ 1 (wocao b))))
=> 2

letcc 是 the seasoned schemer 里面的一个宏。

5.4.6. 简单的函数生成器

功能:使用这个宏来定义一个变量时,生成它的 getter/setter 函数

实现:这里 datum->syntaxsyntax->datum 都要用到,因为要根据变量的名字来生成函数名

(define-syntax define-gs
  (lambda (x)
    (syntax-case x ()
      [(k var val)
       (with-syntax
          ((getter (datum->syntax #'k
                    (string->symbol
                     (string-append
                      (symbol->string (syntax->datum #'var))
                      "-get"))))
           (setter (datum->syntax #'k
                    (string->symbol
                     (string-append
                      (symbol->string (syntax->datum #'var))
                      "-set!")))))
         #'(begin
            (define var val)
            (define getter (lambda () var))
            (define setter (lambda (x) (set! var x)))))])))

使用例:

(define-gs a 1)
(a-get)
=> 1
(a-set! 2)
(a-get)
=> 2

上面的定义有很多重复的地方,TSPL 中有一个很有用的函数,它叫 gen-id,可以拿过来一用:

(define-syntax define-gs2
  (lambda (x)
    (define gen-id
      (lambda (template-id . args)
        (datum->syntax
          template-id
          (string->symbol
           (apply string-append
                  (map (lambda (x)
                        (if (string? x)
                            x
                            (symbol->string (syntax->datum x))))
                       args))))))
    (syntax-case x ()
      [(k var val)
       (with-syntax
           ((getter (gen-id #'k #'var "-" "get"))
            (setter (gen-id #'k #'var "-" "set!")))
         #'(begin
             (define var val)
             (define getter (lambda () var))
             (define setter (lambda (x) (set! var x)))))])))

上面的生成器只是一个比较简单的例子,比较复杂的生成器例子可以看 TSPL 的 250 页左右的 define-structureessentials of programming language 上面也有类似的宏,叫做 define-datatype ,很久之前我粗糙的 实现了一下 ,现在感觉有点看不懂了。

5.4.7. generator

功能:创建一个 generator,就像 js 和 python 中的那样。

实现:把 yield 关键字放进去就行了。

(define-syntax gen-gen
  (lambda (x)
    (syntax-case x (lambda)
      [(k (lambda varlist e1 e2 ...))
       (with-syntax
        ((yed (datum->syntax #'k 'yield)))
        #'(lambda varlist
           (define store-return values)
           (define store-k (lambda x (begin e1 e2 ...)))
           (define yed
            (lambda value
             (call/cc (lambda (k)
               (set! store-k k)
               (apply store-return value)))))
           (lambda resume-vals
             (call/cc (lambda (return)
               (set! store-return return)
               (apply store-k resume-vals))))))])))

(define gen-next (lambda (x . val) (apply x val)))

store-return 作为返回锚点,store-k 存储上一次跳出时的 continuaiton 以便下次恢复。

这里的 yield 是作为函数实现的,还可以直接在宏展开过程中全部替换,不过实现起来有点 trick:因为是在输出表达式中定义宏 yed ,所以省略号要做点特殊处理:

(define-syntax gen-gen2
  (lambda (x)
    (syntax-case x (lambda)
      [(k (lambda varlist e1 e2 ...))
       (with-syntax
    ((yed (datum->syntax #'k 'yield)))
    #`(lambda varlist
        (define store-return values)
        (define store-k (lambda x (begin e1 e2 ...)))
        (define-syntax yed
          (lambda (x)
            (syntax-case x ()
              [(_ v1 v2 (... ...))
               #'(call/cc
                  (lambda (k)
                   (set! store-k k)
                   (apply store-return v1 v2 (... ...) '())))])))
        (lambda resume-vals
          (call/cc (lambda (return)
             (set! store-return return)
             (apply store-k resume-vals))))))])))

使用例:

(define wo-gen (gen-gen
                (lambda (x)
                 (yield x)
                 (yield (+ x 1))
                 (yield (+ x 2)))))
(define wo (wo-gen 1))
(gen-next wo)
=> 1
(gen-next wo)
=> 2
(gen-next wo)
=> 3
(gen-next wo)
=> <nothing>


(define fib-gen
  (gen-gen2 (lambda (x y)
              (let f ([a x] [b y])
                 (yield a)
                 (f b (+ a b))))))
(define fib-s (fib-gen 0 1))
(gen-next fib-s) ;call n times
=> 0
=> 1
=> 1
=> 2
=> 3
=> 5
.....

上面定义的 generator 只是个玩具,在没有 yield 之后调用 generator 是没有返回值的,而且也没有相应的提醒。

5.4.8. setf

setf 是 elisp(common lisp 中应该也有吧)的一个宏,它的功能很强大也很奇怪,它把第一个参数看做“左值”,然后对其中的对象进行赋值操作。举例来说的话:

(setf a 1)
a => 1

(setf b (list 1 2))
(setf (car b) 2) => b = (2 2)
(setf (cadr b) 3) => b = (2 3)
(setf (cdr b) nil) => b = (2)

(setf c (vector 1 2 3))
(setf (aref c 0) 2) => c = [2 2 3]

简而言之,setf 可以根据表达式推断出要进行赋值操作的元素,并对其进行赋值。我们也可以使用 syntax-case 来仿制一个,不过功能上需要做一些缩减。下面的 setf! 支持变量,表和向量的赋值:

(define-syntax setf!
  (lambda (xx)
    (syntax-case xx ()
      [(_ form value)
       (syntax-case #'form (car cdr cadr vector-ref)
         [(car x) (identifier? #'x) #'(set-car! x value)]
         [(cdr x) (identifier? #'x) #'(set-cdr! x value)]
         [(cadr x) (identifier? #'x) #'(set-car! (cdr x) value)]
         [(vector-ref x n) (identifier? #'x) #'(vector-set! x n value)])])))

用法和上面 elisp 是一样的,只不过 aref 变成了 vector-ref。

5.4.9. 延时let

在 elisp 和 Scheme 中都有延时求值的宏,Scheme 中是 delay/force, elisp 中则是 thunk-delay 和 thunk-force,但是 elisp 中还有一个宏,可以像 let 一样用延时求值:

(let ((a (thunk-delay (progn
            (print "1")
            1)))
      (b (thunk-delay (progn
            (print "2")
            2)))
      (c (thunk-delay (progn
            (print "3")
            3))))
  (+ (thunk-force a) (+ (thunk-force b) (+ (thunk-force c) 1))))


(thunk-let ((a (progn (print "1") 1))
            (b (progn (print "2") 2))
            (c (progn (print "3") 3)))
  (+ a b c))

这两段代码的功能是相同的,使用 thunk-let 可以省略掉重复的 delay 和 force。

下面我们用 syntax-case 来实现相同的功能:

(define-syntax delay-let
  (lambda (y)
    (define gen-id
      (lambda (template-id . args)
        (datum->syntax
          template-id
          (string->symbol
           (apply string-append
                  (map (lambda (x)
                         (if (string? x)
                             x
                             (symbol->string (syntax->datum x))))
                       args))))))
    (syntax-case y ()
      [(k ((x1 v1) ...) e1 e2 ...)
       (with-syntax
         (((a1 ...) (map (lambda (x)
                           (gen-id x "thunk" x)) #'(x1 ...))))
         #'(let ((a1 (delay v1)) ...)
              (define-syntax x1
                (make-variable-transformer
                   (lambda (x)
                     (syntax-case x ()
                        [id (identifier? #'id) #'(force a1)]))))
              ...
              e1 e2 ...))])))

写宏还真是够蛋疼的呢,上面的代码写了我 1 小时,不过也算是完成了 填坑

相似地我们还可以定义 delay-let*,它和 let* 很像:

(define-syntax delay-let*
  (lambda (x)
    (syntax-case x ()
      [(_ ((x1 v1) ...) e1 e2 ...)
       (let f ((ls #'((x1 v1) ...)))
         (cond
           ((null? (cdr ls)) #`(delay-let #,ls e1 e2 ...))
           (else
             #`(delay-let #,(list (car ls))
             #,(f (cdr ls))))))])))

delay-let* 定义变量时里层可以使用外层的变量,以下代码可以看出这个特性:

(delay-let* ((x (begin (display "a") 1))
             (y (begin (display "b") (+ x 1)))
             (z (begin (display "c") (+ y 2))))
    (+ x y z))
=> cba7

5.4.10. 其他

Kent,Fridman 写了一个非常厉害的模式匹配宏,链接在这里,不过我看不懂。

更多示例可以去看 TSPL 和 Racket。

以上。

6. 后记

本文最开始取的题目是“elisp 的 lexical-binding”,但是写着写着突然发现我对作用域这个概念的理解似乎出了点问题,似乎是有必要把作用域相关的概念全都过一遍。看完了作用域后我终于明白了作用域和动态/静态作用域好像根本不是一个东西。这时候我又想起了很久之前学过的 syntax-case,我对里面的 datum->syntax 总是无法理解透彻。但是学完作用域后我终于明白了它到底是干什么的了。那就干脆把 syntax-case 也整理一遍吧,蛤。

要说的话拆成三小篇文章也是可以的,但是放一起也没有问题,思路估计看上去会更连贯一点。

  • 本文的第一节厘清了上下文和环境的关系,顺带介绍了名字绑定这个基础概念
  • 本文的第二节介绍了名字绑定的作用域和生存期,分析了“动态作用域”和“静态作用域”和作用域和生存期的关系
  • 本文的第三节介绍了 elisp 中的 lexical-binding,对不同 binding 下求值的情况进行了简单讨论
  • 本文的第四节简要介绍了 Scheme 的 syntax-case,并给出了一些示例

如果您对我上面的 syntax-case 教程有些疑问的话欢迎与我讨论,这样有助于我继续优化。这一部分的编写过程主要是按照我的思维变化过程写出来的,有不懂的地方尽管找我。

syntax-case 不就是带了上下文信息的列表变换吗,这有什么难理解的 :p

7. 参考资料