在阅读本文之前,建议读者优先阅读专栏内前面的文章。
目录
练习1.1.2:编译器相对解释器的优点是什么?解释器相对于编译器的优点是什么?
练习1.1.3:在一个语言处理系统中,编译器产生汇编语言而不是机器语言的好处是什么?
练习1.1.4:把一种高级语言翻译成另一种高级语言的编译器叫源到源的翻译器。编译器使用C语言作为目标语言有什么好处?
练习1.6.1:针对下面的块结构的代码,指出赋给w、x、y和z的值。
练习1.6.3:对于下面的块结构代码,假设使用常见的声明的静态作用域规则,给出其中12个声明每一个的作用域。
前言
本文主要介绍一下对于龙书第一章引论部分出现的练习题的思考与讲解。
练习1.1.1:编译器和解释器之间的区别是什么?
编译器和解释器都是语言处理器,它们的作用都是让计算机能够执行高级语言程序,但是二者的工作方式不同。编译器会先把源程序整体翻译成另一种形式的目标程序,比如汇编语言、机器语言或者字节码。翻译完成后,目标程序可以独立运行,执行时通常不再需要重新分析源代码。解释器则不会先生成完整的目标程序,而是直接读取源程序或中间表示,一边分析一边执行。也就是说,解释器更像是"边翻译边运行"。
简单来说,编译器强调"先翻译,再执行";解释器强调"边分析,边执行"。例如,C语言程序通常需要先经过编译器生成可执行文件,然后再运行;而很多Python程序在使用时更接近解释执行,解释器会在运行过程中逐步处理程序语句。

练习1.1.2:编译器相对解释器的优点是什么?解释器相对于编译器的优点是什么?
编译器相对于解释器的主要优点是目标程序的执行效率通常更高。因为编译器在程序运行之前已经完成了词法分析、语法分析、语义检查和代码优化等工作,所以生成的目标程序可以直接在机器上运行。尤其是在循环、数值计算、大型系统程序中,编译后的代码往往具有更好的运行性能。此外,编译器可以在编译阶段进行比较深入的优化,例如常量折叠、公共子表达式消除、寄存器分配、循环优化等,这些优化可以显著提升程序执行效率。
解释器相对于编译器的优点是灵活性更强,调试和交互体验更好。由于解释器可以逐句执行程序,因此它更适合交互式环境和快速开发场景。程序员可以写一部分代码就立即运行,快速观察结果,这对于调试、教学、脚本编写和实验性开发非常方便。
因此,二者的优势可以概括为:
| 对比项 | 编译器 | 解释器 |
|---|---|---|
| 执行效率 | 通常更高 | 通常较低 |
| 启动运行 | 需要先编译 | 可以直接执行 |
| 调试体验 | 修改后通常要重新编译 | 更适合交互式调试 |
| 优化能力 | 优化空间更大 | 运行时灵活性更强 |
所以,编译器更适合对性能要求较高的场景,而解释器更适合快速开发、动态执行和交互式环境。

练习1.1.3:在一个语言处理系统中,编译器产生汇编语言而不是机器语言的好处是什么?
编译器产生汇编语言而不是直接产生机器语言,有一个重要好处,可以降低编译器后端的复杂度。机器语言是二进制指令,直接面向硬件,格式复杂且不易阅读。如果编译器直接生成机器码,就必须处理大量与具体机器指令编码相关的细节,例如指令格式、地址字段、操作码、重定位信息等。
而汇编语言虽然仍然接近机器语言,但它使用助记符和符号地址来表示机器指令。例如用MOV、ADD、JMP等形式表示底层操作,比直接生成二进制机器码更直观,也更容易调试。
这样做还有几个好处:提高可读性,汇编代码比机器码更容易被程序员和编译器开发者检查;便于调试编译器,如果生成结果有问题,查看汇编代码比查看二进制机器码更容易定位错误;利用已有汇编器,编译器只需要生成汇编代码,之后由汇编器负责把汇编语言转换成机器语言。这样可以复用成熟的汇编器工具,减少编译器自身的实现难度;增强可移植性和工程灵活性,编译器后端可以把"生成汇编代码"和"生成机器码"这两个任务分开,使整个语言处理系统更加模块化。
因此,生成汇编语言相当于在高级语言和机器语言之间增加了一个更容易处理的中间层。

