在阅读本篇文章之前,建议读者优先阅读专栏内前面的文章。
目录
前言
本篇文章紧承上文,接着介绍龙书引论部分最后的内容,即程序设计语言基础。
一、静态和动态的区别
在为一个语言设计一个编译器时,我们所面对的最重要的问题之一是编译器能够对一个程序做出哪些判定。如果一个语言使用的策略支持编译器静态决定某个问题,那么我们说这个语言使用了一个静态策略,或者说这个问题可以在编译时刻决定。另一方面,一个只允许在运行程序的时候做出决定的策略被称为动态策略,或者被认为需要在运行时刻做出决定。
我们需要注意的另一个问题是声明的作用域。如x的一个声明的作用域是指程序的一个区域,在其中对x的使用都指向这个声明。如果仅通过阅读程序就可以确定一个声明的作用域,那么这个语言使用的是静态作用域,或者说词法作用域。否则,这个语言使用的是动态作用域。如果使用动态作用域,当程序运行时,同一个对x的使用会指向x的几个声明中的某一个。大部分语言(比如C和Java)使用静态作用域。

我们可以看一下龙书的例1.3。作为静态/动态区别的另一个例子,我们考虑一下Java类声明中术语static的使用。这个术语作用于数据。在 Java 中,一个变量是用于存放数据值的某个内存位置的名字。这里,static指的并不是变量的作用域,而是编译器确定用于存放被声明变量的内存位置的能力。比如如下声明:
java
public static int x;
使得x成为一个类变量,也就是说不管创建了多少个这个类的对象,只存在一个x的拷贝。此外,编译器可以确定内存中的被用于存放整数x的位置。反过来,如果这个声明中省略了static,那么这个类的每个对象都会有它自己的用于存放x的位置,编译器没有办法在运行程序之前预先确定所有这些位置。
二、环境与状态
我们在讨论程序设计语言时必须了解的另一个重要区别是在程序运行时发生的改变是否会影响数据元素的值,还是影响了对那个数据的名字的解释。比如,执行像x = y + 1这样的赋值语句会改变名字x所指的值。更加明确地说,这个赋值改变了x所指向的内存位置上的值。
可能下面这一点就不是那么明显了。即x所指的位置也可能在运行时刻改变。比如,我们在上面的例子中讨论过,如果x不是一个静态(或者说类)变量,那么这个类的每一个对象都有它自己的分配给变量x的实例的位置。这种情况下,对x的赋值可能会改变那些实例变量中的某一个变量的值,这取决于包含这个赋值的方法作用于哪个对象。
名字和内存(存储)位置的关联,及之后和值的关联可以用两个映射来描述。这两个映射随着程序的运行而改变。环境是一个从名字到存储位置的映射。因为变量就是指向内存位置(即C语言中的术语左值),我们还可以换一种方法,把环境定义为从名字到变量的映射。状态是一个从内存位置到它们的值的映射。以C语言的术语来说,即状态把左值映射为它们的相应右值。环境的改变需要遵守语言的作用域规则。