练习1.1.4:把一种高级语言翻译成另一种高级语言的编译器叫源到源的翻译器。编译器使用C语言作为目标语言有什么好处?
源到源翻译器是指把一种高级语言翻译成另一种高级语言的编译器。如果一个编译器选择C语言作为目标语言,那么它可以获得一个非常重要的优势,借助C语言良好的可移植性和成熟的编译工具链。C语言长期以来被广泛支持,几乎所有主流操作系统和硬件平台都有成熟的C编译器。因此,如果某种语言被翻译成C代码,那么只要目标平台上有C编译器,就可以进一步把它编译成该平台上的可执行程序。
这种方式的好处主要有:可移植性好,不需要为每一种机器都重新设计后端,只要生成标准C代码,就可以利用不同平台上的C编译器完成最终编译;降低编译器实现难度,编译器开发者不必直接生成复杂的机器代码,可以把C语言当作一种中间目标语言;可以利用C编译器的优化能力,现代C编译器通常具有强大的优化能力,例如GCC、Clang等。源语言翻译成C后,可以继续享受这些成熟编译器提供的优化;便于和已有系统交互,C语言在系统编程中应用广泛,使用C作为目标语言可以更方便地调用系统接口、链接已有库和融入现有工程生态。
因此,把C语言作为目标语言,本质上是把C编译器当作后端基础设施来使用,从而让新的语言更容易实现跨平台运行。

练习1.1.5:描述一下汇编器要完成的一些任务。
汇编器的主要任务是把汇编语言程序翻译成机器语言程序。虽然汇编语言已经非常接近机器语言,但它仍然包含助记符、符号地址、标签等内容,不能被机器直接执行,因此需要汇编器进一步处理。汇编器要完成的任务主要包括以下几个方面:
翻译指令助记符:
汇编语言使用助记符表示机器指令,例如:
bash
MOV R1, R2
ADD R1, R3
汇编器需要把这些助记符转换成对应的机器指令操作码。
处理符号和标签:
汇编程序中经常使用标签表示某个地址位置,例如:
bash
LOOP:
ADD R1, R2
JMP LOOP
这里的LOOP不是机器能够直接识别的地址。汇编器需要建立符号表,记录标签和地址之间的对应关系,并在后续指令中把符号替换成实际地址。
分配存储地址:
汇编器需要确定每条指令和每个数据在内存中的位置。也就是说,它要计算指令和数据的地址,为后面的跳转、访问变量等操作提供准确的地址信息。
处理伪指令:
汇编语言中可能包含一些并不直接对应机器指令的伪指令,例如定义数据、分配空间、指定程序起始地址等。汇编器需要识别这些伪指令,并按照它们的含义生成相应的数据或控制汇编过程。
生成目标文件:
汇编器最终会生成目标文件。目标文件通常包含机器代码、数据、符号表和重定位信息等内容。这个目标文件可能还不能直接运行,需要进一步交给链接器,把多个目标文件和库文件组合成最终的可执行程序。
因此,汇编器不仅仅是简单地把汇编代码翻译成机器码,它还要完成符号处理、地址计算、伪指令处理和目标文件生成等工作。