我们可以考虑如下的C程序片断:
cpp
...
int i; /* 全局 i */
...
void f(...) {
int i; /* 局部 i */
...
i = 3; /* 对局部 i 的使用 */
...
}
...
x = i + 1; /* 对全局 i 的使用 */
整数i被声明为一个全局变量,同时也被声明为局部于函数f的变量。执行f时,环境相应地调整,使得名字i指向那个局部于f的那个i所保留的存储位置,且i的所有使用(如图中明确显示的赋值语句i = 3)都指向这个位置。局部的i通常被赋予一个运行时刻栈中的位置。
只要当一个不同于f的函数g运行时,i的使用就不能指向那个局部于f的i。在函数g中对名字i的使用必须位于其他某个对i的声明的作用域内。一个例子是图中明确显示的赋值语句x = i + 1,它位于某个其定义没有在图中显示的过程中。可以假定i + 1中的i指向全局的i。和大多数语言一样,C语言中的声明必须先于其使用,因此在全局i的声明之前的函数不能指向它。
我们上面说的环境和状态映射是动态的,但是也有一些例外。首先,名字到位置的静态绑定与动态绑定。大部分从名字到位置的绑定是动态的。我们在这一节中讨论了这种绑定的几种方法。某些声明(比如上面代码中的全局变量i)可以在编译器生成目标代码时一劳永逸地分配一个存储位置。其次,从位置到值的静态绑定与动态绑定。一般来说,位置到值的绑定也是动态的,因为我们无法在运行一个程序之前指出一个位置上的值。被声明的常量是一个例外。比如下面的C语言定义:
cpp
#define ARRAYSIZE 1000
把名字ARRAYSIZE静态地绑定为值1000。我们看到这个语句就可以知道这个绑定关系,并且知道在程序运行时刻这个绑定不可能改变。
三、静态作用域和块结构
包括C语言和它的同类语言在内的大多数语言使用静态作用域。C语言的作用域规则是基于程序结构的,一个声明的作用域由该声明在程序中出现的位置隐含地决定。稍后出现的语言,比如C++、Java和C#,也通过诸如public、private和protected等关键字的使用,提供了对作用域的明确控制。我们将在本部分考虑块结构语言的静态作用域规则,其中块是声明和语句的一个组合。C使用括号 { 和 } 来界定一个块。另一种为同一目的使用begin和end的方法可以追溯到Algol。
虽然术语名字和变量通常指的是同一个事物,我们还是要很小心地使用它们,以便区别编译时刻的名字和名字在运行时刻所指的内存位置。标识符是一个字符串,通常由字母和数字组成。它用来指向(标记)一个实体,比如一个数据对象、过程、类、或者类型。所有的标识符都是名字,但并不是所有的名字都是标识符。名字也可以是一个表达式。比如名字x.y可以表示x所指的一个结构中的字段y。这里,x和y是标识符,而x.y是一个名字。像x.y这样的复合名字称为受限名字。
变量指向存储中的某个特定的位置。同一个标识符被多次声明是很常见的事情,每一个这样的声明引入一个新的变量。即使每个标识符只被声明一次,一个递归过程中的局部标识符将在不同的时刻指向不同的存储位置。
C 语言的静态作用域策略可以概述如下:一个 C 程序由一个顶层的变量和函数声明的序列组成;函数内部可以声明变量,变量包括局部变量和参数。每个这样的声明的作用域被限制在它们所出现的那个函数内;名字 x 的一个顶层声明的作用域包括其后的所有程序。但是如果一个函数中也有一个 x 的声明,那么函数中的那些语句就不在这个顶层声明的作用域内。还有一些关于 C 语言的静态作用域策略的细节用来处理语句中的变量声明。我们将在接下来的内容中查看这样的声明。

为了避免总是说过程、函数或方法,每次我们要讨论一个可以被调用的子程序时,我们通常把它们统称为过程。但是当明确地讨论某个语言(比如C)的程序时有一个例外。因为C语言只有函数,所以我们把它们称为函数。或者,如果我们讨论像Java这样的只有方法的语言时,我们就使用这个术语。
一个函数通常返回某个类型(即返回类型)的值,而一个过程不返回任何值。C和类似的语言只有函数,因此它们把过程当作是具有特殊返回类型void的函数来处理。void表示没有返回值。像Java和C++这样的面向对象语言使用术语方法。这些方法可以像函数或者过程一样运行,但是总是和某个特定的类相关联。
在C语言中,有关块的语法如下:块是一种语句,块可以出现在其他类型的语句(比如赋值语句)所能够出现的任何地方;一个块包含了一个声明的序列,然后再跟着一个语句序列,这些声明和语句用一对括号包围起来。注意,这个语法允许一个块嵌套在另一个块内。这个嵌套特性称为块结构。C族语言都具有块结构,但是不能在一个函数内部定义另一个函数。
如果块B是包含声明D的最内层的块,那么我们说D属于B。也就是说,D在B中,且不在嵌套于B中的任何其他块中。在一个块结构语言中,关于变量声明的静态作用域规则如下。如果名字x的声明D属于块B,那么D的作用域包括整个B,但是以任何深度嵌套在B中、重新声明了x的所有块B′不在此作用域中。这里,x在B′中重新声明是指存在另一个属于B′的对相同名字x的声明D′。
另一个等价的表达这个规则的方法着眼于名字 x 的一次使用。设B1、B2、...、Bk是所有的包含了x的该次使用的块。其中,Bk嵌套在Bk-1中,Bk-1嵌套在Bk-2中,......依此类推。寻找最大的满足下列条件的i:存在一个属于Bi的对x的声明。这样的声明就是指向Bi中对x的声明。换句话说,x的本次使用在Bi中的这个声明的作用域内。