练习1.3.1:指出下面术语可以被用来描述下面的哪些语言?
强制式语言强调怎样做。程序由一系列命令组成,通过赋值、循环、条件分支等语句一步步改变程序状态。C、Fortran、Java等都具有明显的强制式风格。
声明式语言强调要什么。程序员更关注问题本身的描述,而不是详细规定每一步如何执行。函数式语言通常可以归入声明式语言的范围。
冯·诺伊曼式语言通常指建立在传统冯·诺伊曼计算模型上的语言,程序和数据存放在存储器中,程序通过顺序执行指令、修改变量状态来完成计算。大多数命令式语言都属于这一类。
面向对象语言以对象为核心组织程序,强调封装、继承、多态等思想。C++、Java、Python、VB等都具有面向对象特征。
函数式语言把计算看作函数求值,强调函数、递归、不可变数据和表达式求值。Lisp和ML是典型代表。
第三代语言通常指比汇编语言更高级、接近自然语言和数学表达的通用程序设计语言,如C、Fortran、Cobol、Java 等。
第四代语言通常更接近具体应用领域,抽象程度更高,往往用于数据库、报表、脚本化处理或快速应用开发。严格来说,本题给出的语言里,第四代特征最明显的不多,VB、Python、Perl可在某些场景下体现第四代语言的特点,但不算最典型的4GL。
脚本语言通常用于自动化、快速开发、文本处理、系统管理或把已有工具连接起来。Perl和Python是典型脚本语言。
| 语言 | 可用于描述它的术语 |
|---|---|
| C | 强制式的、冯·诺伊曼式的、第三代 |
| C++ | 强制式的、冯·诺伊曼式的、面向对象的、第三代 |
| Cobol | 强制式的、冯·诺伊曼式的、第三代 |
| Fortran | 强制式的、冯·诺伊曼式的、第三代 |
| Java | 强制式的、冯·诺伊曼式的、面向对象的、第三代 |
| Lisp | 声明式的、函数式的、第三代 |
| ML | 声明式的、函数式的、第三代 |
| Perl | 强制式的、冯·诺伊曼式的、脚本语言 |
| Python | 强制式的、冯·诺伊曼式的、面向对象的、脚本语言 |
| VB | 强制式的、冯·诺伊曼式的、面向对象的、第三代 |
| 术语 | 可以描述的语言 |
|---|---|
| 强制式的 | C、C++、Cobol、Fortran、Java、Perl、Python、VB |
| 声明式的 | Lisp、ML |
| 冯·诺伊曼式的 | C、C++、Cobol、Fortran、Java、Perl、Python、VB |
| 面向对象的 | C++、Java、Python、VB |
| 函数式的 | Lisp、ML |
| 第三代 | C、C++、Cobol、Fortran、Java、Lisp、ML、VB |
| 第四代 | 本题中没有非常典型的第四代语言;若宽泛理解,可把VB、Python、Perl的部分应用场景看作具有第四代语言特征 |
| 脚本语言 | Perl、Python |
练习1.6.1:针对下面的块结构的代码,指出赋给w、x、y和z的值。
cpp
int w, x, y, z;
int i = 4; int j = 5;
{
int j = 7;
i = 6;
w = i + j;
}
x = i + j;
{
int i = 8;
y = i + j;
}
z = i + j;
一开始:
cpp
int i = 4;
int j = 5;
所以外层变量进入第一个块:
cpp
{
int j = 7;
i = 6;
w = i + j;
}
这里重新声明了一个局部变量j = 7,它遮蔽了外层的j = 5。但是这里没有重新声明i,所以:
cpp
i = 6;
修改的是外层的i。因此w = i + j = 6 + 7 = 13,退出第一个块后,局部变量j = 7消失,外层变量变成i = 6, j = 5。所以x = i + j = 6 + 5 = 11。
进入第二个块:
cpp
{
int i = 8;
y = i + j;
}
这里重新声明了局部变量i = 8,遮蔽外层的i = 6。但是没有重新声明j,所以使用外层的j = 5。因此y = i + j = 8 + 5 = 13。退出第二个块后,局部变量 i = 8 消失,外层仍然是i = 6, j = 5,所以z = i + j = 6 + 5 = 11。
也就是说最终结果是:
cpp
w = 13;
x = 11;
y = 13;
z = 11;
练习1.6.2:针对下面代码重复练习1.6.1。
cpp
int w, x, y, z;
int i = 3; int j = 4;
{
int i = 5;
w = i + j;
}
x = i + j;
{
int j = 6;
i = 7;
y = i + j;
}
z = i + j;
一开始:
cpp
int i = 3;
int j = 4;
进入第一个块:
cpp
{
int i = 5;
w = i + j;
}
这里声明了局部变量i = 5,遮蔽外层的i = 3。但是没有重新声明j,所以j使用外层的j = 4。因此,w = i + j = 5 + 4 = 9。退出第一个块后,局部变量i = 5消失,外层变量仍然是i = 3, j = 4。所以x = i + j = 3 + 4 = 7。
进入第二个块:
cpp
{
int j = 6;
i = 7;
y = i + j;
}
这里声明了局部变量j = 6,遮蔽外层的j = 4。但是没有声明局部变量i,所以:
cpp
i = 7;
修改的是外层变量i。此时外层i = 7,局部j = 6,所以y = i + j = 7 + 6 = 13。退出第二个块后,局部变量j = 6消失,外层变量现在是i = 7, j = 4。因此z = i + j = 7 + 4 = 11。
所以最终结果是:
cpp
w = 9;
x = 7;
y = 13;
z = 11;
练习1.6.3:对于下面的块结构代码,假设使用常见的声明的静态作用域规则,给出其中12个声明每一个的作用域。
cpp
{
int w, x, y, z; /* 块 B1 */
{
int x, z; /* 块 B2 */
{
int w, x; /* 块 B3 */
}
}
{
int w, x; /* 块 B4 */
{
int y, z; /* 块 B5 */
}
}
}
使用静态作用域规则时,一个变量声明的作用域通常是从声明位置开始,到它所在块结束为止。如果内层块又声明了同名变量,那么内层声明会遮蔽外层声明。也就是说,外层变量可以被内层块使用,但如果内层块重新声明了同名变量,内层块中就优先使用内层变量。
| 声明位置 | 声明变量 | 作用域说明 |
|---|---|---|
| B1 | w | 在B1中有效,但在B3和B4中被同名w遮蔽 |
| B1 | x | 在B1中有效,但在B2、B3、B4中被同名x遮蔽 |
| B1 | y | 在B1、B2、B3、B4中有效,但在B5中被同名y遮蔽 |
| B1 | z | 在B1和B4中有效,但在B2和B5中被同名z遮蔽 |
| B2 | x | 在B2中有效,但在B3中被同名x遮蔽 |
| B2 | z | 在B2和B3中有效 |
| B3 | w | 只在B3中有效 |
| B3 | x | 只在B3中有效 |
| B4 | w | 在B4和B5中有效 |
| B4 | x | 在B4和B5中有效 |
| B5 | y | 只在B5中有效 |
| B5 | z | 只在B5中有效 |
练习1.6.4:下面的C代码的打印结果是什么?
cpp
#define a (x+1)
int x = 2;
void b() {
x = a;
printf("%d\n", x);
}
void c() {
int x = 1;
printf("%d\n", a);
}
void main() {
b();
c();
}
第一步先调用b(),函数b()原代码是:
cpp
void b() {
x = a;
printf("%d\n", x);
}
宏替换之后,相当于:
cpp
void b() {
x = (x+1);
printf("%d\n", x);
}
这里的x是全局变量:
cpp
int x = 2;
所以执行:
cpp
x = x + 1;
即x = 2 + 1 = 3,因此b()打印3。
第二步则调用c(),函数c() 原代码是:
cpp
void c() {
int x = 1;
printf("%d\n", a);
}
宏替换之后,相当于:
cpp
void c() {
int x = 1;
printf("%d\n", (x+1));
}
注意,这里函数c() 内部声明了一个局部变量:
cpp
int x = 1;
局部变量x会遮蔽全局变量x。所以:
cpp
printf("%d\n", (x+1));
使用的是局部变量x = 1,因此打印2。所以最终打印结果是3和2。
总结
本文讲解了编译原理第一章引论部分的练习题。主要内容包括:编译器和解释器的区别,编译器先整体翻译再执行,解释器边分析边执行;两者的优劣比较,编译器效率高但灵活性差,解释器反之;编译器生成汇编语言而非机器语言的好处是降低复杂度;源到源翻译器使用C作为目标语言可提高可移植性;汇编器需要完成指令翻译、符号处理、地址分配等任务;对多种编程语言进行了范式分类;通过三个块结构代码示例讲解了静态作用域规则;最后分析了一个包含宏替换的C程序输出结果。这些练习涵盖了语言处理器、编程语言范式和作用域等核心概念。