上面说的很绕很复杂,我们这里可以看一下龙书的例1.6,它有下面这样的一个图:

考虑块B1中的声明int a = 1。它的作用域包括整个B1,当然那些嵌套在B1中并且有它自己的对a的声明的块除外。直接嵌套在B1中的B2没有a的声明,而B3就有。B4没有a的声明。因此块B3是整个程序中唯一一位于名字a在B1中的声明的作用域之外的地方。也就是说,这个作用域包括B4和B2中除了B3之外的所有部分。关于程序中的全部五个声明的作用域的总结见下图:

从另一个角度看,考虑嵌套块B4中的输出语句,并把那里使用的变量a和b和适当的声明绑定。包含该语句的块的列表从小到大是B4、B2、B1。请注意,B3没有包含问题中所提到的点。B4有一个b的声明,因此该语句中对b的使用被绑定到这个声明,因此打印出来的b的值是4。然而,B4没有a的声明,因此我们接着看B2。这个块也没有a的声明,因此我们继续看B1。幸运的是,这个块有一个声明int a = 1。因此,打印出来的a的值是1。如果没有这个声明,程序就是错误的。
四、显式访问控制
类和结构为它们的成员引入了新的作用域。如果p是一个具有字段(成员)x的类的对象,那么在p.x中对x的使用指的是这个类定义中的字段x。和块结构类似,类C中的一个成员声明x的作用域可以扩展到所有的子类C′,除非C′有一个本地的对同一名字x的声明。
通过public、private和protected这样的关键字的使用,像C++或Java这样的面向对象语言提供了对超类中的成员名字的显式访问控制。这些关键字通过限制访问来支持封装。因此,私有名字被有意地限定了作用域,这个作用域仅仅包含了该类和友类(C++的术语)相关的方法声明和定义。被保护的名字可以由子类访问,而公共的名字可以从类外访问。
在C++中,一个类的定义可能和它的部分或全部方法的定义分离。因此对于一个和类C相关联的名字x,可能存在一个在它作用域之外的代码区域,然后又跟着一个在它作用域内的代码区域(一个方法定义)。实际上,在这个作用域之内和之外的代码区域可能相互交替,直到所有的方法都被定义完毕。程序设计语言概念中的两个看起来相似的术语声明和定义实际上有着很大的不同。声明告诉我们事物的类型,而定义告诉我们它们的值。因此,int i是一个i的声明,而i = 1是i的一个定义。
当我们处理方法或者其他过程时,这个区别就更加明显。在C++中,通过给出了方法的参数及结果的类型(通常称为该方法的范型),在类的定义中声明这个方法。然后,这个方法在另一个地方被定义,即在另一个地方给出了执行这个方法的代码。类似地,我们会经常看到在一个文件中定义了一个C语言的函数,然后在其他使用这个函数的文件中声明这个函数。

五、动态作用域
从技术上讲,如果一个作用域策略依赖于一个或多个只有在程序执行时刻才能知道的因素,它就是动态的。然而,术语动态作用域通常指的是下面的策略:对一个名字x的使用指向的是最近被调用但还没有终止且声明了x的过程中的这个声明。这种类型的动态作用域仅仅在一些特殊情况下才会出现。我们将考虑两个动态作用域的例子:C预处理器中的宏扩展,以及面向对象编程中的方法解析。
我们首先先看一下下面的C程序代码段:
cpp
#define a (x+1)
int x = 2;
void b() {
int x = 1;
printf("%d\n", a);
}
void c() {
printf("%d\n", a);
}
void main() {
b();
c();
}
标识符a是一个代表了表达式 (x + 1) 的宏。但x到底是什么呢?我们不能够静态地(也就是说通过程序文本)解析x。实际上,为了理解x,我们必须使用前面提到的普通的动态作用域规则。我们检查所有当前活跃的函数调用,然后选择最近调用的且具有一个对x的声明的函数。对x的使用就是指向这个声明。
在上面的例子中,函数main首先调用函数b。当b执行时打印宏a的值。因为首先必须用 (x + 1) 替换掉a,所以我们把本次对x的使用解析为对函数b中的声明int x = 1。原因是b有一个x的声明,因此b中的printf中的 (x + 1) 指向这个x。因此,打印出的值是2。在b运行结束之后,函数c被调用,我们依旧需要打印a的值。然而,唯一可以被c访问的x是全局变量x。函数c中的printf语句指向x的这个声明,且被打印的值是3。
动态作用域解析对多态过程是必不可少的。所谓多态过程是指对于同一个名字根据参数类型具有两个或多个定义的过程。在有些语言中,比如ML,人们可以静态地确定名字所有使用的类型。在这种情况下,编译器可以把每个名字为p的过程替换为对相应的过程代码的引用。但是,在其他语言中,比如在Java和C++中,编译器有时不能够做出这样的决定。
虽然可以有各种各样的静态或者动态作用域策略,在通常的(块结构的)静态作用域规则和通常的动态策略之间有一个有趣的关系。从某种意义上说,动态规则处理时间的方式类似于静态作用域处理空间的方式。静态规则让我们寻找的声明位于最内层的、包含变量使用位置的单元(块)中;而动态规则让我们寻找的声明位于最内层的、包含了变量使用时间的单元(过程调用)中。

我们接下来再看一下龙书的例1.8。面向对象语言的一个突出特征就是每个对象能够对一个消息做出适当反应,调用相应的方法。换句话说,执行x.m() 时调用哪个过程要由当时x所指向的对象的类来决定。一个典型的例子如下:有一个类C,它有一个名字为m() 的方法。D是C的一个子类,而D有一个它自己的名字为m() 的方法。有一个形如x.m() 的对x的使用,其中x是类C的一个对象。
正常情况下,在编译时刻不可能指出x指向的是类C的对象还是其子类D的对象。如果这个方法被多次应用,那么很可能某些调用作用在由x指向的类C的对象,而不是类D的对象,而其他调用作用于类D的对象之上。只得到了运行时刻才可能决定应当调用m的哪个定义。因此,编译器生成的代码必须决定对象x的类,并调用其中的某个名字为m的方法。
六、参数传递机制
所有的程序设计语言都有关于过程的概念,但是在这些过程如何获取它们的参数方面,不同的语言之间有所不同。在本节中,我们将考虑实在参数(在调用过程时使用的参数)是如何与形式参数(在过程定义中使用的参数)关联起来的。使用哪一种传递机制决定了调用代码序列如何处理参数。大多数语言要么使用值调用,要么使用引用调用,或者两者都用。我们将解释这些术语以及另一个被称为名调用的方法,解释后者主要是基于对历史的兴趣。
在值调用中,会对实在参数求值(如果它是表达式)或拷贝(如果它是变量)。这些值被放在属于被调用过程的相应形式参数的内存位置上。这种方法在C和Java中使用,也是C++语言及大部分其他语言的一个常用选项。值调用的效果是,被调用过程所做的所有有关形式参数的计算都局限于这个过程,相应的实在参数本身不会被改变。
然而请注意,在C语言中我们可以传递变量的一个指针,使得该变量的值能够被被调用者修改。同样,C、C++和Java中作为参数传递的数组名字实际上向被调用过程传递了一个指向该数组本身的指针或引用。因此,如果a是调用过程的一个数组的名字,且它被以值调用的方式传递给相应的形式参数x,那么像x2 = i这样的赋值语句实际上改变了数组元素ai。原因是虽然x是a的值的一个拷贝,但这个值实际上是一个指针,指向被分配给数组a的存储区域的开始处。
类似地,Java中的很多变量实际上是对它们所代表的事物的引用,或者说指针。这个结论对数组、字符串和所有类的对象都有效。虽然Java只使用值调用,但只要我们把一个对象的名字传递给一个被调用过程,那么过程收到的值实际上是这个对象的指针。因此,被调用过程是可以改变这个对象本身的值的。

在引用调用中,实在参数的地址作为相应的形式参数的值被传递给被调用者。在被调用者的代码中使用形式参数时,实现方法是沿着这个指针找到调用者指明的内存位置。因此,改变形式参数看起来就像是改变了实在参数一样。但是,如果实在参数是一个表达式,那么在调用之前首先会对表达式求值,然后它的值被存放在一个该值自己的位置上。改变形式参数会改变这个位置上的值,但对调用者的数据没有影响。
C++中的ref参数使用的是引用调用。而在很多其他语言中,引用调用也是一种选项。当形式参数是一个大型的对象、数组或结构时,引用调用几乎是必不可少的。原因是严格的值调用要求调用者把整个实在参数拷贝到属于相应形式参数的空间上。当参数很大时,这种拷贝可能代价高昂。正如我们在讨论值调用时所指出的,像Java这样的语言解决数组、字符串和其他对象的参数传递问题的方法是仅仅复制这些对象的引用。结果是,Java运行时就好像它对所有不是基本类型(比如整数、实数等)的参数都使用了引用调用。

第三种机制------名调用------被早期的程序设计语言Algol 60使用。它要求被调用者的运行方式好像是用实在参数以字面方式替换了被调用者的代码中的形式参数一样。这么做就好像形式参数是一个代表了实在参数的宏。当然被调用过程的局部名字需要进行重命名,以便把它们和调用者中的名字区别开来。当实在参数是一个表达式而不是一个变量时,会发生一些和直觉不符的问题。这也是今天不再采用这种机制的原因之一。

七、别名
引用调用或者其他类似的方法,比如像Java中那样把对象的引用当作值传递,会引起一个有趣的结果。有可能两个形式参数指向同一个位置,这样的变量称为另一个变量的别名。结果是,任意两个看起来从两个不同的形式参数中获得值的变量也可能变成对方的别名。
看一下龙书的例1.9,假设a是一个属于某个过程p的数组,且p通过调用语句q(a, a) 调用了另一个过程q(x, y)。再假设像C语言或类似的语言那样,参数是通过值传递的,但数组名实际上是指向数组存放位置的引用。现在,x和y变成了对方的别名。要点在于,如果q中有一个赋值语句x10 = 2,那么y10的值也是2。
事实上,如果编译器要优化一个程序,就要理解别名现象以及产生这一现象的机制。在很多情况下,我们必须在确认某些变量相互之间不是别名之后才可以优化程序。比如,我们可能确定x = 2 是变量x唯一被赋值的地方。如果是这样,那么把x的使用替换为对2的使用。比如,把a = x + 3替换为简单的a = 5。但是,假设有另一个变量y是x的别名。那么,一个赋值语句y = 4可能具有意想不到的改变x的值的效果。这可能也意味着把a = x + 3替换为a = 5是一个错误,此时,a的正确值可能是7。
总结
本文主要讨论了程序设计语言在编译器设计中的关键概念,包括静态与动态策略的区别、声明作用域、环境与状态映射、参数传递机制等核心问题。文章首先区分了静态策略(编译时决定)和动态策略(运行时决定),并以作用域为例说明静态作用域(如C、Java)和动态作用域的实现差异。随后探讨了名字绑定的两个动态映射------环境(名字到位置)和状态(位置到值),并指出常量绑定的静态特性。在静态作用域规则部分,详细分析了C语言的块结构特性,说明嵌套声明如何通过"最内层匹配"原则确定标识符引用。面向对象语言的访问控制(public/private)也被纳入作用域讨论范畴。动态作用域则通过C宏扩展和面向对象方法解析等案例,展示运行时名字解析机制。最后,文章对比了值调用、引用调用和名调用三种参数传递方式,特别指出引用调用可能引发的别名现象及其对程序优化的影响。整体而言,本文系统梳理了程序设计语言的基础语义规则,为理解编译器如何分析和处理这些语言特性提供了理论框架。