斯坦福-CS143-编译原理中文笔记-全-
斯坦福 CS143 编译原理中文笔记(全)

编译器课程 P1:编译器与解释器概述 🧠

在本节课中,我们将要学习编程语言实现的两种主要方法:编译器和解释器。我们将了解它们的基本概念、历史背景以及现代编译器的主要结构。

编译器与解释器
实现编程语言有两种主要方法:编译器和解释器。这门课程主要讲解编译器,但在第一节课中,我们需要先了解解释器。
解释器擅长直接执行程序。以下是解释器的工作流程:
- 输入:解释器接收两个输入,一是你编写的程序,二是程序运行所需的数据。
- 处理:解释器直接读取程序代码并处理输入数据。
- 输出:解释器直接产生程序的运行结果。
这意味着解释器在执行前不对程序进行预处理。程序编写完成后,可以立即通过解释器处理数据并开始运行。因此,解释器可以被视为实时的,其核心工作是运行程序。

编译器则采用不同的结构。以下是编译器的工作流程:
- 输入:编译器仅以程序作为输入。
- 处理:编译器对程序进行预处理(编译),生成一个可执行文件。这个文件可能是汇编语言、字节码或其他形式的机器可执行代码。
- 输出:生成的可执行文件可以独立运行。当需要计算结果时,只需将数据输入给这个可执行文件,它就会产生输出。
在这种结构中,编译器是离线工作的。它是一个预处理步骤,生成的可执行文件可以在不同的输入数据上反复运行,而无需重新编译程序。

历史背景:从解释器到编译器
了解编译器和解释器的早期发展有助于理解它们的设计初衷。故事始于20世纪50年代的IBM 704计算机。
IBM 704是当时首台取得商业成功的机器。客户开始使用后发现,软件成本远远超过了硬件成本。这在当时是一个重大问题,因为硬件本身已经极其昂贵。软件成为了充分利用计算机的主要成本,这促使人们思考如何提高编程生产力。
最早的尝试之一是1953年由约翰·巴库斯开发的“快速编码”(Speedcoding),它可以被视为早期解释器的例子。
和所有解释器一样,它有优缺点:
- 优点:程序开发速度快,程序员效率高。
- 缺点:解释执行的程序比手写或编译的程序慢10到20倍。此外,解释器本身占用了300字节内存,这在当时是IBM 704整个内存的30%,空间占用成为一大关注点。
“快速编码”并未流行,但约翰·巴库斯认为其理念有前途。当时最重要的应用是科学计算,程序员需要以机器可执行的形式书写公式。巴库斯认为,如果先将公式翻译成机器可直接执行的形式,代码运行会更快,同时仍允许程序员进行高级编程。
于是,“公式翻译”项目,即FORTRAN项目诞生了。该项目从1954年持续到1957年。到1958年,它取得了巨大成功,超过50%的程序都是用FORTRAN编写的。FORTRAN提高了编程的抽象级别和程序员的生产力,让人们能更好地利用计算机。
FORTRAN的影响与现代编译器结构

FORTRAN I是第一个成功的高级编程语言,对计算机科学产生了深远影响。它促进了大量理论工作,并展示了在编程语言领域,扎实的理论与工程技能相结合的重要性。
FORTRAN的影响不仅限于理论研究,更推动了实用编译器的开发。其影响延续至今,现代编译器通常仍保留着由FORTRAN确立的四个核心阶段(在原始描述中为五个,常合并为四个)。
上一节我们介绍了FORTRAN的历史地位,本节中我们来看看它的具体结构。FORTRAN I编译器主要由五个阶段组成:
- 词法分析:将源代码字符流转换为有意义的词法单元序列。
- 语法分析:根据语法规则,将词法单元序列构建成语法树。
- 语义分析:检查程序的语义正确性,如类型匹配、作用域规则等。
- 优化:对程序进行一系列转换,以提高运行速度或减少内存使用。
- 代码生成:将优化后的中间表示翻译成目标语言。根据目标不同,可能是机器码、虚拟机字节码,甚至是另一种高级编程语言。

这五个阶段共同构成了经典编译器的流水线。
总结

本节课中我们一起学习了编程语言实现的两种核心方法。我们了解到解释器是实时执行程序的工具,而编译器则是离线将程序翻译成可执行文件的工具。我们从历史角度回顾了从“快速编码”解释器到FORTRAN编译器的演进,理解了提高编程生产力的需求如何驱动了技术的发展。最后,我们学习了由FORTRAN确立的、至今仍被现代编译器广泛采用的经典多阶段结构,包括词法分析、语法分析、语义分析、优化和代码生成。

课程 P10:形式语言基础 📚

在本节课中,我们将学习形式语言的基本概念。形式语言在理论计算机科学和编译器设计中都扮演着重要角色。我们将了解其定义、核心组成部分,并学习如何通过“意义函数”将语法与语义分离。


定义与基本概念 🔤
上一节我们介绍了课程主题,本节中我们来看看形式语言的具体定义。
形式语言包含一个字母表,即一个字符集合,记作 Σ。该字母表上的语言,就是由这些字符构成的字符串的集合。对于正则语言,我们有特定的规则来构建这些字符串集合。但总的来说,形式语言就是某个字母表上的任意字符串集合。

形式语言的例子 📝
以下是几个形式语言的例子,帮助我们理解这个概念:
- 英语句子:以英文字母为字母表,所有有效英语句子的集合。这不是一个严格的形式语言,因为对“有效句子”的定义可能存在分歧。
- C程序:以ASCII字符集为字母表,所有有效C程序的集合。这是一个非常明确的形式语言,也是C编译器接受的输入集合。
这里需要强调的是,在讨论形式语言或我们感兴趣的字符串集合之前,必须首先明确定义其字母表。
语法与语义:意义函数 🧠
形式语言中的一个重要概念是“意义函数”。我们通常将语言中的一个字符串称为表达式 e,它代表一段程序或我们感兴趣的其他事物。意义函数 l 的作用,就是将语言中的字符串(表达式)映射到它们的含义。
以正则表达式为例,其意义函数 l 将一个正则表达式映射到它所表示的正则语言(一个字符串集合)。以下是其递归定义:

- l(ε) = {“”}:空表达式 ε 表示仅包含空字符串的集合。
- l(c) = {“c”}:对于字母表中的每个字符 c,表达式 c 表示仅包含该单个字符的集合。
- 并集:l(a + b) = l(a) ∪ l(b)。表达式
a + b的含义是a的含义与b的含义的并集。 - 连接:l(ab) = { xy | x ∈ l(a), y ∈ l(b) }。表达式
ab的含义是从a的含义和b的含义中各取一个字符串连接后构成的新集合。 - 迭代:l(a*) = ∪_{i≥0} l(a^i)。表达式
a*的含义是a的含义进行零次或多次连接后所有结果的并集。
这个定义清晰地展示了如何递归地应用意义函数 l,将复合表达式分解为简单表达式,计算其含义,最终得到字符串集合。

分离语法与语义的好处 ✨
使用意义函数将语法(表达式)和语义(含义)分开,有几个重要原因:
- 明确性:它清晰地划分了定义中的语法部分和语义部分,避免混淆。
- 表示法的灵活性:语法和语义分离后,我们可以在不改变语义的前提下,改变语法(表示法)。不同的表示法可能更适合解决特定问题。
- 多对一映射:在有趣的语言中,通常存在多个不同的表达式具有完全相同的语义(即 l 是多对一的函数)。这是编译器进行优化的理论基础——我们可以用一个功能等效但效率更高的程序替换另一个程序。
需要强调的是,我们绝不希望意义函数是“一对多”的,那将导致表达式含义模糊,这不是我们期望的状况。
表示法的重要性:一个例子 🔢
让我们通过一个例子来说明分离语法和语义对表示法的益处。
考虑两种数字系统:阿拉伯数字(如 0, 1, 42)和罗马数字(如 I, IV, XL)。这两种系统的语义(即它们表示的数字,如整数)是完全相同的。然而,它们的语法(表示法)却截然不同。
使用阿拉伯数字进行加、减、乘、除运算的算法相对简单直观。而使用罗马数字进行同类运算则复杂得多。这个例子表明,表示法(语法)的选择至关重要,它影响着我们思考问题的方式和执行操作的程序。分离语法和语义允许我们专注于核心思想(语义),并尝试寻找更优的表示方式。

总结 📖

本节课中我们一起学习了形式语言的基础知识。我们首先了解了形式语言由字母表和字符串集合定义。然后,我们引入了意义函数 l 的核心概念,它明确地将语法(表达式)映射到语义(含义),并以正则表达式为例展示了其递归定义。最后,我们探讨了将语法与语义分离的三个主要好处:明确性、表示法的灵活性,以及为实现编译器优化奠定基础的多对一映射特性。理解这些概念是深入学习编译器和形式语言理论的重要一步。

课程 P11:词法规范与正则表达式 🧩

在本节课中,我们将学习如何使用正则表达式来精确地描述编程语言中的各种词法元素,例如关键字、整数和标识符。我们将通过具体的例子,一步步构建这些规范,并了解一些简化书写的常用技巧。
1. 指定关键字 🔑
上一节我们介绍了正则表达式的基本概念,本节中我们来看看如何用它来描述编程语言中的关键字。这是一个相对简单的案例,我们将仅对三个关键字(if、else、then)进行操作。为更多关键字编写正则表达式的方法与此类似。
以下是定义这三个关键字正则表达式的步骤:
- 为
if编写正则表达式,即字符i的正则表达式后跟字符f的正则表达式,这是这两个正则表达式的连接。 - 将上述结果与
else的正则表达式进行联合(Union)。else由4个单独字符组成,因此需要写出这4个字符的连接。 - 最后,再与
then的正则表达式联合。
直接书写单个字符的连接会显得冗长。实际上,有一种常用的简写:若要编写一个字符序列的正则表达式,只需在序列的最外层字符周围放置引号。例如,"if" 完全等同于 i 和 f 的连接。类似地,可以写出 "else" 和 "then"。因此,这三个关键字的完整正则表达式可以简洁地写为:"if" | "else" | "then"。
2. 定义整数 🔢
现在我们已经学会了如何描述固定的关键字,本节中我们来看看如何描述可变的模式,例如整数。我们希望整数是数字的非空字符串。

首先,我们需要定义“数字”是什么。数字是0到9中的任何一个单个字符。我们可以通过这10个单个字符正则表达式的并集来定义它:0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9。
这是一个非常常见的需求,因此大多数工具都支持为这个正则表达式命名,例如将其命名为 digit。
接下来,我们需要描述“多个数字”。我们知道可以使用克林星号(*)来表示“零个或多个”,即 digit*。但这包含了空字符串,而整数不能为空。一个简单的解决方法是要求整个序列必须以一个数字开始,然后后面跟着零个或多个附加数字,即 digit digit*。
这种“至少出现一次”的模式非常常见,因此有一个专门的简写:加号(+)。a+ 是 a a* 的简写。因此,整数的正则表达式可以简化为:digit+。
3. 定义标识符 🏷️
定义了整数之后,让我们思考一个更复杂的例子:如何定义标识符。标识符通常是以字母开头的,由字母或数字组成的字符串。

我们已经知道如何定义数字(digit)。现在我们需要定义“字母”。直接写出所有大小写字母的并集(a | b | c | ... | z | A | B | ... | Z)非常繁琐。
工具通常支持一种称为“字符范围”的简写。在方括号内,可以用横线连接起始字符和结束字符,表示从第一个字符到第二个字符(包含)的所有单字符的并集。例如:
[a-z]表示所有小写字母。[A-Z]表示所有大写字母。- 可以将它们合并为
[a-zA-Z]来表示所有字母。我们可以将其命名为letter。
现在我们可以写出标识符的定义:它必须以一个字母开头,之后可以是零个或多个字母或数字。因此,标识符的正则表达式为:letter (letter | digit)*。
4. 处理空白字符 ␣
在完整的词法规范中,我们还需要处理那些我们不感兴趣的部分,例如空格、换行符和制表符,以便识别并丢弃它们。
空格字符(‘ ‘)可以直接写出。但换行符和制表符没有很好的打印表示。工具通常通过“转义序列”来命名这些不可打印的字符,即以反斜杠(\)开头后跟特定字符。常见的有:
\n表示换行符(Newline)。\t表示制表符(Tab)。
因此,空白字符(whitespace)可以定义为空格、换行符或制表符中的任意一个。我们需要一个非空的空白序列,所以使用加号(+)。完整的正则表达式为:(‘ ‘ | \n | \t)+。
5. 正则表达式的其他应用示例:电子邮件地址 📧

让我们暂停讨论编程语言,看看正则表达式在另一个领域的应用:电子邮件地址。实际上,电子邮件地址的格式可以用正则表达式来描述。
为了简化,我们假设用户名和域名各部分仅由字母组成。那么一个电子邮件地址可以看作是由 @ 和 . 分隔的四个非空字母序列。
- 用户名:
letter+ @符号- 域名第一部分:
letter+ - 第一个点:
. - 域名第二部分:
letter+ - 第二个点:
. - 域名第三部分:
letter+
因此,一个简化的电子邮件地址正则表达式可以是:letter+ ‘@’ letter+ ‘.’ letter+ ‘.’ letter+。实际的电子邮件地址规则更复杂,但可以用更复杂的正则表达式写出。
6. 真实语言规范片段:Pascal 中的数字 ✨
最后,我们来看一个真实编程语言(Pascal)的词法规范片段,它定义了数字(包括整数和浮点数)。这个定义展示了如何处理可选部分。
数字的整体定义是:一串数字,后面可能跟着一个可选的小数部分,再后面可能跟着一个可选的指数部分。
让我们自下而上地分析:
digit:单个数字,定义为0 | 1 | ... | 9。digits:非空数字序列,即digit+。- 可选小数部分:它要么是一个小数点后跟一串数字(
‘.’ digits),要么完全不存在(用ε表示空字符串)。因此是(‘.’ digits) | ε。这种“可选”结构非常常见,许多工具提供问号(?)作为简写,所以可以写为(‘.’ digits)?。 - 可选指数部分:它要么是以
e开头,后跟一个可选的符号(+或-),再跟一串数字;要么完全不存在。可选的符号可以写为(‘+’ | ‘-’)?。因此,整个可选指数部分可以写为(‘e’ (‘+’ | ‘-’)? digits)?。
将以上组合起来,Pascal 中数字的正则表达式大致为:digits (‘.’ digits)? (‘e’ (‘+’ | ‘-’)? digits)?。

总结 📝
本节课中我们一起学习了如何使用正则表达式来描述编程语言及其他领域中的词法元素。
- 我们学会了为关键字、整数、标识符和空白字符编写正则表达式。
- 我们掌握了实用的简写技巧,如引号表示字符串、
digit和letter的命名、字符范围[a-z]、+(至少一次)、?(零次或一次)。 - 我们还看到了正则表达式在描述电子邮件地址和真实编程语言(如Pascal数字)规范中的应用。

正则表达式是描述简单字符串集合(正则语言)的强大工具。目前,我们仅将其用作规范(Specification),即定义我们感兴趣的字符串集合。在后续课程中,我们将探讨如何实现词法分析,即给定一个字符串 s 和一个正则表达式 r,如何判断 s 是否属于 r 所定义的语言。

课程 P12:使用正则表达式构建词法规范 🧩


在本节课中,我们将学习如何利用正则表达式来构建编程语言的完整词法规范。我们将从回顾正则表达式的基本符号开始,然后探讨如何将这些表达式组合起来,以定义语言的词法规则,并解决词法分析过程中可能遇到的歧义问题。

正则表达式符号回顾 📝
上一节我们介绍了正则表达式的基本概念,本节中我们来看看一些常用的简写符号。

以下是正则表达式中常见的简写符号及其含义:
a+:表示至少一个a的序列,等价于aa*。a|b:表示a或b的并集。a?:表示可选的a,是a+ε的缩写。[a-z]:表示字符范围,即从a到z的所有字符的并集。[^a-z]:表示字符范围的补集,即除了a到z之外的任何字符。
从模式匹配到词法分析 🔍
之前我们讨论的是判断一个字符串 S 是否属于某个正则表达式 R 定义的语言 L(R)。然而,词法分析的目标不仅仅是判断,而是要将一个长的输入字符串(即源代码)分割成一个个有意义的“单词”(即词素)。

因此,我们需要将正则表达式适配到词法分析这个新问题上。
构建词法规范的步骤 🏗️
上一节我们介绍了正则表达式的基本符号,本节中我们来看看如何用它们构建词法规范。

构建词法规范主要分为两个步骤。
第一步:为每个标记类定义正则表达式

首先,我们需要为语言中的每个词法类别(标记类)编写一个正则表达式。
以下是几个常见标记类的正则表达式示例:
- 数字:
digit+ - 关键字:
if、else、while等具体字符串的列表。 - 标识符:以字母开头,后跟零个或多个字母或数字的序列,可表示为
letter (letter|digit)*。 - 标点符号:如左括号
(、右括号)等。
第二步:合并为总词法规范

接下来,我们将所有标记类的正则表达式取并集,形成一个巨大的正则表达式 R,它代表了整个语言的词法规范。
假设我们有 k 个标记类,其正则表达式分别为 R1, R2, ..., Rk,那么总词法规范 R 可以表示为:
R = R1 | R2 | ... | Rk
词法分析的核心算法 ⚙️
上一节我们构建了总词法规范,本节中我们来看看如何使用它来分析输入字符串。
核心算法是一个循环过程,输入是一个字符序列 x1 x2 ... xn。
以下是算法的基本步骤:
- 检查输入字符串的当前前缀(从第一个字符开始)是否属于总正则表达式
R的语言。 - 如果该前缀属于
L(R),则它必定属于某个特定标记类Rj的语言。这意味着我们识别出了一个有效的词素。 - 将这个匹配的前缀从输入字符串中移除(“消耗”掉),并输出对应的标记(
token)。 - 回到步骤 1,对剩余的输入字符串重复此过程,直到整个输入字符串被处理完毕。
处理词法分析中的歧义 🤔
上一节介绍了核心算法,本节中我们来看看算法中存在的几个关键歧义及其解决方案。
歧义一:匹配最长前缀(最大贪婪匹配)
问题:当输入的前缀有多个长度不同的有效匹配时,应该选择哪一个?
规则:总是选择最长的匹配前缀。

原因:这符合人类阅读代码的习惯。例如,当我们看到 == 时,会将其视为一个“双等号”比较运算符,而不是两个独立的“等号”赋值运算符。
歧义二:多个标记类匹配(优先级)
问题:当同一个前缀同时属于多个不同标记类(例如,既是关键字又是标识符)的语言时,应该选择哪个标记类?
规则:为标记类定义优先级。当发生冲突时,选择优先级最高的标记类。
实现:通常通过在词法规范文件中排列标记类正则表达式的顺序来实现。列表中靠前的规则具有更高的优先级。因此,通常将关键字规则放在标识符规则之前。

歧义三:无规则匹配(错误处理)
问题:如果输入的前缀不属于任何已定义的标记类语言,该如何处理?
解决方案:定义一个特殊的“错误”标记类,其正则表达式匹配所有无效的输入字符串(例如,包含非法字符的序列)。将这个错误规则放在词法规范列表的最后,并赋予其最低的优先级。
这样,只有当没有任何有效规则匹配时,才会匹配到这个错误规则,从而可以报告词法错误并提供有用的错误信息(如错误位置)。
总结与展望 📚
本节课中我们一起学习了如何使用正则表达式构建编程语言的词法规范。我们回顾了正则表达式的符号,定义了为每个标记类编写正则表达式的方法,并将它们合并为总词法规范。我们详细探讨了词法分析的核心算法,并解决了其中三个关键的歧义问题:通过最长匹配原则确定词素边界,通过优先级规则解决标记类冲突,以及通过定义错误规则来优雅地处理无效输入。

最后需要提醒的是,在具体实现词法分析器时,规则的定义顺序和编写方式需要仔细考量,以确保获得预期的行为。此外,虽然我们讨论了算法逻辑,但存在高效的一次扫描算法(如基于有限自动机的方法)来实现这一切,这将是未来课程的主题。

课程 P13:有限自动机 🧠


在本节课中,我们将学习有限自动机。它是一种用于实现正则表达式的计算模型,也是词法分析器的核心组成部分。我们将从基本概念开始,逐步理解其工作原理、不同类型以及它们与正则表达式的关系。
概述 📋

上一节我们介绍了正则表达式,它是一种用于描述词法单元模式的规范语言。本节中,我们将探讨有限自动机,它是将正则表达式转化为可执行程序的理想实现模型。有限自动机与正则表达式在表达能力上是等价的,它们描述的语言集合被称为正则语言。
什么是有限自动机? 🤖
有限自动机是一种抽象的计算模型,用于识别字符串是否属于某种特定模式(即语言)。它由以下几个核心部分组成:
- 输入字母表 (Σ):一个有限的字符集合,是自动机可以读取的所有可能输入。
- 有限状态集 (Q):自动机在运行过程中可能处于的所有状态的集合。状态的有限性是“有限”自动机的关键。
- 起始状态 (q₀ ∈ Q):自动机开始运行时所处的状态。
- 接受状态/最终状态集 (F ⊆ Q):如果自动机在读取完整个输入后处于这些状态之一,则它“接受”该输入字符串。
- 状态转换函数 (δ):定义了自动机如何根据当前状态和当前输入字符改变状态。形式化地,它是一个映射:
δ: Q × Σ → Q(对于确定性自动机)。

工作原理
自动机从起始状态开始,逐个读取输入字符串中的字符。每读一个字符,它就根据转换函数从当前状态转移到下一个状态。当所有输入字符都被消耗后:
- 如果自动机停在接受状态,则它接受这个字符串。
- 如果自动机停在非接受状态,或者在中途因没有对应的转换而“卡住”,则它拒绝这个字符串。

有限自动机接受的所有字符串的集合,称为该自动机的语言。
图形化表示 🎨
有限自动机通常用状态图来表示,这比纯数学定义更直观。
以下是图形化表示的规则:
- 状态:用圆圈表示。
- 起始状态:用一个不来自任何其他节点的箭头指向该状态。
- 接受状态:用双圆圈表示。
- 转换:用带标签的有向边表示。例如,从状态
A到状态B且标签为‘a’的边表示:如果在状态A读到字符‘a’,则转移到状态B。
示例解析 🔍
示例1:识别单个数字 ‘1’
让我们构建一个只接受字符串 “1” 的自动机。

状态设计:
- 状态
A:起始状态。 - 状态
B:接受状态。
转换规则:
- 在状态
A,读取‘1’,转移到状态B。
执行过程:
- 输入
“1”:A -(1)→ B。输入结束,处于接受状态B,接受。 - 输入
“0”:在状态A没有关于‘0’的转换,机器卡住,拒绝。 - 输入
“10”:A -(1)→ B。在状态B读取‘0’,没有对应转换,拒绝。
这个自动机的语言是:{ “1” }。
示例2:识别任意个 ‘1’ 后跟单个 ‘0’
现在构建一个接受零个或多个 ‘1’ 后跟一个 ‘0’ 的自动机(即正则表达式 1*0)。
状态设计:
- 状态
A:起始状态。 - 状态
B:接受状态。
转换规则:
- 在状态
A,读取‘1’,自循环回到状态A(用于消耗任意数量的‘1’)。 - 在状态
A,读取‘0’,转移到接受状态B。
执行过程:
- 输入
“110”:A -(1)→ A -(1)→ A -(0)→ B。接受。 - 输入
“100”:A -(1)→ A -(0)→ B。在状态B读取第二个‘0’,没有转换,拒绝。

这个自动机的语言是:{ “0”, “10”, “110”, “1110”, … }。
确定性与非确定性自动机 ⚖️

上一节我们看到的自动机都是确定性的。本节中我们来看看更灵活的非确定性自动机。
确定性有限自动机 (DFA)
DFA 有两个严格限制:
- 没有 ε 移动:每次转换必须消耗一个输入字符。
- 转换是确定的:对于任意状态和输入字符,有且只有一条出边。即函数
δ(q, a)的结果是唯一的。

在 DFA 中,对于给定的输入字符串,通过状态图的路径是唯一确定的。
非确定性有限自动机 (NFA)
NFA 放松了 DFA 的限制:
- 允许 ε 移动:可以不消耗任何输入字符就改变状态。这是一种“免费”的跳转。
- 允许非确定性转换:在同一个状态下,对于同一个输入字符,可以有多条出边指向不同的状态。
NFA 在运行时可以“选择”走哪条路径。关于 NFA 的接受规则是:只要存在至少一条选择路径,使得在消耗完所有输入后,自动机处于某个接受状态,那么整个输入就被接受。

ε 移动的威力:实际上,任何“在同一输入下有多条出边”的非确定性,都可以通过引入额外的状态和 ε 移动来模拟。因此,NFA 和带有 ε 移动的 NFA (ε-NFA) 在能力上是等价的。
NFA 的执行与状态集 📊
由于 NFA 在运行时可能处于多个潜在状态,我们通常用状态集合来跟踪它的所有可能性。
执行步骤:
- 起始时,当前可能状态集为
{起始状态},并考虑所有从起始状态出发的 ε 移动所能到达的状态(即 ε-闭包)。 - 每读取一个输入字符
a,对于当前可能状态集中的每一个状态q,找出所有从q出发、标签为a的转换所能到达的状态,并将这些新状态加入下一个状态集。然后,再次计算这个新集合的 ε-闭包。 - 重复步骤2,直到输入结束。
- 检查最终的可能状态集中是否包含至少一个接受状态。如果是,则 NFA 接受该输入;否则拒绝。

示例:考虑一个简单的 NFA,从起始状态 A 读 ‘0’ 可以到 A 或 B。
- 输入
“0”:最终状态集可能是{A, B}。如果B是接受状态,则接受。 - 输入
“00”:从{A, B}出发,读第二个‘0’,可能到达的状态更多。只要最终集合中包含接受状态,即接受。
DFA 与 NFA 的等价性与权衡 ⚡
一个重要的理论结果是:DFA、NFA 和 ε-NFA 在表达能力上是完全等价的,它们识别的都是正则语言。也就是说,任何用 NFA 描述的语言,都存在一个 DFA 可以描述同样的语言,反之亦然。
然而,它们在实践中有不同的权衡:
- DFA 的优点:执行速度快。对于任何输入,只需沿着唯一确定的路径走,无需跟踪多个选择。
- NFA 的优点:描述更简洁。描述同一个语言,NFA 所需的状态数可能比 DFA 少得多(指数级差距)。NFA 更易于直接从正则表达式构造。
因此,在编译器中,常见的做法是:先用 NFA(或直接由正则表达式构造)来表示词法规则,因为其构造简单、体积小;然后再通过算法(如子集构造法)将 NFA 转换为等价的 DFA,用于最终高效的词法分析扫描。

总结 🎯
本节课中我们一起学习了有限自动机。我们从基本定义和图形化表示入手,理解了 DFA 如何确定性地识别字符串。接着,我们引入了更强大的 NFA,它通过允许 ε 移动和非确定性选择,能够更简洁地描述语言。最后,我们认识到尽管 DFA 和 NFA 形式不同,但能力等价,在实际编译器构造中,我们往往结合两者的优点:用 NFA 设计,用 DFA 执行。

掌握有限自动机是理解词法分析器如何工作的关键一步。在接下来的课程中,我们将看到如何将正则表达式自动转换为 NFA,以及如何将 NFA 转换为可高效执行的 DFA。

课程 P14:正则表达式到非确定性有限自动机 (NFA) 的转换 🧩
在本节课中,我们将学习如何将一个正则表达式转换为一个等价的非确定性有限自动机。这是实现词法分析器的关键步骤之一。

概述
要实现一个词法规范,第一步是将其写为一组正则表达式。但这仅仅是规范,我们需要将其转换为一个可以实际执行词法分析的程序。这个过程通常分为三步:
- 将正则表达式转换为识别相同语言的非确定性有限自动机。
- 将这些 NFA 转换为确定性有限自动机。
- 将 DFA 实现为查找表和遍历代码。
本节课我们将聚焦于第一步:将正则表达式转换为 NFA。我们将为每一种正则表达式结构定义其对应的 NFA 构造方法。
基础正则表达式的 NFA 构造

首先,我们来看两种最简单的基础正则表达式:空串和单个字符。我们将使用以下符号:箭头 → 表示起始状态,双圆圈 ◎ 表示最终状态。在构造中,我们通常只关注机器的起始和最终状态。
空正则表达式 (ε)
对于空正则表达式 ε,其对应的 NFA 非常简单。它只接受空字符串。
构造:创建一个起始状态和一个最终状态,并在它们之间添加一条 ε 转换(不消耗任何输入字符的跳转)。
起始状态 → (ε) → 最终状态 ◎
单个字符 (a)
对于单个字符 a,其 NFA 只接受该特定字符。
构造:创建一个起始状态和一个最终状态,并在它们之间添加一条在字符 a 上的转换。
起始状态 → (a) → 最终状态 ◎
复合正则表达式的 NFA 构造
上一节我们介绍了基础正则表达式的构造,本节中我们来看看如何组合这些简单的 NFA 来构建识别更复杂语言的 NFA。我们将处理三种复合操作:连接、并集和迭代。
连接 (AB)
假设我们已经有了识别语言 A 的 NFA (M_A) 和识别语言 B 的 NFA (M_B)。要构造识别连接 AB 的 NFA,我们需要按顺序运行 M_A 和 M_B。
构造:
- 复合 NFA 的起始状态是 M_A 的起始状态。
- 移除 M_A 最终状态的“最终”标记。
- 从 M_A 的(原)最终状态添加一条 ε 转换到 M_B 的起始状态。
- 复合 NFA 的最终状态是 M_B 的最终状态。
这个构造允许机器先识别属于 A 的部分输入,然后通过 ε 跳转无缝地开始识别属于 B 的剩余部分。
并集 (A|B)
要构造识别并集 A|B(即属于 A 或属于 B)的 NFA,我们需要让机器能“选择”走哪条路径。
构造:
- 创建一个新的起始状态。
- 从这个新起始状态分别添加 ε 转换到 M_A 和 M_B 的起始状态。
- 从 M_A 和 M_B 的最终状态分别添加 ε 转换到一个新的最终状态。
- M_A 和 M_B 自身的最终状态标记被移除。
机器在开始时通过 ε 转换非确定性地选择进入 M_A 或 M_B 的路径。只要有一条路径能成功到达新的最终状态,输入就被接受。
迭代 (A*)
迭代 A* 表示 A 中的字符串重复零次或多次。其 NFA 需要支持循环。
构造:
- 创建一个新的起始状态和一个新的最终状态。
- 从新起始状态添加一条 ε 转换到新最终状态(用于接受空串,即零次重复)。
- 从新起始状态添加一条 ε 转换到 M_A 的起始状态。
- 从 M_A 的最终状态添加一条 ε 转换回到 M_A 的起始状态(实现循环)。
- 从 M_A 的最终状态添加另一条 ε 转换到新的最终状态(用于在完成最后一次重复后退出)。
这样,机器可以选择直接接受空串,或者进入 M_A,执行完成后可以选择循环回去再次执行 M_A,也可以选择跳转到最终状态结束。
构造示例
现在,让我们通过一个具体的例子 (1|0)*1 来实践上述构造规则。我们将按照正则表达式的结构,自底向上地构建其 NFA。
首先,我们构建最基本的单元:
- 构造识别
1的 NFA:→ (1) → ◎ - 构造识别
0的 NFA:→ (0) → ◎
接下来,我们构建复合部分 (1|0):
- 应用并集构造。创建一个新起始状态,通过 ε 转换分别指向两个字符 NFA 的起始状态,再将它们的最终状态通过 ε 转换指向一个新的最终状态。
然后,我们构建迭代部分 (1|0)*:
- 应用迭代构造。为
(1|0)的 NFA 添加一个新的起始状态和新的最终状态。 - 从新起始状态添加 ε 转换到新最终状态(接受空串)。
- 从新起始状态添加 ε 转换到
(1|0)NFA 的起始状态。 - 从
(1|0)NFA 的最终状态添加 ε 转换回到其自身的起始状态(循环)。 - 从
(1|0)NFA 的最终状态添加 ε 转换到新的最终状态。
最后,我们构建整个表达式 (1|0)*1:
- 应用连接构造。将
(1|0)*的 NFA 与另一个识别1的 NFA 连接。 (1|0)*NFA 的最终状态(即上一步的新最终状态)移除最终标记,并添加 ε 转换到识别1的 NFA 的起始状态。- 整个复合 NFA 的最终状态就是识别
1的 NFA 的最终状态。
通过以上步骤,我们就得到了识别语言 (1|0)*1 的完整 NFA。
总结

本节课中我们一起学习了将正则表达式转换为非确定性有限自动机的系统方法。我们首先为空串和单字符这两个原子单元定义了简单的 NFA。然后,我们学习了三种核心复合操作的构造规则:连接、并集和迭代。最后,我们通过一个例子 (1|0)*1 演示了如何综合运用这些规则,自底向上地构建出识别复杂正则表达式的 NFA。这是实现词法分析器的第一步,为后续转换为确定性自动机(DFA)并最终实现为可执行代码奠定了基础。


课程 P15:从NFA到DFA的转换 🧩
在本节课中,我们将学习如何将一个非确定有限自动机转换为一个确定有限自动机。我们将从核心概念“ε闭包”开始,理解NFA与DFA在状态上的根本区别,最后通过一个完整的构造算法和实例,一步步演示转换过程。

ε闭包:理解NFA的“隐形”移动 🔍
上一节我们回顾了从正则表达式构建NFA的过程。本节中,我们来看看NFA中一个关键概念:ε闭包。它帮助我们理解NFA在不消耗任何输入字符(即通过ε移动)时,可能处于的所有状态集合。
ε闭包的定义是:从一个给定的状态(或状态集合)出发,仅通过跟随ε移动所能到达的所有状态的集合。这个过程是递归的,意味着可以经过任意数量的ε移动。
以下是计算ε闭包的步骤:
- 将起始状态加入集合。
- 查看集合中每个状态,找出所有从该状态出发的ε移动所能到达的新状态。
- 将这些新状态加入集合。
- 重复步骤2和3,直到没有新状态可以加入。

公式表示: 给定状态 q,其ε闭包记为 ε-closure(q),是通过递归地添加从 q 经零次或多次ε转换可达的所有状态得到的集合。
从不确定性到确定性:核心思想 💡
理解了ε闭包后,我们来看看NFA与DFA的根本区别。NFA在运行时,由于存在选择(包括ε移动),在读取相同输入后可能处于多个不同的状态。

一个关键问题是:一个拥有 n 个状态的NFA,最多可能处于多少种不同的“状态组合”(即子集)中呢?答案是 2^n 种(包括空集)。虽然这个数字可能很大,但它是有限的。
这引出了转换的核心思想:我们可以构造一个DFA,让它的每一个状态都对应NFA可能处于的一个特定的状态子集。这样,DFA就能通过跟踪NFA所有可能的“并行”路径,来模拟NFA的行为。
构造算法:将NFA正式转换为DFA ⚙️
上一节我们介绍了利用状态子集模拟NFA的思想,本节中我们来看看如何形式化地定义这个转换算法。
假设我们有一个NFA,定义为 (Q, Σ, δ, q0, F),其中:
Q是状态集合。Σ是输入字母表。δ是状态转移函数(可能产生多个结果或ε移动)。q0是起始状态。F是接受状态集合。
我们需要构造一个等价的DFA,定义为 (Q’, Σ, δ’, q0’, F’)。
算法步骤如下:
- DFA的状态集 (Q’):Q’ 的每个状态是 NFA 状态集
Q的一个子集。即Q’ ⊆ 2^Q。 - DFA的起始状态 (q0’):
q0’ = ε-closure({q0})。这是NFA在开始读取输入前,通过ε移动可能处于的所有状态。 - DFA的接受状态集 (F’):
F’ = { S ∈ Q’ | S ∩ F ≠ ∅ }。只要一个DFA状态(即一个NFA状态子集)中包含至少一个NFA的接受状态,那么该DFA状态就是接受状态。 - DFA的转移函数 (δ’):对于DFA中的每个状态
S(即NFA的状态子集)和每个输入符号a ∈ Σ,其转移定义为:
δ'(S, a) = ε-closure( ∪_{s ∈ S} δ(s, a) )
代码描述:def dfa_transition(S, a): # 第一步:计算从集合S中任何状态s出发,通过输入a能直接到达的所有状态 next_states = set() for s in S: next_states.update(nfa_delta(s, a)) # nfa_delta 返回状态集合 # 第二步:计算这些新状态的ε闭包 return epsilon_closure(next_states)
这个算法确保了构造出的DFA是确定性的(每个状态-输入对只有唯一的下一个状态),并且与原始NFA接受完全相同的语言。
实例演示:一步步构建DFA 🛠️
让我们通过一个具体的NFA例子,应用上述算法,手动构建出对应的DFA。
假设我们有如下NFA(其状态转移包含ε移动):
(此处根据原视频描述,NFA图包含状态a, b, c, d, e, f, g, h, i, j,以及基于0和1的转移和ε转移)

构建过程:
-
确定起始状态:NFA起始状态为
a。DFA起始状态q0’ = ε-closure({a})。通过追踪ε移动,我们得到{a, b, c, d, h, i}。我们称这个DFA状态为S0。 -
为
S0计算转移:- 对于输入
0:在S0中,只有状态d有关于0的转移,到达f。然后计算ε-closure({f}),得到{a, b, c, d, f, g, h, i}。这是一个新的DFA状态,记为S1。
δ'(S0, 0) = S1 - 对于输入
1:在S0中,状态c和i有关于1的转移,分别到达e和j。计算ε-closure({e, j})。状态e有ε移动到j,j是接受状态且无ε移出。因此得到{e, j}。这是一个新的DFA状态,记为S2。由于S2包含NFA的接受状态j,因此S2也是DFA的接受状态。
δ'(S0, 1) = S2
- 对于输入
-
为
S1计算转移:- 对于输入
0:在S1中,状态d有关于0的转移至f。ε-closure({f})就是S1本身。
δ'(S1, 0) = S1 - 对于输入
1:在S1中,状态c和i有关于1的转移,分别到达e和j。这与S0遇到输入1的情况相同,因此到达S2。
δ'(S1, 1) = S2
- 对于输入
-
为
S2计算转移:- 对于输入
0:在S2中,状态e和j都没有关于0的转移。因此,δ'(S2, 0)指向一个空集状态。通常我们显式定义一个“死状态”S_empty来接收所有无效转移。
δ'(S2, 0) = S_empty - 对于输入
1:同样,S2中的状态没有关于1的转移。
δ'(S2, 1) = S_empty
- 对于输入
-
处理死状态
S_empty:对于任何输入,死状态都转移到自身。
δ'(S_empty, 0) = δ'(S_empty, 1) = S_empty
最终,我们得到了一个拥有状态 S0, S1, S2, S_empty 的DFA,其中 S2 是接受状态。其状态转移表可以清晰地描述机器的行为。
总结 📝
本节课中我们一起学习了从非确定有限自动机到确定有限自动机的转换。
- 我们首先学习了 ε闭包 的概念,它是理解NFA隐性移动的基础。
- 接着,我们分析了NFA的不确定性本质,并认识到其可能的状态子集数量是有限的,这为模拟提供了可能。
- 然后,我们详细介绍了子集构造法,这是一个将NFA正式转换为等价DFA的通用算法,明确了DFA的每个组成部分(状态集、起始状态、接受状态集、转移函数)是如何从NFA定义中推导出来的。
- 最后,我们通过一个完整的实例,一步步演示了如何应用该算法,从具体的NFA构造出对应的DFA,巩固了对整个转换过程的理解。

掌握NFA到DFA的转换,是理解编译器词法分析阶段如何高效处理正则表达式的关键一步。

课程 P16:词法分析器实现 - 有限自动机的实现 🛠️

在本节课中,我们将学习如何实现有限自动机,以完成词法分析器的构建。我们将重点讨论确定性有限自动机(DFA)和非确定性有限自动机(NFA)的不同实现策略,并比较它们在速度与空间上的权衡。


概述 📋
词法分析器的构建流程通常包括将正则表达式转换为NFA,再将NFA转换为DFA,最后实现DFA。然而,有时我们也可以直接基于NFA实现词法分析器。本节我们将重点关注这最后一步——有限自动机的实现。
上一节我们介绍了从正则表达式到NFA再到DFA的转换过程。本节中,我们来看看如何将这些自动机模型转化为可执行的代码。

DFA的实现:表格驱动法 📊

实现确定性有限自动机(DFA)最直接的方法是使用一个二维数组(表格)。其中一个维度代表状态,另一个维度代表输入符号。表格的每个单元格存储了在特定状态和输入符号下,机器应转移到的下一个状态。
以下是实现DFA的步骤:
- 构建转换表:根据DFA的状态和输入字母表,创建一个二维数组
transition_table[state][input_symbol]。 - 初始化:设置当前状态为起始状态,并准备一个指向输入字符串的指针。
- 循环处理:遍历输入字符串中的每个字符,根据当前状态和当前输入字符,在转换表中查找下一个状态,并更新当前状态。
- 结束条件:当输入字符串处理完毕时,检查当前状态是否为接受状态,以决定是否识别出有效的词素。
让我们通过一个具体的DFA例子来演示如何构建转换表。
DFA转换表示例
假设我们有以下DFA(状态为 s, t, u,输入符号为 0 和 1):
- 在状态
s,输入0转移到t,输入1转移到u。 - 在状态
t,输入0保持在t,输入1转移到u。 - 在状态
u,输入0转移到t,输入1保持在u。
其转换表如下:
| 当前状态 | 输入 0 |
输入 1 |
|---|---|---|
| s | t | u |
| t | t | u |
| u | t | u |
在程序中,我们可以用以下伪代码来模拟这个DFA:
# 假设 transition_table 已按上述表格定义
current_state = ‘s‘
input_string = "0101"
i = 0
while i < len(input_string):
current_char = input_string[i]
# 根据当前状态和输入字符查找下一个状态
current_state = transition_table[current_state][current_char]
i += 1
# 循环结束后,检查 current_state 是否为接受状态...

这种方法非常高效,每个输入字符只需一次数组查找和索引运算。
DFA的优化:压缩转换表 🗜️
你可能已经注意到,在上面的转换表中,所有行都是相同的。我们可以通过一种不同的表示方法来节省空间。
我们可以使用一个一维数组,其中每个状态对应一个条目,该条目是一个指针,指向另一个一维数组(即该状态对应的转换向量)。这样,具有相同转换向量的状态可以共享同一行数据。
优化后的表示结构如下:
- 一个主表
state_vectors,索引是状态(如s,t,u),每个元素是一个指向转换向量的指针。 - 转换向量是一个小数组,按输入符号索引,存储下一个状态。
对于我们的示例DFA:
- 状态
s,t,u的指针都指向同一个转换向量[t, u]。
这种方法的优点是显著压缩了表格大小,特别是当DFA状态很多且转换模式重复时。缺点是内循环稍慢,因为需要多一次指针解引用操作。

直接实现NFA ⚙️
有时,将NFA转换为DFA会导致状态数量爆炸(理论上最多可达 2^n 个状态),使得转换表异常庞大。在这种情况下,直接实现NFA可能更节省空间。
NFA的实现也需要一个表格,但表格的每个单元格存储的是一个状态集合,因为对于同一个输入,NFA可能转移到多个状态,并且还需要处理 ε(空)转移。
以下是NFA表格的示例结构:
| 当前状态 | 输入 0 的转移集 |
输入 1 的转移集 |
ε 转移集 |
|---|---|---|---|
| A | {} | ||
| B | {} | {} | |
| C | {} | {} | |
| D | {} | {} |
模拟NFA的内循环会更复杂且更慢:
- 我们需要维护一个当前可能的状态集合。
- 对于每个输入字符,需要计算这个集合中每个状态在输入字符和 ε 转移下能到达的所有新状态的并集。
- 这个过程涉及到集合的查找、合并与闭包计算。
因此,NFA实现以更慢的执行速度为代价,换取了更紧凑的存储空间。

总结与权衡 ⚖️
本节课中,我们一起学习了实现有限自动机的几种方法:
- DFA表格驱动法:使用二维数组直接实现,执行速度最快,但转换表可能非常庞大。
- DFA压缩表法:通过共享相同的转换行来压缩表格,在空间效率和速度之间取得更好平衡。
- NFA直接实现法:直接模拟非确定性自动机,表格非常紧凑,但模拟过程更慢,因为需要处理状态集合。
在实践中,词法分析器生成工具(如Flex)会根据用户配置在速度与空间之间进行权衡。它们通常提供选项,允许开发者选择是生成更接近完整DFA的(更快但可能更大)实现,还是基于NFA的(更慢但更简洁)实现。

理解这些底层实现策略,有助于我们在需要手动优化或调试词法分析器时,做出更明智的选择。

编译原理课程 P17:从词法分析到语法分析 🚀
在本节课中,我们将要学习编译过程中的一个重要过渡:从词法分析阶段进入语法分析阶段。我们将探讨这两个阶段的关系,理解为什么需要语法分析,并初步了解语法分析器的工作。


正规语言的局限性
上一节我们介绍了正规语言及其在词法分析中的应用。本节中我们来看看正规语言的能力边界。

值得注意的是,正规语言是应用最广泛的形式语言中能力最弱的一类。尽管如此,它们有许多实际应用,我们在之前的视频中已经看到了一些例子。
现在,正规语言的困难在于,许多语言根本不符合其规则。有一些非常重要的语言无法使用正则表达式或有限自动机来表达。
让我们考虑这个语言:它是所有平衡括号的集合。该语言的一些元素将是字符串:
()(())((()))
每个字符串包含成对且正确嵌套的开括号和闭括号。你可以想象这实际上代表了编程语言中的许多结构。例如,任何类型的嵌套算术表达式都将属于此类。还有像嵌套的 if-then-else 语句也具有这个特征。在许多语言中,if 语句像开括号一样起作用,end 或 } 像闭括号一样起作用。因此,编程语言中有许多嵌套结构,而这些结构无法由正则表达式处理。
这提出了一个问题:正规语言可以表达什么?为什么它们不足以识别任意嵌套结构?
我们可以通过查看一个简单的两状态机,来阐明正规语言和有限自动机的局限性。
让我们考虑这个机器。我们有一个开始状态 S0,另一个状态 S1 是接受状态。这个机器将识别包含奇数个 1 的字符串。
以下是其状态转换规则:
S0 --1--> S1
S1 --1--> S0
如果我们看到一个 1 并且我们在开始状态 S0,我们移动到接受状态 S1。当我们看到另一个 1 时,我们从 S1 移回 S0。每当看到奇数个 1,我们在最终状态 S1;每当看到偶数个 1,我们在起始状态 S0。
如果给定一个较长的 1 串,例如包含 7 个 1,它会在这两个状态间来回切换。当到达最后一个 1 时,它将处于最终状态 S1,所以它会接受这个字符串。
但请注意,这个机器不知道它访问最终状态了多少次。它不记得字符串的长度,也无法计算字符串中有多少字符。实际上,它只能计算模 k 的奇偶性。总的来说,有限自动机只有有限个状态,所以它只能表达可以“模 k 计数”的属性,其中 k 是机器的状态数。
例如,如果有三个状态,机器可以跟踪字符串长度是否可被 3 整除。但它不能做像计数到任意高这样的事情。因此,如果需要识别需要任意高计数的语言,比如识别所有平衡括号的字符串,你无法用有限的状态集做到。
语法分析器的作用

既然正规语言能力不足,我们就需要更强大的工具来处理程序的结构。这就是语法分析器(解析器)登场的时候。

那么解析器做什么?它从词法分析器接收标记(Token)序列作为输入,并产生程序的解析树作为输出。
例如,在 Cool 语言中,假设有一个输入表达式:
if x then y else z
词法分析器以字符串字符作为输入,产生以下标记序列作为输出:
[IF, ID(x), THEN, ID(y), ELSE, ID(z)]
这个标记序列是解析器的输入。然后,解析器产生一个解析树,其中嵌套结构已被明确地组织起来。
解析树的结构可能如下(简化表示):
If-Then-Else
/ | \
Predicate Then-Branch Else-Branch
| | |
ID(x) ID(y) ID(z)

总结一下数据流:
- 词法分析器:输入是字符串(字符序列),输出是标记(Token)序列。
- 语法分析器:输入是标记序列,输出是解析树。
这里值得提几点:
- 有时解析树是隐式的。编译器可能永远不会构建一个完整的、独立的数据结构作为解析树,我们稍后会详细讨论。
- 许多编译器确实构建了显式的解析树,但也有很多没有。
- 有些编译器将词法分析和语法分析这两个阶段合并为一个,所有工作都由解析器完成。这是因为语法分析技术(如上下文无关文法)足够强大,可以同时表达词法规则和语法规则。
- 然而,大多数编译器仍按传统方式划分工作,因为正则表达式与词法分析的简单性、高效性非常匹配,分开处理可以使编译器设计更清晰、高效。
本节课总结
在本节课中,我们一起学习了:
- 正规语言的局限性:通过“平衡括号语言”的例子,我们了解到有限自动机由于状态有限,只能进行模
k计数,无法处理需要任意深度嵌套或任意高计数的语言结构,而这正是编程语言语法的核心特征。 - 语法分析器的角色:语法分析器接收词法分析器产生的标记流,其核心任务是识别程序的层次化、嵌套式语法结构,并输出解析树(或等价的结构)。
- 编译阶段的数据流:我们明确了从源代码字符串到最终解析树的完整过程:
字符流-> (词法分析器) ->标记流-> (语法分析器) ->解析树。

理解词法分析和语法分析的分工与协作,是构建编译器的重要基础。下一节,我们将开始深入探讨用于描述语法、驱动语法分析的核心工具——上下文无关文法。


课程 P18:上下文无关文法入门 🧩

在本节课中,我们将要学习上下文无关文法的基本概念。这是一种用于描述编程语言等具有递归结构的语言的形式化方法。我们将了解它的组成部分、工作原理,并通过几个简单的例子来加深理解。


概述
解析器需要区分有效的标记序列和无效的标记序列。为了描述有效的序列,我们需要一种形式化的方法。上下文无关文法正是描述编程语言中这种递归结构的自然工具。

什么是上下文无关文法?
正式地说,一个上下文无关文法由以下四个部分组成:
- 终结符集合 T:语言中不可再分的基本符号。
- 非终结符集合 N:代表语言结构的语法变量。
- 开始符号 S:一个特殊的非终结符,是推导的起点。
- 产生式集合 P:定义了如何将非终结符重写为符号串的规则。

一个产生式的形式是:
X → Y₁ Y₂ ... Yₙ
其中,X 必须是一个非终结符,而右侧的每个 Yᵢ 可以是终结符、非终结符,或者是表示空串的特殊符号 ε。
推导过程
上一节我们介绍了文法的组成部分,本节中我们来看看如何使用这些规则生成字符串。
推导从一个只包含开始符号 S 的字符串开始。然后,我们反复执行以下步骤:
- 在当前字符串中找到任意一个非终结符
X。 - 在文法中找到一个以
X为左侧的产生式X → Y₁ Y₂ ... Yₙ。 - 将字符串中的
X替换为右侧的符号串Y₁ Y₂ ... Yₙ。
重复此过程,直到字符串中不再包含任何非终结符。此时得到的终结符串就是该文法语言中的一个句子。

更形式化地,我们可以用 α ⇒ β 表示一步推导。如果存在一系列推导步骤 α₀ ⇒ α₁ ⇒ ... ⇒ αₙ,则记作 α₀ ⇒* αₙ,表示零步或多步推导。
文法语言的定义
让 G 是一个以 S 为开始符号的上下文无关文法。该文法生成的语言 L(G) 定义为所有满足以下条件的终结符串 w 的集合:
S ⇒* w
其中 w 仅由终结符组成。在编程语言的上下文中,这些终结符通常就是词法分析器生成的标记。

实例解析
以下是几个上下文无关文法的具体例子。

例1:平衡括号字符串

这是一个非常经典的例子。文法可以定义如下:
S → ( S )
S → ε
- 非终结符:
S - 终结符:
(和) - 开始符号:
S
这个文法描述了所有像 ()、(())、((())) 这样成对出现的括号字符串。
例2:Cool语言表达式片段

假设我们要为Cool语言的一部分表达式定义文法。以下是一种可能的写法:
Expr → if Expr then Expr else Expr fi
Expr → while Expr loop Expr pool
Expr → id
这里我们采用了一种简写惯例:将具有相同左侧非终结符的多个产生式写在一起,用 | 分隔。因此,上面的写法等价于:
Expr → if Expr then Expr else Expr fi
Expr → while Expr loop Expr pool
Expr → id
- 非终结符:
Expr - 终结符:
if,then,else,fi,while,loop,pool,id - 开始符号:
Expr
这个文法可以生成诸如 id(一个变量名)、if id then id else id fi 或更复杂的嵌套结构(如 while if id then id else id fi loop id pool)等有效的Cool表达式。
例3:简单算术表达式

让我们定义一个支持加法、乘法和括号的算术表达式文法:
E → E + E
E → E * E
E → ( E )
E → id
- 非终结符:
E - 终结符:
+,*,(,),id - 开始符号:
E
这个文法可以生成像 id、id + id、id * id、id + id * id 以及 (id + id) * id 这样的表达式。
从文法到解析器

上下文无关文法为我们描述语言结构提供了强大的工具,但仅靠它还不够。要构建一个实用的解析器,我们还需要解决以下问题:
- 构建解析树:文法只能判断一个字符串是否合法。我们还需要知道它如何合法,即构建出反映其语法结构的解析树。
- 错误处理:当输入不合法时,解析器必须能优雅地报告错误,给出有意义的反馈信息。
- 高效实现:我们需要具体的算法(如下节课将介绍的自顶向下或自底向上解析算法)来实现上述功能。

此外,一个重要的实践要点是:文法的书写形式会影响解析器的实现。对于同一个语言,可能存在多种不同的文法描述。然而,特定的解析算法或工具可能只接受其中某些特定形式的文法(例如,消除左递归后的文法)。因此,在实际中,我们经常需要为了适配工具而调整文法的写法。
总结

本节课中我们一起学习了上下文无关文法的核心概念。我们了解到,它通过终结符、非终结符、开始符号和产生式来形式化地定义一种语言。通过推导过程,可以从开始符号生成语言中的所有句子。我们通过平衡括号、Cool表达式片段和算术表达式三个例子实践了文法的设计与理解。最后,我们指出了文法与实际构建解析器之间的桥梁,即我们还需要算法来构建解析树和处理错误,并且文法的具体形式对解析工具至关重要。

编译原理 P19:推导与解析树 🌳

在本节课中,我们将要学习上下文无关文法中的推导概念,并了解如何将推导过程可视化为解析树。我们将通过一个具体的例子,详细展示从开始符号到目标字符串的推导步骤,以及如何同步构建对应的解析树。


从推导到解析树
上一节我们介绍了上下文无关文法的基本概念,本节中我们来看看如何通过应用产生式规则来生成字符串,并构建解析树。

推导是指从文法的开始符号出发,反复使用产生式规则,将非终结符替换为终结符或其它符号序列,最终得到目标字符串的过程。这个过程可以线性地记录,也可以绘制成一棵树。
以下是构建解析树的核心规则:
- 当推导中出现非终结符
X时,我们应用一条以X为左部的产生式规则。 - 假设应用的规则是
X -> Y1 Y2 ... Yn,那么在树中,我们将Y1, Y2, ..., Yn作为节点X的子节点加入。
示例:算术表达式的推导与解析
让我们通过一个具体的例子来理解这个过程。考虑一个简单的算术表达式文法,其产生式如下:
E -> E + E
E -> E * E
E -> id
我们的目标字符串是 id * id + id。我们将展示如何为该字符串生成推导,并同步构建解析树。
下图展示了从开始符号 E 到目标字符串的推导过程,以及每一步对应的解析树构建状态:


推导步骤详解:

- 初始状态:推导式仅包含开始符号
E,解析树只有一个根节点E。 - 第一步:应用产生式
E -> E + E。在树中,我们为根节点E添加三个子节点:E、+和E。 - 第二步:应用产生式
E -> E * E替换最左边的E。在树中,我们为最左边的E节点添加三个子节点:E、*和E。 - 第三步:应用产生式
E -> id替换当前最左边的E。在树中,我们将id作为该E节点的子节点。 - 第四步:应用产生式
E -> id替换中间的E(即*右边的E)。 - 第五步:应用产生式
E -> id替换最右边的E(即+右边的E)。
至此,推导完成,我们得到了目标字符串 id * id + id,同时也完成了该表达式的解析树构建。


解析树的特性
解析树以一种清晰的结构化方式展示了推导过程。它具有以下几个重要特性:
以下是解析树的三个关键特性:
- 叶子节点是终结符:解析树的所有叶子节点都是文法中的终结符(如
id,+,*)。 - 内部节点是非终结符:解析树的所有内部节点都是文法中的非终结符(在本例中全是
E)。 - 叶子节点的遍历结果就是原始输入:按照从左到右的顺序读取解析树的所有叶子节点,得到的就是我们最初要解析的字符串。
让我们回到之前的例子进行验证。观察最终构建的解析树,可以看到所有叶子节点(id, *, id, +, id)都是终结符,所有内部节点都是非终结符 E。按顺序读取叶子节点,恰好得到 id * id + id。

此外,解析树还隐含了运算的优先级和结合性。例如,在我们的解析树中,乘法运算 E * E 是加法运算 E + E 的子树,这意味着乘法比加法具有更高的优先级,计算时会先执行乘法。
最左推导与最右推导

在之前的示例推导中,我们采用了一种特定的顺序:每一步都替换当前字符串中最左边的非终结符。这种推导称为最左推导。
自然地,也存在最右推导的概念,即每一步都替换当前字符串中最右边的非终结符。对于同一个字符串和同一棵解析树,可以分别写出它的最左推导和最右推导。
下图展示了针对同一字符串 id * id + id 和同一棵解析树,其最右推导的构建过程:


最右推导步骤简述:
- 从
E开始,应用E -> E + E(替换最右边的E?此处初始只有一个E,故替换它)。 - 替换右边新引入的
E为id(E -> id)。 - 替换左边剩下的
E为E * E(E -> E * E)。 - 替换这个新子树中最右边的
E为id。 - 最后替换剩下的
E为id。
需要指出的是,同一个解析树既对应一个最左推导,也对应一个最右推导。它们只是构建树的分支顺序不同。除了最左和最右推导,实际上还存在许多其他可能的推导顺序(例如随机选择非终结符进行替换),但在编译理论中,我们最关注的是最左推导和最右推导。

总结
本节课中我们一起学习了上下文无关文法中的核心概念——推导与解析树。
- 推导是应用产生式规则生成字符串的过程。
- 解析树是推导过程的图形化表示,它能清晰地展示语法结构、运算优先级和结合性。
- 每个解析树都对应着多个推导,其中最左推导和最右推导是两种最重要、最规范的推导形式。

理解推导和解析树是后续学习语法分析算法的基础。

编译器结构概述(P2)📚


在本节课中,我们将学习编译器的基本结构。编译器是一个将高级编程语言(如C、Java)转换为计算机能执行的机器代码或低级代码的程序。理解其结构是学习编译原理的第一步。

我们将概述编译器的五个主要阶段,并通过与人类理解英语的类比,帮助初学者直观地理解每个阶段的作用。

词法分析:识别单词 🔤

上一节我们介绍了编译器的整体框架,本节中我们来看看第一个阶段:词法分析。它的目标是将程序文本分解成有意义的“单词”,在编译器中称为“标记”。
人类阅读英语时,能自动识别单词间的分隔符(如空格和标点)。编译器进行词法分析时,做的也是类似的工作:扫描源代码,识别出关键字、变量名、常数、运算符和分隔符等基本单元。
以下是程序文本示例及其标记识别过程:
if (x == y) then z = 1; else z = 2;
- 关键字:
if,then,else - 变量名:
x,y,z - 常数:
1,2 - 运算符:
==(双等号),=(赋值符) - 标点/分隔符:
(,),;, 空格

一个有趣的问题是,词法分析器如何知道==是一个整体(比较运算符),而不是两个单独的=(赋值符)?这将在后续关于词法分析实现的课程中详细讨论。

语法分析:理解句子结构 🌳
一旦识别出单词,下一步就是理解句子的结构,这个过程称为语法分析或解析。就像在语文课上分析句子成分一样,编译器需要理解程序中各种结构的组合关系。

解析的结果通常表示为一棵树,称为“语法分析树”或“抽象语法树”。这棵树展示了程序如何由更小的部件层层嵌套组成。
以我们的示例代码为例,其解析树的根节点是一个if-then-else语句。这个语句可以分解为三部分:
- 谓词(条件):
(x == y),它本身由变量x、运算符==和变量y组成。 - then分支:
z = 1,是一个赋值语句。 - else分支:
z = 2,也是一个赋值语句。
这棵树清晰地展示了代码的层次化结构。

语义分析:检查含义一致性 🧐

理解了句子结构后,我们需要尝试理解其含义。对于编译器来说,完全的“理解”过于困难,因此语义分析主要专注于检查程序中的不一致性和错误。
这类似于在英语中处理指代歧义。例如,句子“Jack说Jerry把他的作业忘在家里了”中的“他的”指的是Jack还是Jerry?在没有上下文的情况下,这是模糊的。
在编程语言中,一个核心的语义分析任务是变量绑定,即确定一个变量名具体指向哪个声明。现代编程语言通过严格的规则(如词法作用域)来消除这种歧义。

此外,编译器还会进行类型检查等语义分析。就像知道“Jack(他)把作业忘在家里了”中的“Jack”和“她”类型不匹配一样,编译器会检查变量和操作的数据类型是否一致。

优化:提升程序效率 ⚡
优化阶段在人类语言中没有直接的对应,但可以类比为编辑对文章进行精简和润色,使其更简洁、高效,同时不改变原意。

程序优化的目标是修改程序,以减少其对各种资源的使用,例如:
- 运行时间:让程序执行更快。
- 内存空间:减少程序占用的内存。
- 功耗:降低移动设备的能耗。
- 网络通信:减少发送的消息数量。
然而,优化必须谨慎进行。例如,一个看似正确的优化规则 X = Y * 0 可以优化为 X = 0,这对于整数是成立的,但对于浮点数则可能出错(因为根据IEEE标准,NaN * 0 的结果是 NaN,而非 0)。编译器必须精确地知道何时能安全地应用某种优化。

代码生成:翻译为目标语言 🌐
编译器的最后阶段是代码生成,通常是将高级程序翻译成汇编语言或机器码。这完全类似于人类将一种语言翻译成另一种语言。

这个阶段负责产出最终可被计算机硬件或虚拟机直接执行的低级代码。

总结 📝
本节课中我们一起学习了编译器的五个核心阶段:
- 词法分析:将源代码拆分为标记(单词)。
- 语法分析:根据语法规则构建解析树,理解程序结构。
- 语义分析:进行一致性检查(如变量绑定、类型检查)。
- 优化:改进程序性能,减少资源消耗。
- 代码生成:翻译成目标低级代码。
值得注意的是,随着编译器技术的发展,各阶段的复杂性和比重已发生变化。早期编译器(如FORTRAN 1)各阶段复杂度分布较均匀;而现代编译器的核心复杂性集中在语义分析和优化这两个阶段,词法分析和语法分析因有成熟工具而变得简单,代码生成也因技术成熟而相对稳定。

在后续的课程中,我们将对每个阶段进行深入详细的研究。


课程 P20:歧义与消歧 📚


在本节课中,我们将要学习编程语言文法中的歧义问题。我们将了解什么是歧义文法,为什么它在编程语言设计中是不受欢迎的,以及如何通过重写文法或使用消歧声明来解决这个问题。
什么是歧义文法?🤔

上一节我们介绍了课程主题,本节中我们来看看歧义的具体定义。
如果一个文法对于某些字符串能生成多个不同的解析树,那么它就是歧义的。另一种说法是,对于某些字符串,存在多个最左推导或最右推导。

例如,考虑一个简单的表达式文法,它包含加法和乘法操作符以及标识符(id)。对于字符串 id * id + id,该文法可以生成两个完全不同的解析树。
以下是两个可能的解析树推导过程:
-
解析树 A(先做加法):
- 从开始符号
E开始。 - 使用产生式
E -> E + E。 - 将最左边的
E替换为E * E(使用E -> E * E)。 - 最后,将所有的
E推导为id。
- 从开始符号
-
解析树 B(先做乘法):
- 从开始符号
E开始。 - 使用产生式
E -> E * E。 - 将最右边的
E替换为E + E(使用E -> E + E)。 - 最后,将所有的
E推导为id。
- 从开始符号
这两个推导过程产生了结构不同的解析树,这意味着同一个程序 id * id + id 有两种可能的解释。这会让编译器困惑,因为它不知道应该按照哪种解释来生成代码。因此,歧义在编程语言中是需要避免的。
方法一:重写文法以消除歧义 ✍️
消除歧义最直接的方法是重写文法,使其生成相同的语言,但每个字符串只对应唯一的解析树。
表达式文法的重写
我们之前有歧义的表达式文法可以重写如下:
E -> E + T | T
T -> T * F | F
F -> id | ( E )

在这个新文法中:
E(表达式) 负责生成加法。T(项) 负责生成乘法。F(因子) 负责生成标识符和括号表达式。
让我们看看新文法如何唯一地解析 id * id + id:
- 从
E开始。 - 为了生成加号
+,必须使用产生式E -> E + T。 - 左边的
E必须最终推导为T,而T为了生成乘号*,必须使用T -> T * F。 - 这样,乘法操作
*就被嵌套在加法操作+的解析树内部。

核心机制:通过分层非终结符(E, T, F),文法强制规定了操作符的优先级。乘法(由 T 控制)比加法(由 E 控制)绑定得更紧密,且都在括号(由 F 控制)之内。括号内的 E 允许重新开始整个优先级链条。
通过这种重写,原来有歧义的字符串 id * id + id 现在只对应一个解析树,其中乘法优先于加法计算。


方法二:使用消歧声明 🛠️
重写文法虽然有效,但有时会使文法变得复杂且不直观(例如 if-then-else 语句的无歧义文法就非常复杂)。因此,许多实用的解析器生成工具采用了另一种方法:允许使用有歧义但更自然的文法,同时提供消歧声明。
最常见的消歧声明是优先级和结合性声明。
结合性声明
即使只有一个操作符(如加法),也可能存在歧义。例如文法 E -> E + E | id 对于 id + id + id 是歧义的,因为它没有指明加法是左结合还是右结合。
在工具(如Bison)中,可以这样声明:
%left ‘+’
这声明了 + 是左结合的,从而排除了右结合的解析树,确保了表达式 a + b + c 被解释为 (a + b) + c。

优先级声明
对于包含多个操作符的文法(如加法和乘法),可以通过声明顺序来指定优先级。

%left ‘+’
%left ‘*’
在Bison中,后声明的操作符具有更高的优先级。因此,* 的优先级高于 +,这确保了乘法在加法之前计算。同时,它们都被声明为左结合。


重要提示:这些声明在解析器内部的实际工作原理并非直接“理解”优先级,而是指导解析器在遇到冲突时做出特定的移进/归约决策。在大多数情况下,它们的行为符合直觉,但在某些边缘情况下可能需要特别注意。

总结 📝

本节课中我们一起学习了编程语言文法中的歧义问题。
- 歧义的定义:如果一个文法能为某些字符串生成多个解析树,则该文法是有歧义的。这会导致程序含义不明确。
- 消除歧义的方法一:重写文法,通过引入分层级的非终结符(如
E,T,F)来强制规定操作符的优先级和结合性,从而保证每个字符串只有唯一解析树。 - 消除歧义的方法二:使用消歧声明,在解析器生成工具中,允许使用更简洁但有歧义的文法,同时通过
%left、%right等声明来指定操作符的结合性和优先级,让工具自动处理歧义。

理解并解决文法的歧义,是设计编译器前端和确保编程语言行为一致性的关键一步。

课程 P21:编译器错误处理 🛠️

在本节课中,我们将学习编译器如何处理程序中的错误。我们将探讨错误处理的基本要求,并详细介绍三种主要的错误处理策略:恐慌模式、错误产生和错误纠正。通过理解这些机制,你将明白编译器如何尝试从错误中恢复,以及为何现代编译器采用当前的设计。
概述 📋

编译器有两个相对独立的工作。第一个是将有效程序翻译成目标代码。第二个工作是处理无效程序,即检测错误并向程序员提供良好的反馈。

编程语言中存在多种类型的错误。例如,词法错误由词法分析阶段检测。语法错误(即解析错误)在词法单元正确但组合方式无意义时发生。语义错误(如类型不匹配)则由类型检查器捕获。此外,程序中还存在逻辑错误,这些是有效程序但未按预期执行,编译器通常无法检测这类错误。
良好的错误处理要求编译器准确清晰地报告错误,以便快速识别和修复问题。编译器本身应能迅速从错误中恢复。同时,错误处理不应减慢有效代码的编译速度。

三种错误处理策略
上一节我们介绍了错误处理的基本要求,本节中我们来看看三种具体的错误处理策略。
1. 恐慌模式 (Panic Mode)
恐慌模式是最简单且最流行的错误恢复方法。其基本思想是:当检测到错误时,解析器开始丢弃输入标记,直到找到一个在语言中有明确角色的标记(称为同步标记),然后尝试从该点重新开始解析。
一种典型策略是尝试跳至当前语句或函数的末尾,然后开始解析下一个语句或函数。
以下是恐慌模式的一个简单示例。考虑表达式 (1 ++ 2)。解析器从左到右解析,遇到第二个加号时,发现语法错误(两个加号连续出现)。在恐慌模式下,解析器会丢弃输入直到找到下一个整数(例如数字2),然后将表达式视为 (1 + 2) 继续解析。

在Bison等解析器生成器中,可以使用特殊的终结符 error 来定义错误恢复行为。例如,一个产生式可以定义为:
e : INT
| e '+' e
| '(' e ')'
| error INT // 遇到错误后,跳过输入直到遇到一个INT,然后将其视为一个e
| error ')' // 遇到错误后,跳过输入直到遇到一个')',然后重置状态
2. 错误产生 (Error Productions)
错误产生是指将程序员常犯的已知错误,作为备选产生式直接添加到语法中。

例如,假设数学家习惯将 5 x(中间有空格)表示乘法,而不是 5*x。编译器可以为此添加一个产生式,使 5 x 成为合法的表达式。虽然这会使语法变得复杂且难以维护,但在实践中确有使用。例如,GCC等编译器有时会警告某些写法,但仍会接受它们,其内部机制就类似于错误产生。
3. 错误纠正 (Error Correction)
错误纠正不仅检测错误,还尝试自动修复错误。其目标是找到一个与原始程序“接近”且能正确编译的程序。常用方法包括尝试插入或删除标记,以最小化编辑距离,并在一定范围内进行穷举搜索。
这种方法实现复杂,会减慢正确程序的解析速度,且“接近”的定义并不明确。历史上著名的例子是PLC编译器,它愿意尝试编译任何输入(甚至包括《哈姆雷特》独白),通过大量错误纠正最终生成一个可运行的PL/I程序。
在20世纪70年代,编译周期非常漫长(可能长达一天),因此自动纠正小错误以节省时间是有价值的。然而,现代开发环境拥有极快的交互式编译周期,程序员通常倾向于一次只修正一个错误(通常是第一个报告的错误),因此复杂的错误纠正机制在今天已不那么有吸引力。

总结 🎯
本节课我们一起学习了编译器错误处理的三种主要策略。

- 恐慌模式通过丢弃输入直到同步标记来实现快速恢复。
- 错误产生通过将常见错误模式合法化来提供更灵活的处理。
- 错误纠正尝试自动修复错误,但在现代快速编译环境下实用性降低。

理解这些策略有助于我们认识编译器设计的权衡,以及为何现代工具更侧重于快速、准确的错误报告,而非复杂的自动修复。

编译器原理 P22:抽象语法树 (AST) 🌳
在本节课中,我们将要学习编译器中的一个核心数据结构——抽象语法树。我们将了解它是什么,为什么它比解析树更适合编译器使用,以及它是如何从解析树中“抽象”出来的。

从解析到程序表示 🔄
上一节我们介绍了解析器如何跟踪标记序列的推导。但仅凭推导过程本身,对编译器的后续阶段并不十分有用。编译器的其余部分需要程序的某种表示,它需要一个实际的数据结构来告诉它程序中的操作是什么,以及它们是如何组合在一起的。
我们知道有一种这样的数据结构叫做解析树。但结果表明,解析树并非我们想要处理的。相反,我们想处理一种称为抽象语法树的东西。抽象语法树实际上就是解析树,但忽略了一些细节,我们从解析树的细节中抽象出来。
这里有一个你会看到的缩写:AST 代表抽象语法树。

解析树回顾 📊
让我们通过一个具体的语法例子来看。以下是用于整数加法表达式的语法,它还允许用括号括起表达式:
E -> E + E
E -> ( E )
E -> num
这是一个字符串 (5 + (2 + 3))。词法分析后,我们得到一个标记序列,带有相关的词素,告诉我们实际的字符串是什么。然后这个序列被传递给解析器,解析器会构建一个解析树。

以下是该表达式的解析树:
E
/|\
/ | \
( E )
/|\
/ | \
E + E
| /|\
5 ( E )
/|\
/ | \
E + E
| |
2 3
解析树实际上完全适合编译,我们可以使用解析树来做编译器的工作,因为它是程序的真实表示。但问题在于,那样做会很不方便。
为了看到这一点,让我指出解析树的一些特征。
以下是解析树的一些不便之处:
- 解析树很冗长。例如,我们这里有节点
E,它只有一个子节点(如数字5)。当节点只有一个继承者时,这对我们有何作用?我们实际上不需要这个E节点,我们可以直接将5放在其父节点的位置,使树变得更紧凑。 - 类似地,其他单一继承者节点(如某些括号节点)也是多余的。
- 此外,这些括号在解析中非常重要,因为它们显示了参数与加法操作的关系,表明了运算的嵌套关系。但一旦我们完成了解析,树结构本身就已经告诉了我们相同的事情。我们不再需要知道“这两个表达式在括号内”这个事实,因为加法节点的参数关系已经包含了所有必要信息。
因此,所有这些节点在某种意义上都是多余的,我们不再需要这些信息了。
抽象语法树 (AST) 的优势 ✨
因此,我们更倾向于使用一种称为抽象语法树的工具,它压缩了解析树中所有“多余”的部分。

这是一个抽象语法树,它代表了与上一节中解析树相同的内容 (5 + (2 + 3)):
+
/ \
5 +
/ \
2 3
你可以看到,我们真的只保留了最核心的项目。我们有两个加法节点和三个数字参数。运算的关联性仅由哪个加法节点嵌套在另一个中来显示。我们没有多余的非终结符节点,也没有括号节点,一切都简单多了。
你可以想象,编写算法来遍历这样的结构,比遍历之前那个复杂冗长的解析树要容易得多。
当然,它之所以被称为抽象语法树,正是因为它从具体语法中抽象了出来。我们抑制了具体语法的细节(如括号、单继承节点),只保留了足够的信息,以便能够忠实地代表程序并编译它。
总结 📝

本节课中,我们一起学习了抽象语法树 (AST) 的概念。我们了解到,虽然解析树是语法分析的自然产物,但它包含了过多用于推导过程的细节。抽象语法树通过移除这些冗余信息(如单继承节点、括号标记),提供了一个更简洁、更易于后续编译阶段(如语义分析、代码生成)处理的程序表示。AST 的核心在于从具体语法中“抽象”出程序的逻辑结构。

课程 P23:递归下降解析算法 🧠


在本节课中,我们将要学习第一个解析算法——递归下降解析。这是一种自顶向下的解析方法,我们将通过一个简单的例子来理解其工作原理。
什么是递归下降解析? 📝

递归下降是一种自顶向下的解析算法。你可能怀疑也有自底向上的解析算法,确实存在这样的算法,稍后我们会讨论。
在自顶向下解析算法中,解析树从顶部构建,从根节点开始,从左到右。因此,终端符号将按照它们在标记流中出现的顺序出现。
例如,假设有一个标记流和一个假设的解析树,解析树中节点的数字编号对应于节点构建的顺序。我们必须从根节点开始,这是首先发生的事情。然后,如果下一个位置是非终结符,那将是接下来要处理的部分。如果它有子节点,那么最左边的子节点(因为我们从左到右工作)将是下一个要生成的节点。以此类推,解析过程将按照从左到右的顺序逐步展开。

解析过程示例 🔍
让我们考虑一个整数表达式的语法,并解析一个简单的输入字符串 (5)。
我们将使用递归下降策略来解析这个输入字符串。基本思想是,我们从非终结符开始,从根节点开始,总是按顺序尝试非终结符的规则。我们将首先尝试第一个产生式,如果不起作用,再尝试下一个产生式。这是一个自顶向下的算法,从根开始,从左到右工作。当产生式失败时,我们可能需要回溯以尝试其他产生式。
解析过程涉及三个部分:
- 我们使用的语法。
- 我们构建的解析树(最初只是解析树的根)。
- 我们处理的输入,用一个指针指示我们在输入中的位置。
指针始终指向要读取的下一个终结符号(标记)。我们从输入 ( 开始。
以下是解析步骤的详细说明:
- 从语法开始,尝试第一个产生式
E -> T。这意味着将T作为E的子节点。 T是一个未展开的非终结符,我们必须处理它。尝试T的第一个产生式T -> int,使int成为T的子节点。- 现在我们可以检查进展。生成的终结符
int与输入中的(不匹配,因此这个解析路径失败,需要回溯。 - 撤销上一步的决定,尝试
T的下一个产生式T -> N * T。扩展T后,生成的int标记与输入中的(仍然不匹配,再次回溯。 - 撤销决定,尝试
T的最后一个产生式T -> (E)。扩展T后,生成的(与输入中的(匹配。匹配成功,将输入指针前进。 - 现在需要扩展非终结符
E。再次从第一个产生式开始,E -> T。 - 处理
T,选择第一个产生式T -> int。生成的int与输入中的5匹配,将输入指针前进。 - 继续处理,生成的
)与输入中的)匹配。 - 此时,解析树中的所有内容都已匹配,输入指针到达字符串末尾。这意味着对输入表达式
(5)的解析成功。
总结 📚

本节课中我们一起学习了递归下降解析算法。这是一种自顶向下的解析方法,通过从根节点开始,从左到右构建解析树,并按照顺序尝试语法规则来解析输入字符串。当产生式失败时,算法会进行回溯以尝试其他可能性。我们通过一个简单的例子 (5) 演示了整个解析过程,最终成功接受了该输入字符串。

课程 P24:递归下降解析算法详解 🧠

在本节课中,我们将学习递归下降解析的通用算法。这是一种用于解析上下文无关文法的常见方法,通过编写一系列相互递归的函数来模拟语法的产生式,从而判断输入字符串是否符合给定的语法规则。
算法概述与准备工作 📋

在深入递归下降解析算法的细节之前,我们先定义一些将在整个视频中使用的概念和变量。
首先,我们需要一个表示标记的类型。标记是输入字符串中被识别出的最小语法单位。
// 标记是一个类型,例如 int, plus, open, close 等
typedef enum { INT, PLUS, TIMES, OPEN, CLOSE } Token;
其次,我们需要一个全局变量 next,它作为一个指针,始终指向输入字符串中下一个待处理的标记。这类似于上一视频中使用的“大箭头”,用于指示解析的当前位置。
Token* next; // 指向输入标记流中下一个标记的指针
核心函数定义 ⚙️

递归下降解析器的实现依赖于几类核心函数。上一节我们介绍了标记和指针的概念,本节中我们来看看如何定义匹配标记和产生式的函数。
匹配单个标记的函数
我们需要一个函数来检查输入流中的当前标记是否与期望的标记匹配。
bool term(Token tk) {
// 检查传入的标记 tk 是否与 next 指向的当前标记匹配
if (tk == *next) {
next++; // 匹配成功,指针前进
return true;
} else {
next++; // 即使匹配失败,指针也前进(根据算法描述)
return false;
}
}
请注意,无论匹配成功与否,next 指针都会被递增。
匹配特定产生式的函数
对于语法中的每个非终结符 S 及其每个产生式,我们需要一个函数 S_n 来检查该特定产生式是否能匹配输入。
例如,对于非终结符 E 的第一个产生式 E -> T,其对应的函数 E1 可以这样实现:
bool E1() {
// E -> T 产生式:仅当 T 能匹配输入时成功
return T(); // 调用尝试所有 T 产生式的函数
}
尝试所有产生式的函数
对于每个非终结符 S,我们还需要一个主函数 S()。这个函数会按顺序尝试 S 的所有产生式(即调用 S_1, S_2, ...),直到有一个成功或全部失败。
bool E() {
Token* save = next; // 保存当前指针位置,用于回溯
// 尝试 E 的第一个产生式
if (E1()) {
return true; // 成功则直接返回
}
next = save; // 失败则恢复指针,尝试下一个产生式
// 尝试 E 的第二个产生式
if (E2()) {
return true;
}
next = save; // 所有产生式都失败,恢复指针并返回失败
return false;
}
这个函数体现了回溯机制:在尝试每个备选产生式前保存状态,失败后恢复状态。
完整语法示例解析 🌳
现在,让我们将上述概念应用到一个具体的语法上。我们使用与上一视频相同的表达式语法:
E -> T
E -> T + E
T -> int
T -> int * T
T -> ( E )
上一节我们定义了核心函数,本节中我们来看看如何为这个语法编写完整的解析器代码。

以下是每个函数的具体实现:
// 匹配 E -> T
bool E1() {
return T();
}
// 匹配 E -> T + E
bool E2() {
Token* save = next;
if (T() && term(PLUS) && E()) {
return true;
}
next = save;
return false;
}
// 主函数 E
bool E() {
Token* save = next;
if (E1()) return true;
next = save;
if (E2()) return true;
next = save;
return false;
}
// 匹配 T -> int
bool T1() {
return term(INT);
}

// 匹配 T -> int * T
bool T2() {
Token* save = next;
if (term(INT) && term(TIMES) && T()) {
return true;
}
next = save;
return false;
}
// 匹配 T -> ( E )
bool T3() {
Token* save = next;
if (term(OPEN) && E() && term(CLOSE)) {
return true;
}
next = save;
return false;
}

// 主函数 T
bool T() {
Token* save = next;
if (T1()) return true;
next = save;
if (T2()) return true;
next = save;
if (T3()) return true;
next = save;
return false;
}
要启动解析器,我们需要初始化 next 指针,并调用开始符号对应的函数。
bool parse(Token* input) {
next = input; // 初始化指针,指向输入的第一个标记
return E(); // 从开始符号 E 开始尝试匹配
}
算法执行流程示例 🔍
让我们通过一个具体例子来理解解析器是如何工作的。假设输入字符串是 ( int )。
解析步骤如下:
- 初始化:
next指向(。 - 调用
parse(),进而调用E()。 E()首先尝试E1(),即调用T()。T()按顺序尝试其产生式:T1()尝试匹配int,但当前是(,失败并回溯。T2()尝试匹配int * T,第一个标记int就不匹配(,失败并回溯。T3()尝试匹配( E ):term(OPEN)成功匹配(,next前进指向int。- 然后调用
E()来匹配E。
- 在新的
E()调用中:- 尝试
E1(),即调用T()。 T()中T1()成功匹配int,next前进(此时已到输入末尾)。T()返回成功,因此E1()成功,新的E()调用返回成功。
- 尝试
- 回到
T3()的检查中,接下来term(CLOSE)尝试匹配)。但next已指向输入末尾,没有标记了,因此匹配失败。 T3()失败,T()返回失败,导致最初的E1()失败。- 最初的
E()尝试E2()。 E2()首先调用T()。T()会再次从T1()开始尝试,但此时next已被恢复回指向(,过程将重复,最终仍无法成功匹配T + E的形式。- 最初的
E()中所有产生式尝试失败,返回false。
因此,输入 ( int ) 不符合该语法(因为它缺少右边的闭合括号)。如果输入是 ( int ),则 T3() 中的 term(CLOSE) 将会成功,整个解析就会成功。
总结 📝
本节课中我们一起学习了递归下降解析算法。我们首先定义了标记类型和全局指针 next。然后,我们介绍了三类核心函数:匹配终结符的 term 函数、匹配特定产生式的函数(如 E1)以及整合所有产生式并处理回溯的主函数(如 E)。

我们通过一个具体的表达式语法,逐步实现了所有解析函数,并分析了算法的执行流程。递归下降解析器结构清晰,易于手工实现,是理解编译器前端工作的良好起点。其核心思想在于用递归函数调用模拟语法推导,并通过保存和恢复 next 指针的状态来实现回溯,以探索所有可能的解析路径。
课程 P25:递归下降解析器的局限性 🧐

在本节课中,我们将探讨递归下降解析算法的一个关键局限性。我们将通过具体的例子,分析为何一个看似简单的解析器在处理某些输入时会失败,并理解其背后的原因。

概述
递归下降是一种常见的解析技术,但并非所有实现都能处理所有语法。本节将展示一个特定实现的局限性,即它无法在非终结符的多个可能产生式之间进行回溯。
回顾上次的解析器实现
上一节我们介绍了如何为一组相互递归的函数实现语法解析。考虑解析输入 int 时,我们实现了一个简单的递归下降解析器。
以下是解析非终结符 e 的核心逻辑示意:
def parse_e():
return parse_e1()
def parse_e1():
return parse_t()
def parse_t():
return parse_t1()
def parse_t1():
# 尝试匹配终端符号 ‘int’
if current_token == ‘int’:
consume_token()
return True
return False
对于输入字符串 int,解析过程如下:
parse_e调用parse_e1。parse_e1调用parse_t。parse_t调用parse_t1。parse_t1成功匹配int,消耗输入指针并返回True。- 调用链逐级返回
True,解析成功。
解析更复杂的输入时遇到的问题
现在,让我们考虑一个稍微复杂点的例子:输入字符串 int times int。
解析过程再次从 parse_e 开始:
parse_e调用parse_e1。parse_e1调用parse_t。parse_t调用parse_t1。parse_t1成功匹配第一个int,消耗它并返回True。parse_t因此返回True,parse_e1返回True,最终parse_e返回True。
此时,输入指针仅位于第一个 int 之后,整个字符串 int times int 并未被完全消耗。解析器认为解析成功,但实际上它只解析了输入的一部分,导致整体解析失败。
问题在于,当 parse_t 通过 parse_t1 成功匹配了第一个 int 后,它就立即返回了。解析器没有机会“回溯”去尝试 t 的其他产生式(例如,一个能匹配 int times t 的产生式),即使后续的整体解析因此失败。
核心局限性:缺乏回溯机制
上述例子揭示了该算法的根本问题。
核心概念可以表述为:一旦一个非终结符 X 的某个产生式成功匹配并返回,算法就无法回溯去尝试 X 的其他不同产生式。
这意味着,我上次展示的这种特定递归下降算法并非完全通用的。一个通用的递归下降解析器需要更复杂的回溯机制来处理任何语法。
该算法的适用场景
虽然存在局限性,但这种算法因其简单性仍有其价值。它易于手工实现,并且适用于一大类语法。
具体来说,它适用于任何满足以下条件的语法:对于每个非终结符,在解析的任何可能情况下,最多只有一个产生式能够成功。
在这种情况下,一旦找到一个成功的产生式,就永远不需要回溯重选,因为其他所有产生式注定会失败。我们示例中的语法可以通过改写(例如,进行左因子化)来满足这个条件,从而与此算法兼容。
总结

本节课中我们一起学习了递归下降解析器的一个关键局限性:它缺乏在非终结符级别进行回溯的能力。这导致它在处理某些歧义或需要尝试多个选项的语法时会失败。我们了解到,虽然这个简化版本算法有其适用范围(即每个非终结符最多只有一个产生式可能成功),但一个完全通用的递归下降解析器需要实现完整的回溯机制。
课程 P26:左递归问题与消除方法 🧠

在本节课中,我们将要学习递归下降解析中的一个主要困难——左递归。我们将了解什么是左递归,为什么它会导致解析器陷入无限循环,以及如何通过将左递归文法转换为等价的右递归文法来解决这个问题。


递归下降解析的主要困难
上一节我们介绍了递归下降解析的基本概念,本节中我们来看看它面临的一个主要挑战。
递归下降解析器在解析某些文法时会遇到一个严重问题,这个问题被称为左递归。
什么是左递归?🤔
考虑一个仅含一个产生式的简单文法:
S -> S a
该文法的递归下降算法实现如下:
def s():
return s1()
def s1():
if s() and match('a'):
return True
return False
为符号 S 编写函数时,由于只有一个产生式,无需担心回溯。S 仅在 s1 成功时成功。
现在你能看到问题所在。解析输入字符串时,将调用 s 函数。s 函数会调用 s1 函数。s1 函数首先会调用 s 函数。结果,s 函数将陷入无限循环,永远无法解析任何输入。
该文法表现不佳的原因,是因为它是左递归的。
左递归文法的定义是:任何具有从该非终结符开始,进行非空序列重写,最终又回到相同非终结符的文法。注意 + 号,表示必须进行多次重写。

对于上面的文法,推导过程如下:
S -> S a
-> S a a
-> S a a a
-> ...
总能达到字符串以 a 结尾,但左侧始终是 S。如果字符串左侧始终是 S,将永远无法匹配输入,因为匹配输入的唯一方法是首先生成终端符号。如果首先是非终结符,解析将永远无法取得进展。
这意味着,递归下降解析器不支持左递归文法。
左递归的影响与通用形式
递归下降不支持左递归文法,这似乎是递归下降解析的一个主要问题。确实是个问题,但正如我们稍后所见,其实并不那么严重。
让我们考虑一个稍微更通用的左递归文法。现在我们有两种产生式:
S -> S α | β
其中 β 是其他不提及 S 的符号串。
考虑生成这种语言的规则。它将连接所有以 β 开头的字符串,然后跟随任意数量的 α,但它以一种特殊方式进行。
如果我写出一些推导,其中我多次使用了第一个产生式,你可以看到发生了什么:
S -> S α
-> S α α
-> S α α α
-> ...
若我重复此操作,得到 S 后跟任意数量的 α,然后在一步中可以加入 β,得到 β 后跟任意数量的 α。
这就是生成该语言的证明:以 β 开始,包含一些 α 序列。但可见它是从右向左完成的:首先产生字符串的右半部分。实际上,它产生的最后一件事是输入中出现的第一件事。
这就是为什么它不能与递归下降解析一起工作。因为递归下降解析希望首先看到输入的第一部分,然后从左到右工作,而这个语法是为了从右到左生成字符串而构建的。
消除左递归:转换为右递归 🔄
解决问题的思路是:生成完全相同的语言,但改为从左到右生成字符串,而不是从右到左。
我们这样做的方法是用右递归替换左递归。我们需要在此处添加一个符号。
不再让 S 指向含 S 的左侧,而是让 S 指向 β(生成第一个元素),然后指向 S'。
以下是转换后的文法:
S -> β S'
S' -> α S' | ε
S' 负责生成预期的 α 序列,也可能是空序列。

如果你写出一个推导例子:
S -> β S'
-> β α S'
-> β α α S'
-> β α α ε
-> β α α
我们得到 β 后跟一些 α 的序列。你可以看到它生成与第一个语法完全相同的字符串,但它是以右递归的方式,而不是左递归的方式。
通用左递归消除方法
一般来说,我们可能有多个产生式,其中一些是左递归的,一些不是。这个特定形式的语法产生的语言,将是所有从 S 派生的字符串,从某个 β 开始(β 中不涉及 S),然后跟随零个或多个 α 的实例。
我们可以做完全相同的转换。这只是我们之前想法的概括,从只有一个 β 和一个 α,推广到多个 β 和多个 α。
使用右递归重写左递归文法的通用形式如下:

原始左递归文法:
S -> S α1 | S α2 | ... | S αm | β1 | β2 | ... | βn
转换后的右递归文法:
S -> β1 S' | β2 S' | ... | βn S'
S' -> α1 S' | α2 S' | ... | αm S' | ε
这里每个 β 都作为第一个位置的选择。我们只需要一个额外的符号 S',然后 S' 的规则负责生成任何 α 序列。
间接左递归
现在事实证明,那不是最通用的左递归形式。甚至还有一些其他方式在语法中编码左递归,这里是一种重要的方式。
我们可能有一个语法:
S -> A α
A -> S β | γ
如果你看这里,S 甚至没有立即出现在其产生式的右侧。A 也没有在任何地方出现在其产生式的右侧。所以这里没有所谓的立即左递归在这个语法中。
但另一方面有左递归,因为 S -> A α,然后 A -> S β。所以我们在两步内,产生另一个以 S 开头的字符串。这仍然是一个左递归语法,我们只是通过在左端插入其他非终结符延迟了它,在我们回到 S 之前。

间接左递归也可以消除。实际上这可以自动消除,甚至不需要人工干预。如果你看任何教科书(例如《编译原理》龙书),你会发现做这些转换的算法。
总结与回顾 📝
本节课中我们一起学习了递归下降解析中的左递归问题。
我们关于递归下降解析的讨论表明,它是一种简单而通用的解析策略。你可以使用递归下降解析任何上下文无关文法,因此它在这一点上非常通用。
但它不能与左递归文法一起工作。因此,你必须消除左递归。原则上,这可以自动完成,你可以有算法来消除直接的或间接的左递归。
人们通常手动消除左递归,原因是你需要知道你使用的语法形式,以便你可以编写语义动作(我们将在后续课程中讨论)。因为你需要确切知道语法形式,所以人们通常自己消除左递归,但这并不难做到。

事实上,递归下降在实践中是一种流行的策略。许多更复杂的生产编译器,实际上使用复杂的文法并采用递归下降,因为它非常通用且易于实现。

课程 P27:预测解析(Predictive Parsing) 🧠

在本节课中,我们将学习一种称为预测解析的自顶向下解析算法。我们将了解它与递归下降解析的区别,学习如何通过左因子化改造语法以适应预测解析,并掌握如何使用解析表来驱动解析过程。
概述 📋

上一节我们介绍了自顶向下解析的基本概念。本节中,我们来看看一种更高效、无需回溯的自顶向下解析策略——预测解析。预测解析器能够根据输入流中即将出现的标记(即“前瞻”),确定性地选择正确的语法产生式。
什么是预测解析? 🤔

预测解析类似于递归下降解析,它也是一种自顶向下解析器。但关键区别在于,预测解析器能够“预测”将使用哪个产生式,而从不进行盲目的尝试。解析器总能正确猜测哪个产生式将导致成功的解析(如果存在这样的产生式)。
它通过两种方式实现这一点:
- 它查看输入流中接下来的几个标记(即使用“前瞻”)。
- 它要求语法必须满足特定形式(即LL(k)文法)。
优势是没有回溯,因此解析器是完全确定的。

LL(k) 文法 📖
预测解析器接受的语法称为 LL(k) 文法。这个名称的含义如下:
- 第一个 L:代表从左到右扫描输入。
- 第二个 L:代表构建最左推导,即总是扩展解析树中最左边的非终结符。
- k:代表前瞻符号的数量,即解析器可以提前查看输入流中接下来的
k个标记。
尽管理论上 k 可以是任意值,但在实践中,k 通常等于 1。因此,我们主要讨论 LL(1) 解析。
在LL(1)解析中,解析过程的每一步都只有至多一个可用的产生式。这意味着,如果当前最左非终结符是 A,下一个输入标记是 t,那么恰好只有一个形如 A -> α 的产生式可能成功。任何其他选择都保证是错误的。

语法改造:左因子化 🔧

并非所有语法都天然适合LL(1)解析。回顾我们之前使用的表达式语法:

E -> T + E | T
T -> int * T | int | (E)

使用这个语法进行预测解析会遇到问题。例如,E 的两个产生式都以 T 开头。如果仅前瞻一个标记(int, *, ( 等),我们无法判断应该选择 T + E 还是 T。
为了解决这个问题,我们需要对语法进行左因子化。左因子化的思想是消除一个非终结符的多个产生式中的公共前缀。

以下是改造过程:

-
处理 E 的产生式:
- 原式:
E -> T + E | T - 公共前缀是
T。我们提取它,引入一个新的非终结符X来处理不同的后缀。 - 改造后:
E -> T X,然后为X写产生式来处理+ E或空(ε)。 - 最终:
E -> T X,X -> + E | ε
- 原式:
-
处理 T 的产生式:
- 原式:
T -> int * T | int | (E) - 注意到
int * T和int有公共前缀int,而(E)则不同。 - 对前两个产生式进行左因子化,引入新非终结符
Y。 - 最终:
T -> int Y | (E),Y -> * T | ε
- 原式:
经过左因子化后,我们得到的新语法如下:

E -> T X
X -> + E | ε
T -> int Y | (E)
Y -> * T | ε
这个语法更适合进行LL(1)预测解析。
解析表与解析算法 🗂️
对于改造后的LL(1)语法,我们可以构建一张解析表。这张表定义了在给定当前最左非终结符和下一个输入标记时,应该使用哪个产生式。
解析表的一维是当前最左非终结符(行),另一维是下一个输入终结符(列)。表中的条目是对应的产生式右侧,空白条目表示错误状态。

假设我们已经为上面的语法构建了解析表(构建算法将在后续课程介绍),解析算法使用一个栈来驱动,流程如下:
算法步骤:
- 初始化栈,内容为
[$, S](S是开始符号,$是输入结束标记)。输入字符串末尾也追加$。 - 循环执行以下操作,直到栈为空或遇到错误:
- 情况A:栈顶是终结符
a- 如果
a等于当前输入标记,则匹配成功。弹出栈顶,输入指针前进。 - 否则,触发错误。
- 如果
- 情况B:栈顶是非终结符
X- 查解析表
Table[X, 当前输入标记]。 - 如果条目为产生式
X -> Y1 Y2 ... Yk,则弹出X,并将Yk, ..., Y2, Y1依次压入栈中(注意顺序,保证最左符号在栈顶)。 - 如果条目为空,触发错误。
- 查解析表
- 情况A:栈顶是终结符
- 当栈变为
[$]且输入也只剩下$时,解析成功。
解析示例 🔍
让我们使用上述算法和解析表,解析输入字符串 int * int。
以下是解析过程的步骤追踪:
| 步骤 | 栈 (从顶到底) | 剩余输入 | 动作说明 |
|---|---|---|---|
| 0 | [$, E] |
int * int $ |
初始状态 |
| 1 | [$, X, T] |
int * int $ |
栈顶 E,输入 int,查表得 E -> T X |
| 2 | [$, X, Y, int] |
int * int $ |
栈顶 T,输入 int,查表得 T -> int Y |
| 3 | [$, X, Y] |
* int $ |
栈顶 int 匹配输入 int,弹出并前进输入 |
| 4 | [$, X, T, *] |
* int $ |
栈顶 Y,输入 *,查表得 Y -> * T |
| 5 | [$, X, T] |
int $ |
栈顶 * 匹配输入 *,弹出并前进输入 |
| 6 | [$, X, Y, int] |
int $ |
栈顶 T,输入 int,查表得 T -> int Y |
| 7 | [$, X, Y] |
$ |
栈顶 int 匹配输入 int,弹出并前进输入 |
| 8 | [$, X] |
$ |
栈顶 Y,输入 $,查表得 Y -> ε,弹出 Y |
| 9 | [$] |
$ |
栈顶 X,输入 $,查表得 X -> ε,弹出 X |
| 10 | 接受 | 栈顶 $ 匹配输入 $,栈空,解析成功! |
通过这个例子,你可以看到预测解析器如何确定性地、一步步地构建出解析树,而无需任何回溯。
总结 🎯
本节课中我们一起学习了预测解析的核心内容:
- 预测解析是一种无回溯、确定性的自顶向下解析方法。
- 它要求语法是 LL(1) 文法,即通过一个前瞻符号就能确定产生式选择。
- 对于不满足LL(1)的语法,可以通过左因子化进行改造。
- 解析过程由一个解析表驱动,并使用栈来跟踪解析状态。
- 算法通过匹配终结符和展开非终结符(根据查表结果)来推进,直到成功接受或报错。

预测解析是许多编译器前端使用的高效算法,理解它是掌握语法分析的关键一步。在接下来的课程中,我们将探讨如何自动构建那张至关重要的解析表。

课程 P28:构建LL(1)解析表 - 计算First集合 🔍

在本节课中,我们将要学习如何为LL(1)语法分析器构建解析表。具体来说,本节将重点介绍一个核心概念:First集合。理解First集合是后续构建解析表的关键第一步。
概述:解析表的构建条件
在深入First集合之前,我们需要了解构建解析表的基本逻辑。解析表的核心作用是指导分析器在特定情况下应选择哪个产生式进行推导。
假设当前最左非终结符是 A,下一个输入符号是 t。分析器决定使用产生式 A → α 来替换 A 的条件主要有两种:
- 如果从 α 推导出的第一个终结符可能是 t。这意味着 t 属于 α 的 First集合。
- 如果 α 可能推导出空串 ε,并且 t 可以出现在 A 的后面(即属于 A 的 Follow集合)。
本节课我们将聚焦于第一种情况,即如何计算 First集合。
First集合的定义与计算规则
对于语法中的任意符号串 X(可以是终结符、非终结符或它们的组合),其 First(X) 集合包含所有可能出现在由 X 推导出的第一个位置的终结符。此外,出于技术原因,如果 X 能够推导出空串 ε,那么 ε 也属于 First(X)。
以下是计算First集合的规则:

规则一:终结符的First集合
对于任意终结符 t,其First集合只包含它自身。
公式:
First(t) = { t }
规则二:非终结符的First集合
对于非终结符 A,考虑其所有产生式 A → Y1 Y2 ... Yk。其First集合的计算遵循以下步骤:
- 如果存在产生式
A → ε,则将 ε 加入First(A)。 - 对于每个产生式
A → Y1 Y2 ... Yk:- 将
First(Y1)中除 ε 外的所有元素加入First(A)。 - 如果
First(Y1)包含 ε,则继续检查First(Y2),并将其所有元素(除 ε 外)加入First(A)。 - 依此类推,直到遇到某个
Yi,其First(Yi)不包含 ε。 - 如果所有
Y1到Yk的First集合都包含 ε,则将 ε 加入First(A)。
- 将
规则三:符号串的First集合
对于符号串 X1 X2 ... Xn,其First集合的计算方式与非终结符类似:
- 首先将
First(X1)中除 ε 外的所有元素加入结果集合。 - 如果
First(X1)包含 ε,则继续将First(X2)中除 ε 外的元素加入。 - 重复此过程,直到某个
Xi的First集合不包含 ε。 - 如果所有
X1到Xn的First集合都包含 ε,则将 ε 加入结果集合。
实例演练:计算First集合
让我们通过一个具体的语法来实践上述规则。考虑以下表达式语法:
E -> T X
X -> + T X | ε
T -> F Y
Y -> * F Y | ε
F -> n | ( E )
以下是计算过程:
第一步:计算终结符的First集合
根据规则一,每个终结符的First集合就是它自身。
First(n) = { n }
First(+) = { + }
First(*) = { * }
First(() = { ( }
First()) = { ) }
第二步:计算非终结符的First集合
我们需要按照依赖关系,从最基本的非终结符开始计算。

-
计算
First(F):- 产生式
F -> n:将First(n) = { n }加入。 - 产生式
F -> ( E ):将First(() = { ( }加入。 F没有ε产生式。
结果:
First(F) = { n, ( } - 产生式
-
计算
First(Y):- 产生式
Y -> * F Y:将First(*) = { * }加入。 - 产生式
Y -> ε:将 ε 加入。
结果:
First(Y) = { *, ε } - 产生式
-
计算
First(T):- 产生式
T -> F Y:我们需要计算First(F Y)。First(F) = { n, ( },不包含 ε。因此,First(T)直接包含First(F)的所有元素{ n, ( }。
结果:
First(T) = { n, ( } - 产生式
-
计算
First(X):- 产生式
X -> + T X:将First(+) = { + }加入。 - 产生式
X -> ε:将 ε 加入。
结果:
First(X) = { +, ε } - 产生式
-
计算
First(E):- 产生式
E -> T X:我们需要计算First(T X)。First(T) = { n, ( },不包含 ε。因此,First(E)直接包含First(T)的所有元素{ n, ( }。
结果:
First(E) = { n, ( } - 产生式
总结
本节课中,我们一起学习了 First集合 的概念和计算方法。我们了解到:
- First集合 定义了从一个语法符号串开始推导时,可能出现在首位的所有终结符(包括可能的 ε)。
- 计算遵循三条核心规则:终结符的First集合是它自身;非终结符的First集合由其产生式右侧符号串的First集合决定;符号串的First集合由其组成符号的First集合递推得到。
- 掌握First集合的计算是构建LL(1)解析表的第一步,它帮助我们确定在看到一个输入符号时,哪个产生式有可能匹配这个输入。

在下一节课中,我们将学习另一个关键概念——Follow集合,它用于处理当前非终结符可能推导为空串(ε)时,该如何决定使用哪个产生式。


编译原理课程 P29:构建解析表之Follow集计算 🧮
在本节课中,我们将学习如何计算语法中非终结符的 Follow集。Follow集用于确定在解析过程中,一个非终结符后面可能出现的终结符是什么。这对于构建预测分析表至关重要。
概述 📋
上一节我们介绍了First集的计算,它告诉我们一个符号串能推导出的第一个终结符。本节中,我们来看看Follow集。Follow集的定义与First集不同,它不关心符号本身能生成什么,而是关注该符号在语法推导中可能出现在什么位置,以及紧跟在它后面的终结符有哪些。
Follow集的定义与直观理解
给定一个语法符号X,其Follow集是所有满足以下条件的终结符t的集合:存在某个推导,使得终结符t可以紧跟在符号X之后出现。

以下是计算Follow集的直观规则:
- 相邻符号规则:如果在某个产生式
A -> α X β中,符号X后面紧跟着符号串β,那么β的First集(除去ε) 中的终结符,都在X的Follow集中。 - 产生式末尾规则:如果符号X位于某个产生式
A -> α X的末尾,或者X后面的β可以推导出ε(即β =>* ε),那么左部符号A的Follow集中的终结符,也在X的Follow集中。 - 开始符号的特殊规则:特殊的结束标记
$总是在开始符号的Follow集中。

计算Follow集的算法步骤
以下是计算语法中所有非终结符Follow集的算法概要:
- 初始化:将
$加入开始符号的Follow集。 - 遍历语法中的每一个产生式
A -> α。 - 对于产生式右部α中的每一个符号
Xi(从左到右):- 如果
Xi后面有符号Xi+1...Xj,则将First(Xi+1...Xj)(除去ε)加入Follow(Xi)。 - 如果
Xi后面没有符号,或者Xi+1...Xj可以推导出ε(即ε ∈ First(Xi+1...Xj)),则将Follow(A)加入Follow(Xi)。
- 如果
- 重复步骤2和3,直到所有Follow集不再发生变化。
注意:Follow集中只包含终结符,不包含ε。
实例演练:算术表达式语法
让我们通过一个具体的语法例子来实践Follow集的计算。语法如下:
E -> T X
X -> + E | ε
T -> F Y
Y -> * T | ε
F -> ( E ) | int
其中,E 是开始符号。
我们将逐步计算每个非终结符的Follow集。
步骤1:初始化与开始符号E
根据规则,$ 在 Follow(E) 中。
Follow(E) = { $ }
现在,我们需要查看E在语法中的使用位置,以确定其Follow集中是否还有其他终结符。
步骤2:分析E的使用位置
E在语法中被使用的位置有两处:
- 在产生式
F -> ( E )中。这里,E后面紧跟着终结符)。因此,)在Follow(E)中。Follow(E) = { $, ) } - 在产生式
E -> T X中,E出现在产生式右部。根据“产生式末尾规则”,Follow(E)是Follow(X)的子集(因为X在产生式末尾)。这个关系我们稍后会用到。
步骤3:分析X的使用位置
X在语法中只在一个地方被使用:E -> T X。X出现在产生式右部,因此 Follow(E) 是 Follow(X) 的子集。
同时,从步骤2我们知道,Follow(X) 也是 Follow(E) 的子集。这意味着 Follow(E) 和 Follow(X) 最终必须相等。
Follow(X) = Follow(E) = { $, ) }
步骤4:分析T的使用位置
T在语法中被使用的位置有两处:
- 在产生式
E -> T X中。- T后面是X。因此,
First(X)(除去ε)中的终结符在Follow(T)中。First(X) = { +, ε },除去ε后得到{ + }。 - 因为
ε ∈ First(X)(X可以推导出ε),根据“产生式末尾规则”,Follow(E)也在Follow(T)中。
Follow(T) ⊇ { + } ∪ Follow(E) = { +, $, ) } - T后面是X。因此,
- 在产生式
Y -> * T中,T出现在产生式右部。因此,Follow(Y)是Follow(T)的子集。

步骤5:分析Y的使用位置
Y在语法中只在一个地方被使用:T -> F Y。Y出现在产生式右部,因此 Follow(T) 是 Follow(Y) 的子集。
同时,从步骤4我们知道,Follow(Y) 也是 Follow(T) 的子集。这意味着 Follow(T) 和 Follow(Y) 最终必须相等。
Follow(Y) = Follow(T) = { +, $, ) }
步骤6:最终结果
现在,我们已经处理了所有非终结符。最终得到的Follow集如下:
Follow(E) = { $, ) }Follow(X) = { $, ) }Follow(T) = { +, $, ) }Follow(Y) = { +, $, ) }Follow(F):根据语法,F出现在T -> F Y和F -> ( E )中。计算可得Follow(F) = { *, +, $, ) }(具体计算过程作为练习,逻辑与上述类似)。
总结 🎯
本节课中我们一起学习了Follow集的计算方法。我们了解到:
- Follow集用于确定非终结符后面可能出现的终结符。
- 其计算基于三个核心规则:相邻符号规则、产生式末尾规则和开始符号特殊规则。
- 计算过程是一个迭代过程,需要关注符号在语法中的所有使用位置,并处理Follow集之间的相互包含关系。
掌握First集和Follow集的计算,是下一步构建LL(1)预测分析表的基础。在下一节课中,我们将看到如何利用这两个集合来填充预测分析表,从而实现对输入串的自顶向下语法分析。


编程语言经济学 📈 P3

在本节课中,我们将探讨“编程语言经济学”这一话题。我们将分析为什么存在如此多的编程语言、为何不断有新的语言诞生,以及如何评价一门编程语言的好坏。理解这些宏观因素,有助于我们更好地把握编程语言的发展脉络和现实应用。

为什么存在如此多的编程语言? 🤔
任何思考编程语言超过几分钟的人都会想到一个问题:为什么有这么多编程语言?我们有数百种,甚至数千种日常使用的编程语言。为什么所有这些都需要存在?为什么一种编程语言不够用?
这个问题的部分答案并不难找到。编程的应用领域有着非常独特且相互冲突的需求,因此很难设计一种语言,能在所有情况下为所有程序员做好所有事情。
以下是几个不同应用领域及其需求的例子:
-
科学计算:这个领域主要进行工程应用、大型科学实验和长期运行的模拟计算。
- 需求:需要良好的浮点支持(
fp)、强大的数组及数组操作支持,以及并行计算能力。 - 代表性语言:FORTRAN。其名称即“公式翻译”,始终保留着科学计算的核心,至今仍是该领域的领先语言之一。
- 需求:需要良好的浮点支持(
-
商务应用:这个领域关注企业数据处理。
- 需求:需要极高的数据可靠性、良好的报告生成功能以及强大的数据分析能力。
- 代表性语言:SQL。作为关系数据库的查询语言,它在数据处理领域占据主导地位。
-
系统编程:这个领域涉及嵌入式系统、操作系统等底层开发。
- 需求:需要对资源进行极低层的细粒度控制,并且常常需要考虑实时性限制。
- 代表性语言:C 和 C++ 家族。
可以看到,不同领域的要求完全不同。在一个领域最重要的特性,在另一个领域可能无关紧要。因此,很难将所有需求整合到一个单一、连贯的系统中。

为什么会有新的编程语言诞生? 🆕
既然已经有这么多语言,为什么我们还需要设计新的?要回答这个问题,需要从一个关键观察开始:程序员培训是编程语言的主要成本。
这里的“成本”不仅指购买教材或上课的实际支出,更指程序员投入学习该语言的时间价值。如果要让成千上万的程序员学习一门新语言,这将是一项巨大的经济投资。
从这个观察出发,我们可以得出几个预测:
- 广泛使用的语言将缓慢变化:改变一门拥有大量用户的语言,意味着需要重新教育整个社区。因此,即使是很小的语法扩展或功能增加,成本也非常高。随着用户群扩大,语言的进化速度会越来越慢。
- 新语言更容易启动和快速进化:从零用户开始的新语言,几乎没有培训成本。即使只有少量用户,教他们变化的成本也不高。因此,新语言能更快地适应变化的情况,进行实验的成本也很低。
在这两者之间存在一种张力。程序员何时会选择从现有语言转向新语言?当他们认为学习新语言带来的生产力提升,短期内能超过培训成本时,他们就会转换。
新的编程语言最可能被采用来填补空白。随着技术发展,不断有新的应用领域出现(例如移动应用、互联网),产生了新的编程需求。旧语言由于变化缓慢,很难快速适应这些新领域。这就为新语言的诞生创造了机会。
此外,还有一个预测:新语言往往看起来像旧语言。它们与某些前辈语言有家族相似性。这不仅有设计传承的原因,更有经济利益:降低培训成本。让新语言像旧语言,可以利用程序员已有的知识,让他们更快上手。Java 设计得像 C++,就是一个经典例子。
什么是好的编程语言? 🏆

最后,我们来探讨什么是一门好的编程语言。不幸的是,这个问题并没有明确的答案。没有普遍接受的评价指标,这意味着人们对于好语言的标准存在分歧。
为了说明制定这种指标的困难,让我们看一个曾被认真提出的指标:“一门好的语言是人们使用的语言。”
- 支持论点:这是一个非常明确的指标,它测量语言的人气。更广泛使用的语言,可能在某些方面确实更好。
- 反对论点:如果遵循这个逻辑,那么 Visual Basic 可能就是世界上最好的编程语言。但除了技术卓越性之外,还有许多其他因素影响一门语言的流行程度,例如是否解决了特定利基市场的需求,以及历史惯性。技术卓越性甚至可能不是最重要的原因。
因此,仅凭使用人数无法客观衡量一门语言的好坏。
总结 📝

本节课我们一起学习了编程语言经济学中的核心概念。
要记住的两个最重要观点是:
- 应用领域有冲突的需求:很难设计一个单一系统来完美满足所有需求。向现有系统添加新功能通常需要很长时间。
- 程序员培训是主要成本:这个观察解释了为什么新语言会不断诞生。当新的应用机会出现时,设计一门全新的、专门适应新需求的语言,往往比试图推动整个庞大的现有程序员社区和系统进行缓慢的变革,更加直接和高效。

这两点共同构成了编程语言生态不断演进的底层经济逻辑。

课程 P30:LL(1) 解析表构建 🧩

在本节课中,我们将学习如何整合FIRST集和FOLLOW集的知识,来为给定的文法构造 LL(1) 解析表。解析表是LL(1)解析器的核心,它明确地告诉解析器在给定栈顶符号和下一个输入符号时,应该选择哪个产生式进行推导。
解析表构建规则 📝
上一节我们介绍了FIRST集和FOLLOW集的计算,本节中我们来看看如何利用它们来填充解析表。
构造解析表 T 的过程是:遍历文法 G 中的每一个产生式 A -> α,并根据以下规则向表中添加条目。

以下是具体的规则:
-
情况一:终结符在 FIRST(α) 中
对于产生式A -> α,如果存在终结符t属于 FIRST(α),那么就在解析表的[A, t]单元格中填入这个产生式A -> α。这表示当栈顶是A且下一个输入是t时,应该用α来替换A。 -
情况二:ε 在 FIRST(α) 中,且 FOLLOW(A) 中有终结符
对于产生式A -> α,如果 ε 属于 FIRST(α)(即α可以推导出空串),那么对于 FOLLOW(A) 中的每一个终结符t(包括结束符$),在解析表的[A, t]单元格中填入这个产生式A -> α。这表示当栈顶是A,下一个输入是t,且t可以合法地跟在A后面时,我们可以选择将A替换为空(即“消除”A)。 -
特殊情况:输入结束符
$
如果栈顶是A,而输入已经结束(下一个“输入”是$),并且$属于 FOLLOW(A),那么对于所有能推导出ε的产生式A -> α,在[A, $]单元格中填入该产生式。这允许我们在输入结束时,将栈中剩余的A消除掉。
核心公式:表格条目 T[A, t] 的填充逻辑可以总结为:
如果 t ∈ FIRST(α),则 T[A, t] = A -> α
否则,如果 ε ∈ FIRST(α) 且 t ∈ FOLLOW(A),则 T[A, t] = A -> α
构建示例 ✨
现在,让我们通过一个具体的例子来应用上述规则。我们将使用以下熟悉的表达式文法:
E -> T X
T -> ( E )
T -> int Y
X -> + E
X -> ε
Y -> * T
Y -> ε
首先,我们需要知道所有非终结符的 FIRST集 和 FOLLOW集(这些我们在之前的课程中已经计算过)。
以下是构建解析表的步骤:
-
初始化表格
表格的行是非终结符:E, T, X, Y。
表格的列是终结符:(, ), int, +, *, $。 -
应用规则填充表格
我们将逐个分析每个产生式。对于产生式
E -> T X:FIRST(T X)包含(和int。- 因此,在
[E, (]和[E, int]单元格中填入E -> T X。
对于产生式
T -> ( E ):FIRST(( E ))只包含(。- 因此,在
[T, (]单元格中填入T -> ( E )。
对于产生式
T -> int Y:FIRST(int Y)只包含int。- 因此,在
[T, int]单元格中填入T -> int Y。
对于产生式
X -> + E:FIRST(+ E)只包含+。- 因此,在
[X, +]单元格中填入X -> + E。
对于产生式
Y -> * T:FIRST(* T)只包含*。- 因此,在
[Y, *]单元格中填入Y -> * T。
对于产生式
X -> ε:FIRST(ε)包含ε。- 我们需要查看
FOLLOW(X)。根据计算,FOLLOW(X) = { ), $ }。 - 因此,在
[X, )]和[X, $]单元格中填入X -> ε。
对于产生式
Y -> ε:FIRST(ε)包含ε。- 我们需要查看
FOLLOW(Y)。根据计算,FOLLOW(Y) = { +, ), $ }。 - 因此,在
[Y, +]、[Y, )]和[Y, $]单元格中填入Y -> ε。
-
完成表格
最终,我们得到以下解析表(空白单元格表示错误状态):
| 非终结符 | ( |
) |
int |
+ |
* |
$ |
|---|---|---|---|---|---|---|
| E | E->T X |
E->T X |
||||
| T | T->(E) |
T->int Y |
||||
| X | X->ε |
X->+E |
X->ε |
|||
| Y | Y->ε |
Y->ε |
Y->*T |
Y->ε |
在解析过程中,如果栈顶符号和下一个输入符号所对应的单元格为空,则意味着当前输入串不符合该文法,解析器将报告错误。

非 LL(1) 文法示例 ⚠️
上一节我们成功构建了一个LL(1)解析表,本节中我们来看看当文法不是LL(1)时会发生什么。
考虑一个简单的左递归文法:
S -> S a
S -> b
让我们尝试为其构建解析表。
FIRST(S) = { b }FOLLOW(S) = { a, $ }(因为S是开始符号,且a跟在S后面)

现在填充表格:
- 对于产生式
S -> b:FIRST(b) = {b},所以在[S, b]填入S -> b。 - 对于产生式
S -> S a:FIRST(S a) = FIRST(S) = {b},所以在[S, b]也需要填入S -> S a。
问题出现了:表格的 [S, b] 单元格现在有两个产生式(S -> b 和 S -> S a)。这意味着当栈顶是 S 且下一个输入是 b 时,解析器无法确定应该选择哪个产生式。这种多重定义的条目表明该文法不是 LL(1) 文法。
核心结论:
- 判断一个文法是否是 LL(1) 的唯一机械方法就是尝试构建其 LL(1) 解析表。如果表中任何一个单元格包含多于一个产生式,则该文法不是 LL(1)。
- 一些文法特征保证其不是 LL(1),例如:
- 存在左递归(如
A -> A α)。 - 存在歧义(同一个句子有多种最左推导)。
- 需要超过一个符号的向前看。
- 存在左递归(如
- 即使一个文法消除了左递归、进行了左公因子提取并且是无歧义的,它仍然可能不是 LL(1)。许多编程语言的语法都不是严格的 LL(1),因此需要更强大的解析技术(如 LR 解析),这些技术都建立在当前所学的基础概念之上。
总结 📚

本节课中我们一起学习了 LL(1) 解析表的构建方法。我们首先回顾了利用 FIRST集 和 FOLLOW集 填充表格的两条核心规则。然后,我们通过一个完整的表达式文法示例,一步步演示了如何构造出无冲突的解析表。最后,我们探讨了当文法不是 LL(1) 时(例如存在左递归),解析表中会出现多重定义条目,从而导致解析过程不确定。理解如何构建和检查 LL(1) 解析表,是掌握自顶向下语法分析的关键一步。

课程 P31:自底向上解析入门 🧩

在本节课中,我们将要学习自底向上解析的基本概念。这是一种与之前学过的自顶向下解析不同的方法,它从输入字符串的标记开始,逐步规约,最终构建出完整的解析树。我们将了解其工作原理、优势以及它与最右推导的关系。

什么是自底向上解析? 🔄
上一节我们介绍了自顶向下解析,本节中我们来看看自底向上解析。首先要知道的是,自底向上解析比确定型自顶向下解析更通用。回忆一下我们讨论过的递归下降,这是一个完全通用的解析算法,但需要回溯。现在我们专注于确定型技术,上次我们讨论了LL(1)或预测性解析。

现在我们要换挡,讨论自底向上解析。结果是,即使自底向上解析更通用,它同样高效。它使用了我们在自顶向下解析中学到的所有想法。实际上,自底向上解析是大多数解析器生成工具首选的解析方法。
自底向上解析器的一个优点是它们不需要对文法进行左因子化。我们可以回到示例中的“自然”文法(“自然”在这里是带引号的,因为我们仍然需要编码加号和乘号的优先级)。自底向上解析器不会处理歧义文法。

自底向上解析如何工作? ⚙️
让我们通过一个例子来理解自底向上解析器如何处理一个典型的输入字符串。
首先要知道的是,自底向上解析通过逆向运行产生式,将字符串还原为开始符号。以下是解析步骤的示例:
- 左侧是字符串的状态序列。
- 右侧是使用的产生式。
我们开始时是整个字符串,即终端符号的字符串。我们挑选了一些终端符号(在这个例子中,是单个的 int),然后运行了一个逆向产生式。我们用产生式的左侧替换了匹配到的右侧部分。
例如:
- 我们从
int开始,匹配产生式t -> int的右侧,并用左侧t替换它。 - 然后,我们取子串
int * t,匹配产生式t -> int * t的右侧,并用左侧t替换它。 - 以此类推。
在每一步中,我们都在匹配字符串的一部分,并用某个产生式的左侧替换那个子串。最后,整个字符串被替换成开始符号 e。

所以,我们从输入字符串(标记流)开始,最终到达了起始符号。如果你从底部(输入字符串)开始向上(起始符号)阅读这些步骤,这实际上是一个推导。但当我们倒着运行时(从字符串到起始符号),我们称这些步骤为规约。
与最右推导的关系 🔗
你可能会想,我是如何知道要执行这个特定序列的规约的?这是自底向上解析的另一个有趣属性。
如果你按这些产生式的反向阅读,它们追踪一个最右推导。解析器实际上在从输入到起始符号的方向上运行(规约),但如果我们反向看这些步骤,它恰好对应一个最右推导。

例如,推导步骤可能是:
e -> t + e -> t + t -> t + int -> int * t + int -> int * int + int
这引出了关于自底向上解析的第一个重要事实:自底向上解析器反向追踪一个最右推导。所以如果你在自底向上解析上遇到麻烦,回到这个基本事实总是有帮助的。自底向上解析器追踪一个最右推导,但它以相反的方式通过使用规约而不是产生式来做。

构建解析树 🌳
让我们通过动画来可视化规约序列和解析树的构建过程,这非常有帮助。
我们开始时是整个输入字符串。自底向上解析器采取一系列规约步骤来构建完整的解析树。基本思想是:每一步执行归约时,我们用某个产生式的左部(父节点)替换右部子节点(在输入中匹配到的子串),就像在自顶向下解析中,我们会使右部成为左部的子节点。
以下是构建过程:
- 自顶向下解析器从开始符号(根节点)开始,通过扩展前缘的非终结符逐步构建树。
- 自底向上解析器则从最终解析树的所有叶节点(整个输入)开始,并在其上构建小树,然后将它们粘贴在一起。
随着解析的进行,原始输入中越来越多的部分将被组合成越来越大的子树。在最后一步,所有子树被粘贴成一个以开始符号为根的完整解析树。因此,它是通过自下而上组合小解析树来构建完整树的。
总结 📝
本节课中我们一起学习了自底向上解析的基础。我们了解到:
- 自底向上解析是一种从输入标记开始,通过规约逆向产生式,最终到达文法开始符号的解析方法。
- 它比确定型自顶向下解析更通用,且是许多解析器生成器的首选。
- 自底向上解析器反向追踪一个最右推导。
- 解析过程通过将输入字符串的小片段逐步规约并组合成更大的子树,最终自底向上地构建出完整的解析树。

理解自底向上解析的关键在于掌握“规约”操作和它与“最右推导”的逆向关系。在接下来的课程中,我们将深入探讨如何决定在何时进行何种规约,即移进-归约解析算法的核心。

编译原理 P32:自底向上解析与移入-归约策略 🧩
在本节课中,我们将要学习自底向上解析的核心策略——移入-归约解析。我们将了解解析器如何通过两种基本操作(移入和归约)来处理输入字符串,并最终推导出语法的开始符号。我们还会探讨解析过程中可能遇到的冲突及其含义。

自底向上解析回顾 📚

上一节我们介绍了自底向上解析的基本概念。本节中我们来看看其核心策略。
自底向上解析的主要策略是所谓的“移入-归约解析”。回顾上次课程最重要的内容:解析过程逆向运行产生式。这一特定事实有一个重要后果。
让我们思考一下移入-归约解析的状态。假设我们有一个字符串 alpha beta omega,并且下一次归约操作将把 beta 替换为 X。记住,我们正在逆向运行产生式。那么,我声称 omega 必须是终结符串。为什么是这样呢?
如果你考虑一下,当 X 被替换时,我们取这个步骤。如果我们看前向步骤是逆向步骤,那么 X 必须是最右非终结符。这意味着 X 的右边没有非终结符。因此,X 右边的所有字符或标记都是终结符。
结果是,最右非终结符右边的终结符,正是自底向上解析器实现中“未检查的输入”。如果有 alpha X omega,并且 X 是最后一个非终结符,那么 omega 就是未读的输入。

解析焦点与分隔线 📍
标记我们在解析中的位置将是有用的。我们的输入焦点是:我们将使用一条垂直线来做这件事。
我们将在已读内容的左侧和待处理内容的右侧画一条垂直线。左侧包含解析器已看到的所有终结符和非终结符。右侧包含解析器尚未看到的内容(尽管我们知道都是终结符)。竖线仅标记两个子字符串的分界线。

移入与归约操作 ⚙️
实现自底向上解析,实际上我们只需要两种操作:移入操作和归约操作。

移入操作
移入操作读取一个输入标记。我们可以通过将垂直条向右移动一个标记来表示这一点。
如果我们的输入焦点在这里,并且我们想读取更多的输入标记,那么我们只需将垂直条向右移动。这表示现在解析器知道了下一个终结符号。现在我们可以开始处理它,并与它匹配以执行再次归约的目的。垂直条右侧的内容解析器还没有看到。
归约操作
归约操作是在左字符串的右侧应用逆产生式。

如果我们有一个产生式 A -> X Y,并且我们在这里立即有 X 和 Y 位于垂直条的左侧(即,这是我们的焦点点),那么我们可以做一次归约。我们可以用产生式的左侧 A 替换右侧 X Y。这是一个归约移动。
操作序列示例 🔄

这是上次视频中的示例,它恰好是仅显示归约操作的示例。现在也显示了垂直条,这显示了在每个归约执行时输入焦点的位置。
我们现在知道缺少的是移入操作的序列。以下是移入操作和归约操作的序列,它将初始输入字符串带到开始符号。
让我们更详细地走过这个过程。我们将逐步进行,显示每个移入和每个归约移动。
除了我们下面的输入字符串,我们还有一个指针显示我们在输入中的位置。我们还没有看到任何输入,我们的输入指针在整串的左侧。
因此,第一步是做一个移入。然后我们再做另一个移入。然后我们再做另一个移入。现在,如果你回头看之前的例子,你知道接下来我们需要做的是归约。
记住,我们只能在垂直条的左侧进行归约。因此,我们总是必须在执行归约移动之前读取足够的输入。然后我们执行另一个归约移动。
接下来要做的是移入操作。我们还没解释如何知道是移入还是归约,我们将会讲到。我只是展示存在一系列移位和归约操作成功解析这个例子。
现在我们把整个输入移到了这里。抱歉,我们已移过整个输入,没有更多输入可读。现在只能做归约操作。幸运的是从这一点开始有一系列归约操作我们可以执行。
这里我们归约 int,然后我们归约 T + T。哦,忘了,我们首先归约 T 到 E,然后我们归约 T + E 回到开始符号。
栈的实现 📦
结果,这个左串(垂直线左边的部分)可以用栈实现。因为我们只在垂直线左边立即做归约操作,所以它总是垂直线左边字符串的一个后缀(归约发生的地方)。

所以:
- 移入操作是将一个终结符推入栈中(读取一个输入标记并推入栈中)。
- 归约操作是弹出栈中的一些符号(那是产生式右部),然后它推入一个非终结符到栈中(那是产生式左部)。
用伪代码表示:
# 移入操作
stack.push(next_token())
input_pointer += 1
# 归约操作 (假设产生式为 A -> XYZ)
right_hand_side = [stack.pop() for _ in range(3)] # 弹出右部符号
stack.push(A) # 压入左部非终结符

解析冲突 ⚠️
现在,可能在一个给定状态中,移入或归约都可能导致有效解析。特别是如果移入或归约都是合法的(如果你能做其中一件事),那么我们说有一个移入-归约冲突。解析器可以读取一个输入标记并推入栈中,或者它可以执行一个归约。
如果可以通过两个不同的产生式进行归约,那么有一种称为归约-归约冲突。
归约-归约冲突通常指示语法中存在某种严重问题,它们几乎总是坏的。如果你在为语言(如COOL)构建语法时遇到归约-归约冲突,那么你可能犯了非常严重的错误。
移入-归约冲突是不好的,但它们通常更容易消除。如果你有移入-归约冲突,那么这几乎是可以预见的。你可能需要使用优先级声明来消除它们。我们将在另一个视频中讨论更多。
但一般来说,如果你有这些冲突之一,这意味着在某些状态下,解析器不知道该做什么。你需要重写语法或使用其他机制(如优先级和结合性)来解决歧义。
总结 ✨

本节课中我们一起学习了自底向上解析的核心——移入-归约策略。我们了解了如何用一条垂直线分隔已读和未读输入,并定义了两种基本操作:移入(读取标记)和归约(应用逆向产生式)。我们看到解析过程可以通过栈来实现,并且探讨了在解析过程中可能遇到的移入-归约冲突与归约-归约冲突。理解这些冲突是设计和调试语法的关键。在接下来的课程中,我们将学习如何利用优先级和结合性来解决这些冲突。

课程 P33:自底向上解析中的句柄概念 🧩

在本节课中,我们将要学习自底向上解析中的一个核心概念——句柄。我们将了解句柄的定义、它在解析过程中的重要性,以及为什么正确识别句柄是决定何时进行“移位”或“归约”动作的关键。

回顾自底向上解析

上一节我们介绍了自底向上解析的基本框架。本节中我们来看看其核心动作。
自底向上解析主要使用两种动作:
- 移位:读取一个输入标记,并将解析焦点(竖线)向右移动一格。
- 归约:将焦点左侧的、与某个产生式右侧匹配的符号串,替换为该产生式的左侧非终结符。

在实现上,焦点左侧的字符串通常用栈来管理。栈顶由竖线标记。移位动作将终结符推入栈中;归约动作则从栈顶弹出零个或多个符号(即某个产生式的右侧),然后将对应的非终结符(产生式左侧)压入栈中。

解析中的关键问题:何时归约?
自底向上解析中尚未解决的关键问题是:如何决定何时进行移位,何时进行归约?
让我们通过一个示例语法来思考这个问题。假设我们有如下产生式(为简化,用代码表示核心关系):
E -> T
T -> int
T -> T * int
现在,考虑解析的一步:我们已经将标记 int 移入栈中,输入中下一个标记是 *。此时,栈顶是 int,而我们有产生式 T -> int。我们似乎可以进行归约,将 int 替换为 T。
如果此时归约,栈将变为 T,而输入仍是 * ...。然而,查看语法可知,没有以 T * 开头的产生式。这意味着,如果我们在此处归约,后续将无法通过任何归约步骤回到起始符号,解析将陷入错误。

这个例子告诉我们:即使栈顶恰好是某个产生式的右侧,立即进行归约也可能是一个错误。我们可能需要等待,在更合适的时机进行归约。
句柄的定义
那么,何时归约才是正确的呢?答案就是:仅在栈顶的符号串构成一个“句柄”时才进行归约。
句柄形式化了对“正确归约位置”的直觉。它的定义与最右推导密切相关。
考虑一个最右推导过程:
S =>* α X ω => α β ω
(其中 =>* 表示经过任意多步推导,S 是起始符号,X 是一个非终结符,ω 是终结符串,α 和 β 是符号串)。

在这个推导的最后一步,我们用产生式 X -> β 的右侧 β 替换了最右非终结符 X。那么,在自底向上解析中,当我们看到串 α β ω 且焦点在 β 之后时,β 就是当前步骤的句柄。对它进行归约(用 X 替换 β)是安全的,因为这样我们可以逆向地、一步步地回到起始符号 S。
核心定义:句柄是某个产生式 A -> β 的右侧 β,以及它在当前句型(从起始符号推导出的一个中间串)中的位置,对这个 β 进行归约(替换为 A)后,得到的串仍然可以经过一系列归约最终回到起始符号。
句柄的重要性质

了解句柄的定义后,我们来看它的一个重要性质,这解释了为什么自底向上解析可以用栈高效实现。
句柄总是出现在栈顶,而不会隐藏在栈的内部。

这是一个基于归约步数的归纳论证:
- 初始状态:栈为空,性质成立。
- 归约之后:进行一次归约后,栈顶是一个新压入的非终结符。根据最右推导的定义,这个非终结符就是当前句型中的最右非终结符。
- 下一个动作:下一个句柄要么包含这个最右非终结符(在其右侧扩展),要么完全在它的右边。它绝不可能出现在这个最右非终结符的左边。
- 移位的角色:为了接触到下一个可能成为句柄的符号,解析器只需要不断地进行移位操作,将焦点右边的终结符移入栈顶。因此,任何待归约的句柄最终都必然被暴露在栈顶。

这个性质至关重要。它意味着解析器在决定动作时,只需要关注栈顶的内容和当前的输入标记,无需查看栈内更深层的历史。这正是一个栈数据结构足以胜任自底向上解析的原因。
总结与前瞻

本节课中我们一起学习了自底向上解析的核心概念——句柄。
我们明确了:
- 句柄是允许我们安全进行归约的、产生式右侧的一个具体出现。
- 错误地在非句柄位置归约会导致解析失败。
- 句柄总是出现在解析栈的顶部,这证明了使用栈的合理性。
到目前为止,我们定义了什么是句柄以及它的重要性。然而,我们还没有解决最关键的问题:在实际解析过程中,如何自动地、高效地找到句柄? 如何让解析器“聪明”地判断栈顶的符号串是否构成一个句柄,而不是仅仅匹配某个产生式的右侧。

如何找到句柄,将是接下来关于解析的讨论中所要解决的主要问题,也是构建实用自底向上解析器(如LR解析器)的核心。


课程 P34:识别句柄的核心思想 🧠

在本节课中,我们将学习自底向上语法分析中一个核心但具有挑战性的任务:如何识别“句柄”。我们将探讨其理论上的困难,并介绍一类在实践中广泛使用的、能够有效识别句柄的语法。


识别句柄的挑战与机遇

上一节我们介绍了自底向上分析的基本概念,本节中我们来看看识别句柄的具体挑战。

识别句柄有一个坏消息和一个好消息。
坏消息是,对于任意的上下文无关文法,目前没有已知的高效算法能直接识别句柄。这意味着,在通用情况下,我们无法快速找到归约的时机。
好消息是,存在一些启发式方法,能够为一大类上下文无关文法正确地猜测出句柄。

我们可以用文法的包含关系来理解这个“大类”:
- 所有上下文无关文法 (CFG):这是最大的集合。
- 无二义性上下文无关文法:这是前者的一个子集,排除了会产生多种解析结果的文法。
- LR(k) 文法:这是一个更小的集合。
L代表从左到右扫描输入,R代表最右推导,k代表向前查看的符号数量。这是最一般的、能被确定性(即无回溯)解析的文法类。 - LALR(k) 文法:这是 LR(k) 文法的一个子集。虽然表达能力稍弱,但生成的解析表更小,是大多数实用解析器生成工具(如 Yacc, Bison)实际使用的文法类。
- SLR(k) 文法:这是 LALR(k) 文法的进一步简化,称为简单 LR 文法。

这些包含关系是严格的,即对于每个 k,都存在是 LR(k) 但不是 SLR(k) 的文法,也存在是 LR(k) 但不是 LALR(k) 的文法。

解析器的已知信息:可行前缀

既然检测句柄不简单,那么解析器在每一步都知道些什么呢?
解析器知道当前的栈的内容。因此,我们需要研究能从栈中获得多少信息。
我们定义:一个符号串 α 是一个可行前缀,如果存在某个剩余输入串 ω,使得 α | ω 是某个最右推导过程中的一个有效格局(即解析过程中的一个合法状态)。
- α 代表当前栈的内容。
- ω 代表剩余的输入。
- | 是栈顶与剩余输入的分隔符。

这个定义的意义在于:可行前缀是一个不会超过句柄右端边界的符号串。只要解析器栈上的内容是一个可行前缀,就意味着解析过程尚未出错,处于一个有效的移位-归约解析状态。

这个定义引出了自底向上解析的第三个关键事实:对于任何文法,其所有可行前缀构成的集合是一个正则语言。
这是一个非常重要的结论,意味着我们可以构造一个有限自动机来识别这些可行前缀。所有现代的自底向上解析器工具都基于这一事实。
记录解析状态:LR(0) 项
为了构造识别可行前缀的自动机,我们首先需要一个新的概念:LR(0) 项(简称项)。
一个项是在一个产生式的右部某处加了一个点(.)的产物。
例如,对于产生式 T -> ( E ),我们可以得到以下项:
T -> . ( E )T -> ( . E )T -> ( E . )T -> ( E ) .

对于空产生式 X -> ε,它只有一个项:X -> .。

项的含义是:
- 点
.左边的部分表示已经识别(在栈上看到) 的符号。 - 点
.右边的部分表示期望在未来看到才能完成归约的符号。
因此,项 T -> ( E . ) 表示:“我们正在试图识别一个 T,目前已经在栈上看到了 ( 和 E,期望接下来能看到 ) 来完成这个产生式”。
栈的结构与项集
解析器的栈并非符号的随机堆积。在成功的解析过程中,栈上的内容总具有一种特殊的结构:它是由若干个产生式右部的前缀层层嵌套组成的。
考虑输入 ( int ) 和文法 T -> ( E ), E -> int。当栈内容为 ( int,剩余输入为 ) 时:
- 栈顶的
int是产生式E -> int的(完整)右部前缀。 - 栈中的
(是产生式T -> ( E )的右部前缀。 - 此时,对应的项集可以记录为
{ T -> ( . E ), E -> int . }。这精确描述了当前状态:我们正在识别T,已看到(,期望看到E;同时,我们刚刚完成了E -> int的识别。

更一般的情况是,栈可以被视为一个“前缀栈”:
- 栈顶的前缀(例如
int)最终将归约为某个非终结符(例如E)。 - 归约得到的非终结符(
E)会成为栈中下一个(更深的)前缀(例如( . E ))所缺失的后缀的一部分,使其向完整的右部更近一步。 - 这个过程递归进行,直到栈底。
总结与预告

本节课中我们一起学习了识别句柄的核心思想。我们了解到:
- 通用地识别句柄是困难的,但存在一大类文法(LR(k) 及其子集)可以使用确定性方法处理。
- 解析器通过栈来跟踪状态,栈上的内容被称为可行前缀,其集合是正则语言。
- 我们使用 LR(0) 项 来精确描述解析器在识别某个产生式右部时所处的进度。
- 栈的结构本质上是嵌套的产生式右部前缀,项集可以有效地记录这种结构。

在下一个视频中,我们将基于“可行前缀可由有限自动机识别”这一关键事实,给出具体的算法来构造这个识别自动机,从而最终实现句柄的识别。

课程 P35:识别可行前缀算法 🧠

在本节课中,我们将学习自底向上解析中的核心技术:如何构造一个算法来识别给定文法的可行前缀。我们将从一个简单的文法示例出发,逐步构建一个非确定性有限自动机(NFA),该自动机能够判断解析器栈的内容是否构成一个可行前缀。
概述与准备
上一节我们介绍了可行前缀的概念及其重要性。本节中,我们来看看如何构造一个自动机来识别它们。
首先,为了简化算法,我们通常会对原始文法进行一个小的修改:添加一个虚拟的开始符号和产生式。
- 具体做法:给定文法 G,其开始符号为 S。我们创建一个新的开始符号 S',并添加一条新的产生式:S' → S。
- 目的:这使我们能明确解析的起点(即栈底的目标),让后续的状态和转换定义更加清晰。
构建识别可行前缀的 NFA
我们的目标是构建一个 NFA,它从栈底到栈顶读取解析器的栈内容,并判断该内容是否是一个可行前缀。该 NFA 的状态由文法的项目(Item)构成。
NFA 的核心规则
以下是构建该 NFA 的两条核心转换规则。
规则一:移进(Shift)或匹配符号
假设当前状态对应的项目是 A → α · x β。这表示自动机已经在栈上“看到”了符号串 α,并期望接下来能看到符号 x(x 可以是终结符或非终结符)。
- 转换条件:如果栈上的下一个符号确实是 x。
- 动作:自动机可以转移到新状态 A → α x · β。这表示它已成功“消耗”了栈上的符号 x,并继续等待 β 的出现。
公式表示:
对于文法中的每个项目 [A → α · x β](其中点不在最右端),存在一个转换:
[A → α · x β] --x--> [A → α x · β]

规则二:展开(Expand)或猜测推导
假设当前状态对应的项目是 A → α · B β,且点后面紧跟的是一个非终结符 B。这表示自动机期望在栈顶看到能最终归约为 B 的符号串。
- 转换条件:栈顶可能不是 B 本身,而是由 B 推导出来的某个符号串的开头。
- 动作:自动机可以进行一个 ε-转移(不消耗栈符号),直接“跳转”到以 B 为左部、且点在最左侧的所有产生式项目。这相当于猜测:“接下来栈上的内容将由 B 推导产生,让我开始尝试识别 B 的右部”。
公式表示:
对于每个形如 [A → α · B β] 的项目(B 为非终结符),以及文法中每个以 B 为左部的产生式 B → γ,存在一个 ε-转换:
[A → α · B β] --ε--> [B → · γ]
NFA 的起始与接受
- 起始状态:由于我们添加了虚拟产生式
S' → S,因此 NFA 的起始状态是项目[S' → · S]。这表示自动机初始时期望看到能从 S 推导出的内容。 - 接受状态:该 NFA 的所有状态都是接受状态。这意味着,只要自动机能成功消耗完整个栈的内容(即读完栈顶)而不在中途“卡住”(没有可用的转换),那么它读取的栈内容就被识别为一个可行前缀。
实例解析
让我们通过一个熟悉的表达式文法来具体看看这个 NFA 是如何构建的。
原始文法 G:
E → T
E → T + E
T → int
T → ( E )
T → int * T
增强后的文法 G'(添加虚拟开始符号):
S' → E
E → T
E → T + E
T → int
T → ( E )
T → int * T
现在,我们根据上述规则,从起始状态 [S' → · E] 开始,逐步推导出整个 NFA 的状态和转换。下图展示了为文法 G‘ 构建的、用于识别可行前缀的完整 NFA(为清晰起见,部分 ε-转换用虚线表示):

(注:此图展示了自动机的复杂性,它包含大量状态和转换。)
状态与转换分析
让我们追踪其中几条路径,理解自动机的工作逻辑:
-
从起始状态开始:状态
[S' → · E]表示我们期望看到能从 E 推导出的内容。由于 E 是非终结符,我们应用规则二,通过 ε-转换跳转到所有 E 产生式的起始项目:[E → · T]和[E → · T + E]。这体现了非确定性“猜测”的能力:自动机同时尝试两种可能。 -
移进终结符:考虑状态
[T → ( · E )]。这里点后面是终结符((实际上应为open,这里用(代指)。根据规则一,如果栈上的下一个符号是(,自动机可以转移到[T → ( E · )]。 -
期待非终结符的展开:在状态
[T → ( · E )]中,点后面是非终结符 E。除了可能直接移进 E(如果它恰好在栈上),根据规则二,自动机还可以通过 ε-转换跳转到[E → · T]和[E → · T + E],开始尝试识别 E 的右部。 -
识别句柄:当自动机进入一个点在最右端的项目时,例如
[T → int ·]或[E → T + E ·],这表示它已经在栈上识别出了一个完整的产生式右部。这个完整的右部就是一个句柄,提示解析器可以进行归约操作。
通过这种方式,NFA 并行地探索所有可能的语法分析路径。只要栈内容对应至少一条有效的语法推导前缀,就至少有一条路径能让 NFA 顺利读完整个栈。
总结

本节课中,我们一起学习了如何为给定文法构造一个识别可行前缀的非确定性有限自动机(NFA)。
- 核心思想:将文法的项目作为自动机的状态,并定义两类转换规则:
- 移进规则:匹配栈上预期的下一个语法符号。
- 展开规则:当预期一个非终结符时,通过 ε-转换开始尝试识别该非终结符的各个产生式。
- 工作方式:该 NFA 从栈底向栈顶读取,利用非确定性并行探索所有可能的语法分析状态。若能无阻塞地消耗完整个栈,则栈内容是一个可行前缀。
- 重要意义:这个识别可行前缀的 NFA,是后续构建更高效、实用的LR 解析表(如 LR(0)、SLR、LR(1) 等)的理论基础和构造起点。它建立了栈内容(语法分析状态)与文法项目之间的精确对应关系。
通过这个自动机,我们为自底向上解析器提供了判断当前分析动作是否“安全”的依据,这是实现高效、准确语法分析的关键一步。
课程 P36:有效项(Valid Items) 🧩

在本节课中,我们将学习编译原理中的一个核心概念——有效项。我们将通过一个具体的示例自动机,来理解如何识别有效前缀,并深入探讨有效项的定义和意义。

有效项的概念

上一节我们介绍了用于识别语法有效前缀的非确定自动机(NFA)及其对应的确定自动机(DFA)。本节中,我们来看看什么是“有效项”。
为了唤醒你的记忆,下图是我们上次停止的地方。这是一个完整的非确定自动机,用于识别示例语法的有效前缀。

通过使用标准的子集构造法,我们可以构建一个等效于该非确定自动机的确定自动机。

这个确定自动机识别与NFA完全相同的语言,即示例语法的有效前缀。现在请注意,DFA中的每个状态都是一个项集(Item Set)。这些状态包含了NFA可能处于的状态集合。
回忆一下,这意味着NFA可能处于这些状态中的任何一个。特别是,这个状态是起始状态,因为它包含了项 S' -> .E。在《龙书》中,这种DFA的状态被称为规范LR(0)项集。
注:《龙书》给出了另一种构建LR(0)项集的方法,与我给出的方法略有不同。我的方法有所简化,但我认为对于初学者来说更容易理解。
现在,我们需要引入一个新的定义。

有效项的定义
我们将说,一个项对于有效前缀 αβ 是有效的,如果满足以下条件:
存在一个从起始符号 S' 出发的推导:S' =>* αAω => αβγω。
更直观地解释,在解析了前缀 αβ 之后(即 αβ 已经在栈上),有效项描述了此时解析器可能处于的“状态”集合。它指明了接下来可能应用的产生式规则。
一个更简单的理解方式是:对于一个给定的有效前缀 α,对该前缀有效的项,正是DFA在读取该前缀后,所到达的最终状态中包含的项。这些项描述了在看到栈内容 α 之后,解析器可能面临的所有情况。

有效项的性质
一个项通常对许多不同的前缀都有效。
例如,考虑项 E -> ( . E )。下图展示了它在自动机中的情况:


通过查看自动机可以确认,如果我们从起始状态开始,读取一个开括号 (,我们会进行状态转换,最终到达包含该项的状态。之后,每多读一个开括号,我们都会在这个状态中循环。

因此,如果输入序列是5个开括号,我们会在这个状态循环5次。请注意,项 E -> ( . E ) 始终存在于这个状态中。这仅仅意味着,此项对任何由开括号组成的前缀序列都是有效的。
总结
本节课中,我们一起学习了有效项的概念。我们了解到:
- 有效项与有效前缀紧密相关,它描述了在解析器处理了某个有效前缀后,接下来可能应用的语法规则。
- 可以通过观察识别有效前缀的确定有限自动机(DFA) 来找到对某个前缀有效的所有项:即DFA读完该前缀后所处状态中包含的项集。
- 一个项可能对多个不同的前缀有效,这反映了语法中规则应用的灵活性。
理解有效项是构建LR类语法分析器(如LR(0)、SLR、LR(1)分析器)的关键一步,它帮助我们确定在解析的每一步应该采取“移进”还是“归约”动作。

编译原理课程 P37:SLR 解析算法详解 🧩

在本节课中,我们将学习如何实现一个自底向上的解析算法,具体来说,我们将深入探讨 SLR(简单LR)解析。SLR解析建立在 有效项 和 可行前缀 这两个核心概念之上,这些概念我们在之前的课程中已经介绍过。

从 LR(0) 解析开始
上一节我们介绍了可行前缀和有效项的概念,本节中我们来看看如何基于这些概念构建一个基础的解析算法。
首先,我们定义一个非常基础的自底向上解析算法,称为 LR(0) 解析。其核心思想如下:
- 假设解析栈中的内容是
α,下一个输入标记是t。 - 我们有一个识别可行前缀的 确定性有限自动机(DFA)。当它读取栈内容
α后,会终止于某个状态s。

解析算法只需要处理两种情况:
- 归约:如果状态
s是 DFA 的 最终状态,并且包含一个形如X -> β.的项(点在最右端),这意味着我们在栈顶看到了产生式X -> β的完整右侧(β)。此时,我们可以通过这个产生式进行归约。 - 移进:如果状态
s包含一个形如X -> β . t γ的项(点后面是终结符t),这意味着在当前栈内容α后添加一个t是合适的。如果下一个输入正好是t,那么我们就应该执行移进操作。
LR(0) 解析的冲突问题

LR(0) 解析在什么情况下会遇到麻烦呢?主要有两种冲突:
以下是两种主要的冲突类型:
- 归约-归约冲突:如果 DFA 的某个状态包含两个(或更多)形如
A -> β.和B -> γ.的项,这意味着栈顶同时看到了两个完整产生式的右侧。解析器没有足够的信息来决定执行哪一个归约,算法将不再是确定性的。 - 移进-归约冲突:如果 DFA 的某个状态同时包含一个形如
X -> β.的项(指示归约)和一个形如Y -> γ . t δ的项(指示如果下一个输入是t则移进)。在这种情况下,解析器无法决定是应该移进t还是执行归约。
让我们看看之前课程中构建的、用于识别可行前缀的 DFA。这个 DFA 确实存在一些冲突。
- 在某个状态,我们既可以按
E -> T进行归约,也可以(如果下一个输入是+)进行移进。这个状态存在一个 移进-归约冲突。 - 在另一个状态,我们既可以按
T -> int进行归约,也可以(如果下一个输入是*)进行移进。这个状态同样存在一个 移进-归约冲突。

改进 LR(0) 解析并不困难。接下来,我们将介绍一种改进方法——SLR 解析。
改进为 SLR 解析
上一节我们看到了 LR(0) 解析的局限性,本节中我们来看看如何通过添加启发式信息来解决冲突,从而得到 SLR 解析。

将 LR(0) 解析修改为 SLR 解析的改动实际上非常小。我们只是在 归约 的情况下添加了一个新的条件。
- LR(0) 归约条件:如果状态包含
X -> β.,则总是可以归约。 - SLR 归约条件:如果状态包含
X -> β.,并且下一个输入符号t属于 FOLLOW(X)(即非终结符X的跟随集),我们才执行归约。
这个改变背后的逻辑是:归约操作将栈顶的 β 替换为 X。如果下一个输入符号 t 根本不可能出现在 X 之后(即 t 不在 FOLLOW(X) 中),那么这次归约就是没有意义的。通过利用输入流中下一个符号的信息,我们可以更精确地决定何时归约。

在这些新规则下,如果解析表中仍然存在冲突(无论是移进-归约还是归约-归约),那么这个语法就不是 SLR(1) 语法。SLR 解析利用了两个信息源:
- 栈的内容(由 DFA 分析)。
- 输入中的下一个符号(用于细化归约决策)。
示例:SLR 如何解决冲突
让我们看看 SLR 规则如何解决之前示例中的冲突。
回顾之前存在移进-归约冲突的两个状态:
-
第一个状态(包含
E -> T.和E -> E . + T):- 移进:如果下一个输入是
+,根据项E -> E . + T,我们应该移进。 - 归约:根据 SLR 规则,只有当下一个输入属于
FOLLOW(E)时,我们才按E -> T归约。FOLLOW(E)包含$(输入结束)和)。 - 结果:对于输入
+,唯一动作是移进;对于输入$或),唯一动作是归约。冲突解决。
- 移进:如果下一个输入是
-
第二个状态(包含
T -> int.和T -> T . * int):- 移进:如果下一个输入是
*,我们应该移进。 - 归约:只有当下一个输入属于
FOLLOW(T)时才归约。FOLLOW(T)包含+,),$。 - 结果:对于输入
*,唯一动作是移进;对于输入+,)或$,唯一动作是归约。冲突解决。
- 移进:如果下一个输入是

因此,这个语法是 SLR(1) 语法。许多语法不是 SLR(1),所有歧义语法都不是 SLR(1)。但我们可以通过 优先级声明 来进一步扩展 SLR 解析器的能力。
优先级声明的作用
考虑最自然的(也是歧义的)加法和乘法表达式语法:
E -> E + E | E * E | int
为这个语法构建 DFA,会发现在某个状态同时包含项 E -> E * E.(可归约)和 E -> E . + E(如果输入是 + 则可移进)。这正好对应了“乘法是否比加法优先级高”的问题。

- 如果选择 归约,意味着先将
E * E组合起来,体现了乘法优先级更高。 - 如果选择 移进,则会让加号先入栈,可能导致加法先被计算。
在这种情况下,我们可以通过声明“* 的优先级高于 +”来解决这个移进-归约冲突。解析器生成工具(如 Yacc/Bison)会利用这个声明,在冲突时选择 归约,从而强制执行我们期望的优先级。
注意:“优先级声明”这个术语有些误导。它并不直接定义优先级,而是定义了当解析表出现冲突时应该如何解决。在简单的算术表达式语法中,这种冲突解决恰好产生了优先级的效果。对于更复杂的语法,建议检查生成的解析自动机,以确保冲突解决符合预期。

SLR 解析算法描述
现在,我们可以给出完整的 SLR 解析算法。设 M 是识别可行前缀的 DFA。
算法步骤如下:
- 初始化:初始配置为
| ω$(栈为空,输入为ω并在末尾附加结束符$)。 - 循环:重复以下步骤,直到配置变为
E | $(栈中只剩开始符号E,输入只剩$)。- 设当前配置为
α | tω,其中α是栈内容,t是下一个输入符号。 - 让 DFA
M读取栈内容α,到达状态s。 - 检查状态
s中的项和下一个输入符号t:
a. 移进:如果s包含形如X -> β . t γ的项,则执行移进:配置变为αt | ω。
b. 归约:如果s包含形如X -> β.的项,且t ∈ FOLLOW(X),则执行归约:从栈顶弹出|β|个符号,压入X,配置变为α‘ X | tω(其中α‘是弹出后的栈)。
c. 接受:如果配置为E | $,则解析成功。
d. 报错:如果以上都不适用,则报告语法错误。
- 设当前配置为
关于冲突:如果在构造解析表时,发现某个状态对某个输入符号存在多个可选动作(多个移进、多个归约或移进归约皆有),则该语法不是 SLR(1) 语法。实践中,k 通常为 1,即只前瞻一个输入符号。

总结

本节课中我们一起学习了 SLR 解析算法。我们从基础的 LR(0) 解析出发,了解了它因缺乏前瞻信息而容易产生 移进-归约冲突 和 归约-归约冲突。为了解决这个问题,SLR 解析引入了一个关键改进:只有在下一个输入符号属于 归约产生式左部非终结符的 FOLLOW 集 时,才执行归约。这使得解析决策更加精确,能够处理一大类实用的上下文无关文法。我们还了解了如何通过 优先级声明 来指导解析器解决特定的冲突,从而处理像表达式优先级这样的常见需求。SLR 是构建高效、确定性自底向上解析器的重要基础。
📚 课程 P38:SLR 解析示例详解

在本节课中,我们将通过一个具体的例子,详细学习 SLR 解析器的工作过程。我们将解析输入 int * int,并一步步跟踪解析器的状态、栈和输入的变化,以理解移入和归约动作是如何协同工作,最终完成语法分析的。

🧠 概述:解析器初始状态
首先,我们回顾一下解析器自动机。这是一个确定性自动机,我们在之前的课程中已经构建完成。所有状态都已编号。
解析开始时,我们在输入末尾添加了美元符号 $ 作为结束标记。此时,我们尚未读取任何输入,解析器指针位于输入的最左侧。
解析器从状态 1 开始,栈为空。以下是状态 1 中有效的项目:
E -> .TT -> .intT -> .T * int
在这些项目中,点 . 都位于最左侧,表示我们期待看到这些符号。当前输入是 int,因此没有归约动作的可能,唯一可能的操作是移入。
初始配置总结:
- 栈: 空
- 输入:
int * int $ - DFA 状态: 状态 1
- 动作: 移入
int
🔄 第一步:移入 int
根据状态 1 的指示,我们执行移入动作,将输入中的 int 移入栈中。
执行后,配置变为:
- 栈:
int - 输入:
* int $ - 动作: 移入完成
现在,自动机需要根据新的栈内容 int 来决定下一个状态。它从栈底开始读取:
- 从初始状态开始。
- 读取栈上的
int,进入状态 3。
状态 3 中的项目告诉我们接下来能做什么:
T -> int .(点在最右边,表示可以归约)T -> T .* int(点后面是*,表示可以移入*)
此时,我们需要根据向前看符号(即输入中的下一个标记 *)来决定动作。* 是否在 T 的后继集合中?检查语法规则 T -> T * int,可知 * 是 T 的后继。因此,移入 * 是可行的,而归约 T -> int 不可行(因为 * 不在 int 的后继集合中)。
所以,DFA 保持在状态 3,并决定移入 *。
🔄 第二步:移入 *
我们执行移入动作,将输入中的 * 移入栈顶。
执行后,配置变为:
- 栈:
int * - 输入:
int $ - 动作: 移入完成
自动机再次读取整个栈 int * 来决定状态:
- 从初始状态开始。
- 读取
int,进入状态 3。 - 读取
*,进入状态 11。
状态 11 中只有一项:T -> T * . int。点后面是 int,而输入中的下一个符号正是 int。因此,唯一的动作是移入 int。
🔄 第三步:移入第二个 int
我们执行移入动作,将输入中最后一个 int 移入栈中。
执行后,配置变为:
- 栈:
int * int - 输入:
$ - 动作: 移入完成,输入结束
现在栈内容是 int * int,输入已结束(只剩下 $)。自动机读取栈:
- 读取
int,进入状态 3。 - 读取
*,进入状态 11。 - 读取
int,最终回到状态 3(根据T -> T * int .项目)。
此时,状态 3 中的项目再次是:
T -> int .(可归约)T -> T .* int(可移入*,但输入是$,不匹配)
输入是 $,它是否在 T 的后继集合中?检查语法 E -> T 和 T -> T * int,$ 可以是 T 的后继(当 T 作为 E 的一部分时)。因此,归约动作 T -> int 是可行的。
我们执行归约 T -> int。这意味着将栈顶的 int 替换为非终结符 T。
🔄 第四步:归约 T -> int
执行归约后,栈内容发生变化:
- 栈(归约前):
int * int - 栈(归约后):
int * T - 输入:
$
关键点: 栈的内容发生了改变,我们不是简单地添加符号,而是用非终结符 T 替换了栈顶的 int。这导致 DFA 需要沿着一条新的路径重新计算状态。
自动机读取新栈 int * T:
- 读取
int,进入状态 3。 - 读取
*,进入状态 11。 - 读取
T,最终进入状态 4。
状态 4 中的项目是 T -> T * T .。点在最右边,表示可以归约。同样,检查向前看符号 $ 是否在 T 的后继集合中(根据更高层的语法规则)。是的,$ 在 T 的后继集合中。因此,我们执行归约 T -> T * T。
🔄 第五步:归约 T -> T * T
执行归约 T -> T * T。这意味着将栈顶的三个符号 T * T 替换为一个 T。
执行后,配置变为:
- 栈:
T - 输入:
$
栈内容发生了根本性变化。自动机读取栈顶的 T,进入状态 5。
状态 5 中的项目是:
E -> T .(可归约)E -> E .+ T(可移入+,但输入是$,不匹配)
输入是 $,它是否在 E 的后继集合中?对于开始符号 E(或 S‘),$ 是其后继。因此,我们执行归约 E -> T。

🔄 第六步:归约 E -> T 与接受
执行归约 E -> T,将栈顶的 T 替换为 E。
执行后,配置变为:
- 栈:
E - 输入:
$
自动机读取栈顶的 E,进入状态 2。
状态 2 中只有一项:S‘ -> E .。点在最右边,并且向前看符号是 $,这正好是开始符号 S‘ 的后继。这表示解析成功,我们执行接受动作。

✅ 总结
本节课中,我们一起学习了 SLR 解析器解析输入 int * int 的完整过程。我们一步步跟踪了解析器的状态、栈和输入的变化:
- 初始状态:栈空,准备移入。
- 移入阶段:依次移入
int、*、int,同时 DFA 根据栈内容转换状态。 - 归约阶段:当输入结束或遇到合适的前看符号时,根据项目点的位置和 FOLLOW 集,执行归约动作(
T -> int和T -> T * T),用非终结符替换栈顶的符号串。 - 路径变化:归约动作会改变栈的内容,导致 DFA 重新计算状态,可能走上与之前不同的路径。
- 最终接受:当栈中只剩下开始符号
E(或S‘),且输入结束时,解析器接受该输入字符串。
通过这个详细的例子,你应该对 SLR 解析器中移入-归约的冲突解决、状态转换以及 FOLLOW 集的作用有了更直观的理解。

编译原理课程 P39:SLR解析算法改进与完整实现 🚀

在本节课中,我们将结束关于SLR(简单LR)解析的讨论。我们将介绍完整的SLR解析算法,并探讨如何通过优化栈操作来提升其效率。最后,我们会简要介绍比SLR更强大的LR解析算法。
回顾SLR解析的低效问题

上一节我们介绍了SLR解析的基本概念。本节中我们来看看原始SLR算法中存在的一个主要低效问题。
原始SLR解析算法在每一步都需要重新扫描整个栈,以确定自动机的状态。考虑解析过程中的栈操作:每一步可能会压入或弹出符号,但栈的大部分内容保持不变。重新运行自动机在整个栈上意味着大量重复工作。

以下是栈操作的示意图:
栈底 [符号1, 符号2, ..., 符号n-1] 栈顶
每一步只在栈顶进行微小改动,却要重新计算整个栈对应的自动机状态,这显然是可以优化的。

改进思路:在栈中存储状态

利用“自动机大部分工作重复”的观察,我们可以改进算法。核心思想是:在每一步中,记住自动机在每个栈前缀上的状态。
我们将改变栈的表示方式。之前,栈中只存放符号。现在,栈中的每个元素将是一个 (符号, DFA状态) 对。

改进后的栈结构如下:
栈底 [(占位符, 状态1), (符号1, 状态2), ..., (符号n, 状态m)] 栈顶
其中,每个存储的DFA状态是运行DFA在其左侧所有栈内容上的结果。栈底需要一个起始状态,通常用一个占位符符号(如$)和DFA的初始状态S0配对。
完整的SLR解析表与算法
基于改进的栈结构,我们现在可以定义完整的SLR解析算法。算法依赖于两张表:
- GOTO表:这是一个二维数组,表示DFA的转换函数。
GOTO[状态i, 符号X] = 状态j意味着从状态i遇到符号X时,DFA将转移到状态j。 - ACTION表:这也是一个二维数组,由当前栈顶状态和下一个输入符号索引,指示解析器应执行的动作。
ACTION表可能包含四种动作:

- Shift (移进):
shift sj - Reduce (规约):
reduce by A -> β - Accept (接受):
accept - Error (报错):
error
以下是确定ACTION表项的规则:
- Shift动作: 如果栈顶状态
si有一个项目表明可以移进输入符号a并进入状态sj(即GOTO[si, a] = sj),则ACTION[si, a] = shift sj。 - Reduce动作: 如果栈顶状态
si有一个完整项目(点在最右端)A -> α.,并且下一个输入符号a属于FOLLOW(A)集合,则ACTION[si, a] = reduce by A -> α。有一个例外:如果规约的产生式是S' -> S.(其中S'是文法的增强开始符号),且下一个输入是结束符$,则执行accept动作。 - Error动作: 所有其他未定义的情况都标记为
error。
算法步骤详解
以下是结合了状态栈的完整SLR解析算法步骤:
-
初始化:
- 设输入字符串为
w$($是结束符)。 - 初始化栈为
[(任何占位符, 状态1)],其中状态1是DFA的起始状态。 - 设置输入指针
ip指向w的第一个符号。
- 设输入字符串为
-
主循环:
重复以下步骤,直到接受或报错:
a. 设s为当前栈顶的状态(即栈顶对的第二个元素)。
b. 设a为ip所指向的当前输入符号。
c. 查询动作表ACTION[s, a],根据结果执行相应操作:
* 如果ACTION[s, a] = shift s':
* 将符号-状态对(a, s')压入栈中。
* 将输入指针ip前进到下一个符号。
* 如果ACTION[s, a] = reduce by A -> β:
* 从栈顶弹出|β|(β的长度)个符号-状态对。
* 设新的栈顶状态为s''。
* 查询GOTO[s'', A] = s_new。
* 将符号-状态对(A, s_new)压入栈中。
* (注意:输入指针ip在此步骤中不前进)。
* 如果ACTION[s, a] = accept:
* 解析成功,终止循环。
* 如果ACTION[s, a] = error:
* 调用错误恢复例程或报告语法错误。

从SLR到更强大的LR解析
简单LR解析被称为“简单”是有原因的。在实践中,它对于某些文法的处理能力有限。更广泛使用的自底向上解析算法基于更强大的 LR文法。

LR文法与SLR文法的核心区别在于 向前看符号(Lookahead)被直接编码到了项目项中。
- LR(1)项 是一个二元组
[A -> α . β, a],其中:A -> α . β是标准的LR(0)项。a是一个向前看符号(终结符)。
- 这个项的含义是:当栈顶内容匹配
α(即β已全部被看到),并且下一个输入符号是a时,才可以按A -> αβ进行规约。
这比SLR中简单地使用 FOLLOW(A) 集合来决定规约要精确得多。SLR可能允许在 FOLLOW(A) 中但实际上下文中不可能出现的符号触发规约,导致冲突。LR(1)通过精确的向前看信息避免了这种情况。
实践中,最常用的是 LALR(1)(向前看LR)解析器,它是对LR(1)的一种优化,在保持强大解析能力的同时,大大减少了自动机的状态数,使其更适用于实际编译器构造。
总结
本节课中我们一起学习了:
- SLR解析的优化:通过将DFA状态与符号一同存入栈中,避免了每一步重新扫描整个栈的低效操作。
- 完整的SLR算法:定义了 GOTO表 和 ACTION表,并详细阐述了基于状态栈的解析步骤。
- 更强大的解析器:简要介绍了 LR(1) 和 LALR(1) 解析器,它们通过将向前看符号集成到项目项中,提供了比SLR更精确、更强大的语法分析能力。

理解SLR是学习更复杂自底向上解析算法的基础。虽然实际编译器(如GCC、Clang的早期版本)多使用LALR(1)或更现代的算法,但SLR的原理和优化思想仍然至关重要。
课程 P4:Cool 语言概述 🧑🏫
在本节课中,我们将学习 Cool 语言的基本概念、设计目标以及如何编写和运行一个最简单的 Cool 程序。Cool 是一门面向对象的课堂教学语言,其核心设计目标是让学生能在一个学期内编写出完整的编译器。
语言设计目标 🎯
Cool 是“面向对象的课堂语言”的缩写。其独特的设计要求是,编译器必须在相对较短的时间内编写完成。学生通常只有一个学期的时间来编写编译器,因此它必须易于快速实现。Cool 主要用于教学编译器设计。
世界上 Cool 编译器的数量远超 Cool 程序的数量。可能有成千上万个 Cool 编译器被编写出来,但只有几十或几百个 Cool 程序。这可能是唯一一种编译器数量超过程序数量的语言。这揭示了 Cool 的主要设计要求:编译器易于编写比程序易于编写更重要。
为了简化实现而不损害教学价值,语言包含了一些“怪癖”,这些特性对于日常编程工作可能并不方便。
语言特性概览 📚
Cool 的设计旨在让你体验现代编程语言的抽象概念,包括:
- 静态类型
- 通过继承实现代码重用
- 自动内存管理
当然,语言中也省略了许多内容,因为我们无法将所有功能都塞进一门语言并期望它能被快速实现。本课程将涵盖其中一部分,但遗憾的是,一些有趣的语言思想无法在本课中涉及。

课程项目:构建 Cool 编译器 ⚙️
本课程的项目是构建一个完整的编译器。具体来说,你将把 Cool 语言编译到 MIPS 汇编语言。MIPS 是为 80 年代设计的机器指令集,现在有可以在任何硬件上运行的模拟器,这使得整个项目具有很好的可移植性。
运行你的编译器,它会生成 MIPS 汇编代码,然后这些代码可以在任何机器上的模拟器中运行。
项目被分为 5 个作业:
- 首先,你将编写一个 Cool 程序,这个程序本身将是一个解释器,以获得编写简单解释器的经验。
- 编译器本身将包括我们讨论的 4 个阶段:词法分析、语法分析、语义分析和代码生成。
所有这些阶段都是“可插拔”的,这意味着我们有每个阶段的独立参考实现。例如,当你正在处理语义分析时,你可以从参考编译器中取出词法分析、语法分析和代码生成组件,并将你的语义分析模块插入该框架中进行测试。
这种方式的好处是,如果你在某个组件上遇到困难,或者不确定某个组件是否工作良好,你可以独立地测试它,而不会在处理其他组件时遇到问题。
最后,虽然没有强制性的优化作业,但我们提供了一些建议的优化方案。许多学生已经为 Cool 编写了优化器,这是一个可选的作业。如果你对程序优化感兴趣,可以尝试。
编写第一个 Cool 程序 ✍️
上一节我们介绍了 Cool 语言和课程项目。本节中,我们来看看如何编写和运行一个最简单的 Cool 程序。
首先要知道的是,Cool 源文件的扩展名是 .cl。你可以使用任何文本编辑器来编写程序。
每个 Cool 程序都必须有一个名为 Main 的类。类声明以关键字 class 开始,后跟类名(这里是 Main)和一对花括号 {},花括号内是属于该类的内容。每个类声明必须以分号 ; 结束。
程序由类声明列表组成,每个类声明以分号结束。
现在我们需要让这个类做点事情,所以我们将在类中定义一个方法,我们称其为 main。事实上,Main 类的 main 方法必须始终存在,这是程序启动时执行的方法。此外,此方法必须不接受任何参数。
方法的参数列表始终为空,方法体位于一对花括号 {} 中。类由这样的声明列表组成,并且声明之间必须用分号分隔。即使类中只有一个方法,它也必须以分号结束。
现在,我们可以定义方法具体要做什么。Cool 是一种表达式语言,这意味着在可以放置代码的任何地方,你都可以放置任意表达式。方法没有显式的 return 语句,方法体的值就是整个方法的返回值。
以下是一个最简单的 Cool 程序,它仅仅返回数字 1:
class Main {
main(): Int {
1
};
};
编译与运行 🚀
Cool 编译器称为 coolc。要编译程序,只需给编译器一个 Cool 源文件列表。这里我们只有一个文件 one.cl:
coolc one.cl
如果编译成功,你会看到目录中生成一个新文件 one.s,这就是程序的 MIPS 汇编代码。
要运行代码,我们使用 MIPS 模拟器 spim。只需将汇编文件交给它:
spim one.s
它会运行并打印很多信息,包括执行的指令数、加载/存储次数、分支数等性能统计。如果程序成功执行,你会看到类似“程序成功执行”的消息。
添加输出功能 📤

上面的程序虽然运行成功,但并没有在屏幕上输出任何内容,因为它只是返回了一个值。若要在 Cool 程序中打印内容,你必须显式地调用输出方法。

Cool 有一个内置的原始类 IO,我们可以声明一个 IO 类型的属性(字段),然后使用该对象来执行输入/输出操作。
以下是修改后的程序,它打印“Hello, world!”并返回数字 1:
class Main {
i: IO <- new IO; -- 声明并初始化一个 IO 类型的属性 i
main(): Int {
{
i.out_string("Hello, world!\n"); -- 调用 out_string 方法打印字符串
1; -- 块中最后一个表达式的值成为整个块的值
}
};
};
关键概念解释:
i: IO <- new IO;声明了一个名为i、类型为IO的属性,并用new IO表达式将其初始化为一个新的IO对象。- 方法体是一个表达式块,由花括号
{}包裹,内部是一系列用分号;分隔的表达式。 - 表达式块按顺序评估其中的表达式,整个块的值是最后一个表达式的值。
\n是字符串中的换行符。
编译并运行这个程序,你将在屏幕上看到“Hello, world!”。
程序的其他写法 🔄
为了让你更熟悉 Cool 的语法,这里展示几个实现相同功能的替代写法:
1. 更改返回类型
由于 out_string 方法返回的是 IO 对象,我们可以将 main 方法的返回类型改为 IO 或所有类的根类 Object。
class Main {
main(): IO { -- 返回类型改为 IO
(new IO).out_string("Hello, world!\n")
};
};
2. 通过继承获得 IO 功能
让 Main 类继承自 IO 类,这样 Main 的对象本身就拥有了 IO 的所有方法。
class Main inherits IO { -- Main 继承自 IO
main(): Object { -- 返回类型可以是 Object
out_string("Hello, world!\n") -- 直接调用继承来的方法,默认对象是 self
};
};
关键概念解释:
inherits IO表示Main类继承了IO类的所有属性和方法。self是当前对象的名称(类似于 Java/C++ 中的this)。- 当调用方法时没有明确指定对象,则默认派发给
self。
总结 📝

本节课中,我们一起学习了 Cool 语言。我们了解了它的教学导向设计目标,知道了课程项目是构建一个完整的、将 Cool 编译到 MIPS 汇编的编译器。我们动手编写了第一个 Cool 程序,学习了基本的程序结构、类与方法定义、表达式块以及如何使用 IO 类进行输出。我们还看到了通过继承来复用代码的简洁写法。

在接下来的视频中,我们将看到更多 Cool 编程的例子,进一步探索这门语言。

课程 P40:SLR解析示例详解 🧩


在本节课中,我们将通过两个具体的语法示例,学习如何构建SLR解析自动机,并判断一个语法是否为SLR(1)文法。我们将从简单的例子开始,逐步过渡到更复杂的结构,以理解SLR解析中的关键概念和潜在冲突。
示例一:简单左递归语法
上一节我们介绍了SLR解析的基本概念,本节中我们来看看一个非常简单的例子。考虑以下语法:
S -> S a
S -> b
这个语法生成一个以 b 结尾、前面有任意数量 a 的字符串。请注意,该语法是左递归的。对于自底向上的解析器(如SLR解析器)来说,左递归语法是完全可接受的。
构建解析自动机
第一步是向语法中添加一个新的开始产生式。出于技术原因,我们需要一个新的开始符号 S',其产生式为:
S' -> S
解析自动机NFA的起始状态是项 S' -> .S。我们直接计算DFA的状态,而不显式构建NFA。
以下是DFA第一个状态必须包含的项。由于点 . 紧跟在非终结符 S 之后,这意味着在NFA中存在ε转换,可以到达所有以 S 为左部的产生式的初始项。因此,第一个状态包含以下三项:
S' -> .SS -> .S aS -> .b
现在,我们必须考虑每个可能出现在栈上的符号所对应的转换。
以下是可能的转换情况:
- 如果看到输入
b,则进入状态:S -> b. - 如果看到输入
S,则进入状态:S' -> S.和S -> S. a
状态 S -> b. 和 S' -> S. 都只有一项,且点都在最右端,表示这些状态已完成,唯一的动作是规约。
冲突分析
初始状态只有移入动作,没有规约动作,因此不存在冲突。已完成的状态也只有一个规约动作,没有冲突。
唯一需要关注的是状态 S' -> S. 和 S -> S. a。在此状态下,我们可以:
- 根据
S' -> S.进行规约。 - 或者,将
a移入栈中。
要判断是否存在移入-规约冲突,我们需要查看 S' 的FOLLOW集。S' 是开始符号,其后面只能跟输入结束符 $。这意味着:
- 如果输入是
$,则规约。 - 如果输入是
a,则移入。
由于 a 不在 S' 的FOLLOW集中,因此在此状态下没有冲突。结论:该语法是SLR(1)文法。

示例二:稍复杂的语法
在理解了简单示例后,我们来看一个稍复杂的扩展语法。语法如下:
S -> S a S
S -> b
同样,我们需要添加新的开始产生式:S' -> S。
构建解析自动机
起始状态与之前类似,包含以下项:
S' -> .SS -> .S a SS -> .b
根据可能的输入符号进行状态转换:
以下是状态转换的详细情况:
- 看到
b:进入状态S -> b. - 看到
S:进入状态S' -> S.和S -> S. a S - 看到
a:从状态S -> S. a S出发,进入新状态S -> S a .S。由于点.后是非终结符S,需要加入S的所有产生式初始项,因此该状态包含:S -> S a .SS -> .S a SS -> .b
从这个新状态出发,可能的转换有:
- 看到
b:进入S -> b. - 看到
S:进入S -> S a S.
状态 S -> S a S. 的唯一可能输入是 a,看到 a 会转换到状态 S -> S a .S,从而形成循环。
冲突分析
我们检查所有状态是否存在移入-规约或规约-规约冲突。没有状态包含多个规约项,因此不存在规约-规约冲突。
我们重点关注包含可能冲突动作的状态。状态 S' -> S. 和 S -> S. a S 与第一个例子相同,没有冲突。
关键状态是 S -> S a S.。该状态有一个规约项 S -> S a S.。我们需要计算非终结符 S 的FOLLOW集:
- 从
S' -> S可知,$在FOLLOW(S)中。 - 从产生式
S -> S a S可知,a在FOLLOW(S)中(因为S后面可以跟a)。 - 同样从
S -> S a S可知,右边S的FOLLOW集也是左边S的FOLLOW集的子集,但这没有增加新元素。
因此,FOLLOW(S) = { $, a }。
这导致了问题:在该状态下,如果下一个输入符号是 a,根据FOLLOW集,我们应该进行规约;但同时,该状态也存在看到 a 就移入的转换。因此,该状态存在移入-规约冲突。
结论:该语法不是SLR(1)文法。
总结 📝
本节课中我们一起学习了两个SLR解析的构建示例。
- 在第一个例子中,我们构建了一个简单左递归文法的SLR自动机,并通过分析FOLLOW集确认其是SLR(1)的,没有冲突。
- 在第二个例子中,我们扩展了语法,构建了更复杂的自动机。分析发现,由于非终结符
S的FOLLOW集中包含了移入符号a,导致了一个状态出现移入-规约冲突,因此该语法不是SLR(1)文法。

通过这两个例子,我们实践了SLR解析表的构建过程,并深入理解了FOLLOW集在判断移入-规约冲突时的核心作用。

课程 P41:类型检查的实现 🧠

在本节课中,我们将学习如何将类型系统的规则转化为具体的代码实现。我们将通过遍历抽象语法树,并利用类型环境来检查和推导表达式的类型。
概述与高层设计 📋

类型检查的高层概述是:它可以单次遍历抽象语法树实现。实际上,这个过程分为两个阶段。
上一节我们介绍了类型检查的整体流程,本节中我们来看看具体的实现细节。
第一阶段是顶部向下阶段,它负责传递类型环境。我们从树的根部开始,递归地将初始类型环境向下传递,通过抽象语法树的各个节点,直到到达叶子节点。
第二阶段是底部向上阶段,它负责传递类型。从叶子节点开始,我们利用环境来计算子表达式的类型,并将结果类型向上传递。
实现加法规则 ➕
让我们从类型系统中一个简单的规则开始:加法规则。
简单回顾一下,类型检查表达式 e1 + e2 的规则是:首先类型检查 e1,然后类型检查 e2。两个子表达式必须是 int 类型。如果满足,则整个表达式的类型也是 int 类型。类型检查在相同的环境中进行。
以下是实现此规则的代码逻辑:
def type_check(env, expr):
if expr is of form e1 + e2:
# 类型检查子表达式 e1
t1 = type_check(env, e1)
# 类型检查子表达式 e2
t2 = type_check(env, e2)
# 确认 t1 和 t2 都是 int 类型
if t1 == int and t2 == int:
return int
else:
# 打印类型错误信息
print("Type error: addition operands must be int")
代码直接根据规则翻译而来。首先,我们递归调用 type_check 来获取 e1 和 e2 的类型 t1 和 t2。然后,我们检查它们是否都是 int 类型。如果检查成功,则整个表达式的类型为 int;否则,应报告错误。

实现 Let 初始化规则 🔧
现在,让我们看一个稍微更复杂的类型检查规则及其实现:let 初始化规则。
我们正在声明一个变量 x,类型为 T,它将在表达式 e1 中可见。但在执行 e1 之前,我们将 x 初始化为 e0 的值。整个 let 表达式期望得到类型 T1。
以下是规则的前提条件:
e0必须具有某种类型T0,且T0是T的子类型。- 在扩展了
x: T声明的环境中,e1必须具有类型T1。
以下是实现此规则的代码逻辑:
def type_check(env, expr):
if expr is of form let x: T = e0 in e1:
# 在相同环境中类型检查初始化表达式 e0
t0 = type_check(env, e0)
# 扩展环境,添加变量 x 的声明
new_env = env.extend(x, T)
# 在新环境中类型检查主体表达式 e1
t1 = type_check(new_env, e1)
# 检查 t0 是否是 T 的子类型
if is_subtype(t0, T):
return t1
else:
# 打印类型错误信息
print("Type error: initializer type does not match variable declaration")
首先,我们在原始环境中类型检查初始化表达式 e0,得到其类型 t0。接着,我们创建一个扩展了 x: T 声明的新环境,并在此新环境中类型检查主体表达式 e1,得到其类型 t1。最后,我们检查 t0 是否是 T 的子类型。如果所有检查都通过,则整个 let 表达式的类型就是 t1。
总结 🎯
本节课中我们一起学习了如何将类型系统的形式化规则转化为可执行的代码。
我们首先了解了类型检查分为向下传递环境和向上推导类型两个阶段。然后,我们通过实现加法规则和 let初始化规则,具体演示了如何将规则中的前提条件逐条翻译成递归函数调用和类型检查语句。

核心在于理解规则中的环境如何传递,以及如何利用递归遍历抽象语法树来完成类型推导。

编译器原理课程 P42:语义分析入门 🧠

在本节课中,我们将要学习编译器前端的一个重要阶段——语义分析。我们将了解它的作用、必要性,以及它在酷C语言编译器中的具体任务。
概述
上一节我们结束了关于语法分析(解析)的讨论。解析的任务是检测语言中所有不正确的句子。本节中,我们来看看编译器前端的最后一个阶段:语义分析。它是逐步过滤输入字符串的管道中的最后一道防线,负责捕获程序中所有潜在的剩余错误,确保最终只有有效的程序可以被编译。
为什么需要语义分析?

你可能会问,为什么需要一个独立的语义分析阶段?答案很简单:编程语言的一些特性,其相关错误是语法分析无法捕获的。我们使用的上下文无关文法,其表达能力不足以描述语言定义中我们感兴趣的一切。
这种情况与我们之前从词法分析切换到语法分析时类似:并非所有事情都能用有限自动机完成,我们需要上下文无关文法来描述更强大的语言特征。同样,上下文无关文法本身也不够强大,还有一些额外的特征无法用它轻易表达。
酷C语言中的语义分析任务

那么,语义分析在酷C语言编译器中具体做什么呢?它执行多种检查。以下是酷C编译器执行的六类典型检查:
- 标识符声明检查:确保所有使用的标识符都已事先声明。
- 标识符作用域检查:确保标识符的使用遵守其作用域限制。
- 类型检查:这是语义分析器的核心功能,确保表达式和操作中的类型兼容性。
- 面向对象特性检查:检查类之间的继承关系是否合理(例如,避免循环继承)。
- 定义唯一性检查:
- 确保类没有被重复定义。
- 确保方法在类内仅被定义一次。
- 保留标识符检查:确保没有误用语言中的保留字或特殊标识符。
实际上,这个列表并不完整,还有许多其他限制将在后续课程中讨论。主要信息是,语义分析器需要执行多种不同的检查,这些检查因编程语言而异。酷C的检查是静态类型、面向对象语言的典型代表,而其他语言家族(如函数式语言)则会有不同的检查重点。
总结

本节课中,我们一起学习了语义分析在编译器中的角色。我们了解到,它是继词法分析和语法分析之后的前端最后阶段,负责处理那些无法通过语法规则捕获的程序错误。通过酷C语言的例子,我们看到了语义分析器需要执行诸如类型检查、作用域验证和面向对象规则校验等多种任务,以确保程序的语义正确性。

课程 P43:语义分析之作用域 🎯
在本节课中,我们将要学习语义分析中的一个核心概念——作用域。我们将了解为什么需要分析作用域,以及静态作用域与动态作用域的区别,并探讨 COOL 语言中标识符绑定的特殊规则。

概述:什么是作用域?
讨论作用域的动机是,我们希望能够匹配标识符的声明与标识符的使用。当我们说变量 x 时,需要知道它指的是哪个变量。如果变量 x 在程序中可能有多个定义,那么确定其具体指向哪一个,就是大多数编程语言(包括 COOL)中一个重要的静态分析步骤。

作用域示例
以下是来自 COOL 的两个例子,它们说明了作用域分析的必要性。

在第一个例子中,y 的声明将与这里的使用匹配。因此,我们知道 y 应该是一个字符串。编译器会报错,因为你试图将字符串和数字相加。
在第二个例子中,这里是 y 的声明,但在 let 的主体中我们没有使用 y。声明一个不使用的变量本身不是错误,尽管可能会生成警告。相反,我们在这里看到的是对 x 的使用,但没有匹配的定义。如果没有 x 的外部定义,那么我们将得到一个未定义或未声明的变量错误。

作用域的定义
这两个例子引出了作用域的概念。标识符的作用域是程序的一部分,在该部分中标识符是可访问的。关键在于,同一个标识符可能在程序的不同部分指代不同的事物。相同名称的不同作用域不能重叠。因此,在任何给定的程序部分,变量 x 只能指代一件事物。
标识符可以有受限的作用域。有很多例子表明,标识符的作用域可以小于整个程序。
静态作用域 vs. 动态作用域

当今大多数编程语言都采用静态作用域,COOL 就是一个例子。静态作用域的特征是,变量的作用域仅取决于程序文本,而不是任何类型的运行时行为。程序在运行时做什么并不重要,作用域纯粹从语法上定义。
你可能会惊讶,静态作用域竟然有替代方案。实际上,有一些语言采用所谓的动态作用域。历史上,曾有过关于哪种作用域更优的争论,但今天静态作用域阵营显然已经胜出。Lisp 曾经是动态作用域语言,但很久以前就改为了静态作用域。另一种已成为历史的语言 Snowball 也具有动态作用域。
动态作用域的特征是,变量的作用域取决于程序的执行。
静态作用域示例
让我们看一个静态作用域的 COOL 代码示例。这里有几个不同的 x 声明和一些 x 的不同用法。

问题是,这三个 x 的用法分别指向哪个定义?实际上,这两个 x 指向最外层的定义。而这里的 x 则指向内层的定义。我们遵循最内层规则:变量绑定到相同名称的、最接近的定义。因此,内层定义的 x 用于此处的使用,返回值为 1;外层定义的 x 用于另外两处使用,返回值为 0。

动态作用域示例
在动态作用域语言中,变量将引用程序执行中最近的绑定。请看这个例子:
假设有函数 g,它定义了变量 a 并初始化为 4,然后它调用另一个函数 f。函数 f 引用了变量 a。
def g():
a = 4
f() # 调用 f
def f():
return a # 这里的 a 指向什么?

在动态作用域下,f 中的 a 将取 g 中定义的值 4。因为这个引用将指向执行过程中最近的、活跃的 a 绑定(即 g 中的定义)。关于动态作用域如何工作,在我们更详细地讨论语言实现之前,暂时不多说,后续课程会再次涉及。
COOL 语言中的标识符绑定
在 COOL 语言中,标识符绑定由多种机制引入:
- 类声明:引入类名。
- 方法定义:引入方法名。
- 对象标识符:通过以下几种方式引入:
let表达式- 函数的形参
- 类中的属性定义
case表达式的分支
COOL 作用域的特殊规则

重要的是理解,并非所有标识符都遵循之前概述的最内层规则。
类名的作用域
类定义在 COOL 中不能嵌套,实际上它们在程序中全局可见。这意味着类名在程序的任何地方都被定义,可以在定义之前使用。

例如:
y: Bar; -- 这里使用了 Bar
...
class Bar { ... }; -- Bar 在这里才定义
这是完全合法的 COOL 代码。
属性名的作用域
属性名在定义它们的类中是全局性的。这意味着它们可以在定义之前使用。

例如,在类中可以先定义一个方法使用属性 a,然后再定义属性 a:
class C {
method() : Int { a }; -- 这里使用了属性 a
a : Int <- 5; -- 属性 a 在这里定义
};
这完全合法。类内方法和属性的定义顺序是随意的。
方法名的作用域
方法名的规则更复杂。例如,一个方法不必在用到它的类中定义,可以在其父类中定义。方法可以被重定义,实现方法覆盖,即使它已经定义过。目前没有精确的语言描述来概括所有规则,我们将在未来的视频中深入讨论。
总结

本节课中,我们一起学习了语义分析中的核心概念——作用域。
我们首先了解了分析作用域的目的是为了匹配标识符的声明与使用。然后,我们通过例子区分了静态作用域(由程序文本决定)和动态作用域(由程序执行决定),并明确了现代语言多采用静态作用域。
最后,我们探讨了 COOL 语言中标识符绑定的特殊规则,特别是类名和属性名具有全局或类内全局作用域,可以在定义前使用,这与局部变量的“最内层规则”不同。方法名的规则则更为复杂,涉及继承和覆盖。

理解这些规则,是构建编译器语义分析阶段的基础。

编译器设计课程 P44:符号表 🗂️

在本节课中,我们将要学习编译器中的一个核心数据结构——符号表。我们将了解它的基本概念、工作原理、实现方式,以及它在语义分析阶段如何帮助管理程序中的标识符(如变量名、类名)及其作用域。

上一节我们介绍了编译器的一般工作流程,本节中我们来看看语义分析中的一个关键工具。
符号表与递归下降遍历
许多语义分析和代码生成任务可以表示为对抽象语法树(AST)的递归下降遍历。其基本思想是,在处理树的每个节点时,执行三个步骤:
- 前处理:在访问子节点之前,对当前节点进行一些操作。
- 递归处理:以相同的方式递归处理所有子节点。
- 后处理:在处理完所有子节点后,返回当前节点并进行一些操作。
这种算法被称为树的自顶向下遍历。在语义分析中,我们需要知道哪些标识符已被定义,以及它们当前是否在作用域内。符号表就是用来实现这一功能的数据结构。

了解了基本算法后,我们来看一个具体的例子,看看符号表如何与递归下降配合工作。
符号表应用示例:处理 let 表达式
考虑一个 let 节点,它包含一个变量 x 的初始化表达式和主体表达式 e。以下是处理它的递归下降策略:
- 首先,处理初始化表达式(可能涉及类型检查等)。
- 然后,在处理主体表达式
e之前,将变量x添加到当前作用域的符号集合中。 - 接着,递归处理主体表达式
e。 - 最后,在处理完
e之后,将x从符号集合中移除。
这样,在离开这个 let 子树后,符号表的状态就恢复到了进入它之前的样子。这完美地模拟了局部变量的作用域生命周期。

那么,如何具体实现这样一个能跟踪作用域的符号表呢?最简单的方法是使用栈。
简单符号表:基于栈的实现

对于一个声明完全嵌套(如多层 let)的语言,可以使用栈来实现简单的符号表。它支持三个核心操作:
以下是三个核心操作的伪代码描述:
操作 push(symbol): // 添加符号
将 symbol 及其相关信息(如类型)压入栈顶
操作 lookup(symbol): // 查找符号
从栈顶向栈底搜索,返回第一个匹配 symbol 名称的条目
操作 pop(): // 移除符号
弹出栈顶的符号(离开作用域时调用)
这种实现的优点是简单,并且能自动处理定义隐藏(内层定义覆盖外层定义)。当离开一个作用域时,只需弹出栈顶元素,外层的定义就会重新可见。

然而,栈式符号表有其局限性。当需要处理同一作用域内同时定义多个标识符的情况时,它就力不从心了。
增强符号表:支持作用域层级
考虑函数参数列表,多个参数在同一作用域内定义。为了检测“重复定义”这类错误,我们需要一个更强大的符号表接口。
修订后的接口包含以下五个方法:
以下是五个核心方法的描述:
操作 enterScope(): // 进入新作用域
压入一个新的、空的作用域到作用域栈顶
操作 exitScope(): // 退出当前作用域
弹出作用域栈顶的作用域

操作 addSymbol(symbol): // 添加符号
将 symbol 添加到当前作用域(栈顶的作用域)中
操作 lookup(symbol): // 查找符号
从作用域栈顶向栈底搜索,返回第一个匹配的 symbol
操作 checkScope(symbol): // 检查当前作用域
仅检查栈顶的作用域,若 symbol 已存在则返回 true
这个结构可以看作一个作用域栈,每个栈元素本身是一个容纳该层所有定义的集合。checkScope 方法专门用于检查当前作用域内是否有重复定义。

最后,我们来讨论一类特殊的标识符——类名,它们的处理方式与普通变量有所不同。
类名的特殊处理
与 let 变量和函数参数不同,类名允许前向引用(即在定义之前使用)。这意味着我们不能通过单次遍历程序来完成所有类名的检查。
解决方案是进行两次遍历:
以下是处理类名的两遍扫描策略:
- 第一遍:收集所有类定义,记录下定义的类名。
- 第二遍:检查所有类体的代码,确保使用的类名都在第一遍收集的定义中。
这个例子给我们的启示是:编译器设计不应畏惧增加遍历次数。将复杂任务分解为多个简单步骤(如两遍、三遍扫描),往往比设计一个极度复杂、所有逻辑纠缠在一起的单遍算法更清晰、更易于实现和调试。
总结

本节课中我们一起学习了符号表这一编译器核心组件。我们了解了它如何与递归下降遍历算法配合,用于管理标识符的作用域和绑定。我们从最简单的栈实现开始,逐步扩展到能处理复杂作用域和重复定义检查的增强版接口。最后,我们还探讨了类名这类需要多遍扫描处理的特殊标识符。理解符号表是构建编译器语义分析阶段坚实的基础。

课程 P45:类型系统基础 🧩

在本节课中,我们将要学习编程语言中“类型”这一核心概念。我们将探讨类型的定义、不同类型系统的区别,以及它们在语言设计中的作用。

什么是类型?🤔
上一节我们介绍了本课程的主题,本节中我们来看看“类型”到底是什么。一个基本问题是类型是什么。这个问题值得问,因为类型的概念因编程语言而异。
大致来说,共识是类型是一组值。更重要的是,类型是一组对这些值独有的操作,一组在这些值上定义的操作。
以下是两个核心示例:
- 整数类型:你可以对整数执行一些操作,例如加法、减法,以及比较大小(大于、等于或小于)。
- 字符串类型:它们有不同类型的操作,例如连接和测试一个字符串是否为空。
重要的是,这些操作不同于整数上定义的操作。我们不想混淆它们。如果我们开始对整数执行字符串操作,我们只会得到无意义的结果。

类型在语言中的表达方式 🏗️
在现代编程语言中,类型以多种方式表达。在面向对象的语言中,我们经常看到类是类型的概念。特别是在Cool中,类名是类型(除了一个例外叫self类型)。
我只想指出这不一定是这样。在面向对象语言中,将类和类型等同起来往往很方便。但还有其他设计,其中类不是唯一的类型。在一些没有类概念的语言中,类型是完全不同的事物。所以类和类型实际上是两个不同的事物,在大量面向对象语言设计中被识别。我只想让你知道这不一定是唯一的做法。

汇编语言中的类型缺失 ⚠️
考虑以下汇编语言片段:
add r1, r2, r3
这实际上做了什么?它将寄存器r2的值和寄存器r3的值相加,并将结果放入寄存器r1。
问题是r1、r2和r3的类型是什么?你可能希望它们是整数。但实际上这是一个陷阱问题。因为在汇编语言层面,我无法分辨。没有任何东西阻止r1、r2和r3具有任意类型。它们可以是任何类型的代表,因为它们只是一堆包含零和一的寄存器。加法操作将乐于接受它们并相加。
为了使这个更清楚,也许考虑某些对每种类型值合法的操作是有用的。例如,将两个整数相加是完全有意义的。如果我有两个代表整数的位模式,那么当我将它们相加时,我将得到一个代表这两个整数之和的位模式。
但另一方面,如果我取一个函数指针和一个整数,并将它们相加,我真的没有得到任何东西。函数指针是一个位模式,整数是一个位模式。我可以取这两个位模式,我可以运行它们并通过加法,我确实得到了一个新的位集。但对这个结果没有有用的解释,我得到的结果没有任何意义。

但问题是,这两个操作在汇编语言层面上具有相同的实现。在汇编语言层面上,这两个操作看起来完全一样。因此,在汇编语言层面上我无法分辨我正在做的是哪一个。
类型系统的必要性 🛡️

如果我想有类型,如果我想确保我只对正确的类型执行某些操作,那么我需要某种类型描述和一些类型的系统来强制这些区别。
所以再一次强调,语言类型系统指定了哪些操作对于哪些类型是有效的。然后类型检查的目标是确保操作仅与正确的类型一起使用。
通过类型检查确保值解释。在机器码层面无其他检查,仅是许多零和一。机器将执行我们告诉它的操作,无论操作是否合理。类型系统的目的是确保位模式解释,确保整数位模式不被误用,避免得到无意义结果。

编程语言的类型分类 📊
当前编程语言关于类型处理分三类。
以下是三种主要类型系统:
- 静态类型语言:在编译时检查所有或几乎所有类型。Cool是其中之一。C和Java等语言也是静态类型。
- 动态类型语言:在运行时检查几乎所有类型。Lisp家族语言如Scheme和Lisp在此列。如Python和Perl等语言也属于此类。
- 无类型语言:完全不检查类型,编译时或运行时都不检查。机器码基本如此。机器码无类型概念,不强制抽象边界。
静态类型 vs. 动态类型:争论与现状 ⚖️
关于静态与动态类型优劣有争论。我不偏袒任何一方,为你列出各派支持者所说的观点。

支持静态类型的人认为,静态检查在编译时捕获许多编程错误。也避免了运行时类型检查的开销。如果在编译时做了所有类型检查,那么运行时无需检查类型,进行操作时无需检查参数是否为正确类型。因为在编译时已彻底检查一次。这些都是绝对正确的。这是静态检查的两个主要优势:首先,证明有些错误不会发生;其次,运行更快。
动态类型支持者反驳静态类型系统限制性。本质上静态类型系统必须证明程序类型良好,所有类型有意义。它通过限制可编写的程序类型实现。一些程序在静态类型语言中更难编写,编译器难以证明其正确。普遍认为使用静态类型系统进行快速原型开发更难。这里的意思是,如果你在探索某个想法,你可能并不确切知道所有类型。必须承诺某种在所有情况下都能工作的东西,当你只是在摆弄并弄清楚你要做什么时,这限制很大。
那么实际现状如何呢?很多代码是用静态类型语言编写的。人们常用的实用类型语言总有一种逃逸机制。所以在C和Java中,你有一些不安全转换的概念。在C中,不安全转换可能导致运行时崩溃。在Java中,会导致运行时未捕获异常。但结果是,现在会因为类型原因出现运行时错误。
在动态类型方面,使用动态语言编程的人,他们最终似乎将静态类型回溯到这些动态类型语言中。因此,如果动态类型语言变得足够流行,人们开始尝试为它们编写优化编译器,人们想要优化编译器的第一件事是一些类型信息,因为它有助于生成更好的代码。因此,人们最终尝试弄清如何获取更多类型信息来自这些动态类型语言。
在我看来,是否妥协值得商榷,因为两者都是妥协。但这就是我们现在的情况。

Cool语言中的类型系统 🧊
实际上,现在Cool是一种静态类型语言。Cool中可用的类型是类名。因此每次你定义一个类,你就定义了一个新类型,以及特殊的保留符号self类型(我们将在单独的视频中讨论它)。
Cool的工作方式是用户声明标识符的类型。对于每个标识符,你需要说明其类型。但编译器完成其余工作。编译器推断表达式的类型。特别是编译器为程序中的每个单个表达式分配类型。我们将遍历整个抽象语法树,使用标识符的声明类型,它将计算一个类型。

类型检查与类型推断 🔍
总结时,值得提及的是,对于计算类型的过程,人们使用了一些不同的术语,它们意味着略有不同。
以下是两个相关但不同的概念:
- 类型检查:我们有一个完全类型的程序(意味着我们有一个抽象语法树,所有节点上都填满了类型)。我们唯一的工作是检查类型是否正确。所以我们可以只看每个节点和它的邻居,并确认该部分的类型是正确的。
- 类型推断:是填充缺失类型信息的过程。这里的观点是我们有一个抽象语法树,上面没有类型,或者可能只有一些关键位置的类型(比如声明的变量),然后我们想要填充缺失的类型。我们有一些节点完全没有类型信息,不仅仅是确认或检查类型是否正确,我们实际上必须填充缺失的类型信息。
这两件事是不同的。实际上在许多语言中是非常不同的,但人们经常交替使用这些术语。我也不会在我的视频中特别小心使用哪个术语。

本节课中我们一起学习了编程语言中“类型”的核心概念。我们了解了类型的定义是一组值及其专属操作,探讨了静态类型、动态类型和无类型语言的区别,并分析了它们各自的优缺点。最后,我们介绍了Cool语言作为静态类型语言的实现方式,以及类型检查与类型推断的基本区别。理解类型系统是掌握编程语言设计和实现的关键一步。

编译器原理 P46:类型检查入门 🧠

在本节课中,我们将要学习编译器中的一个核心环节——类型检查。我们将了解如何使用逻辑推理规则来形式化地描述和验证程序中表达式的类型。
概述

类型检查是编译器确保程序在运行前符合类型规则的过程。它使用一种称为“推理规则”的形式化系统来证明某个表达式具有特定的类型。本节课将介绍推理规则的基本概念、表示方法,并通过简单的例子展示如何构建类型证明。
推理规则:类型检查的基石

上一节我们介绍了词法分析和语法分析的形式化工具。本节中我们来看看用于类型检查的另一种形式化表示——逻辑推理规则。
推理规则是一种逻辑陈述。它表示:如果某些假设为真,那么某个结论也为真。因此,推理规则本质上是一种蕴含语句。

在类型检查中,典型的推理是:如果两个表达式具有某些类型,则另一个表达式保证具有某种类型。类型检查语句就是推理规则的示例。
推理规则的表示法
如果你以前没见过这种表示法,可能会感到陌生。但实际上,通过练习很容易读懂。我们将从一个非常简单的系统开始,并逐渐添加功能。

我们使用以下符号:
∧表示逻辑“与”(即英语单词“and”)。x : t表示“x的类型为t”。这是一个逻辑断言。
现在考虑一个简单的类型规则:如果 e1 的类型为 int,并且 e2 的类型为 int,则 e1 + e2 的类型也为 int。

我们可以用数学语言逐步重写这个规则:
- 将“如果-那么”替换为蕴含符号
⇒。 - 将“和”替换为
∧。 - 最终得到一个纯数学陈述:
(e1 : int) ∧ (e2 : int) ⇒ (e1 + e2 : int)。
这个陈述意味着:e1 类型为 int 与 e2 类型为 int 同时成立,蕴含着 e1 + e2 的类型为 int。
推理规则的标准格式
我们刚刚写出的陈述是推理规则的一种特殊情况:一组假设联合起来可以推导出某个结论。

推理规则的常规表示法如下:
假设1, 假设2, ..., 假设n
————————————————————————————
结论
水平线以上的所有内容是假设,水平线以下是结论。其含义与上一页完全相同:如果水平线以上的所有假设都为真,那么水平线以下的结论可以推断为真。
这里引入一种新的标记法:⊢(读作“可证明的”)。它明确表示某件事在定义的规则系统中是可证明的。因此,规则可以这样读:如果所有假设都是可证明的,那么结论也是可证明的。

在类型检查系统中,我们将证明的假设和结论都具有“某个表达式具有特定类型”的形式,即 ⊢ e : t。
编写简单的类型规则
有了这些定义,我们现在可以编写一些简单的类型规则了。

以下是两个核心规则:
-
整数字面量规则:如果
i是一个整数字面量(即程序中的整数常量),那么它的类型是可证明的int。i 是整数字面量 —————————————————— ⊢ i : int这条规则表示:每个整数常量都有类型
int。 -
加法规则:如果
e1的类型是可证明的int,并且e2的类型也是可证明的int,那么e1 + e2的类型是可证明的int。⊢ e1 : int, ⊢ e2 : int —————————————————————————— ⊢ e1 + e2 : int

注意,这些规则是描述如何为表达式赋予类型的模板。它们使用通用的表达式(如 e1, e2)和字面量(如 i),而不是为每个具体的值或表达式单独制定规则。
构建类型证明:一个例子
让我们通过一个具体例子来展示如何应用这些规则。我们想证明表达式 1 + 2 具有类型 int。
证明过程如下:
- 根据整数字面量规则,因为
1是整数字面量,所以可证⊢ 1 : int。 - 同样,因为
2是整数字面量,所以可证⊢ 2 : int。 - 现在,我们有了加法规则所需的两条假设(
⊢ 1 : int和⊢ 2 : int)。 - 应用加法规则,我们可以得出结论:
⊢ 1 + 2 : int。
这个证明过程与表达式的抽象语法树(AST)结构完全对应。
类型系统的正确性与精确性
任何合理的类型系统都必须满足一个关键属性:正确性。

正确性条件是指:如果类型系统能够证明某个表达式 e 具有类型 t,那么当实际运行该程序并计算表达式 e 时,返回的值确实具有类型 t。我们希望类型系统的预测能反映程序运行时的实际情况。
显然,我们只想要正确的规则。但需要注意的是,对于同一个表达式,可能存在多个都“正确”但“精确度”不同的规则。
例如,对于整数字面量 i:
- 更精确的规则:
⊢ i : int(最佳规则)。 - 正确但不精确的规则:
⊢ i : Object。因为在 COOL 语言中,每个整数都是对象,所以这个结论也正确,但它丢失了“这是一个整数”的具体信息,导致后续无法进行整数特有的操作。
因此,在众多正确的规则中,我们通常选择能给出最具体类型的那一个。对于整数字面量,最具体的类型就是 int。

总结:类型证明与抽象语法树
本节课中我们一起学习了类型检查的基本原理。
总结如下:
- 类型检查的本质是证明表达式
e具有类型t的事实。 - 这种证明是基于抽象语法树(AST)的结构自底向上进行的。
- 证明过程构成一棵“证明树”,其形状与 AST 相同,但根在底部(结论)。
- 对于 AST 中的每个节点,我们应用一个类型规则。该规则的假设是其子表达式的类型证明,结论是该节点整个表达式的类型。
- 类型信息从叶子节点(如字面量)开始计算,然后流向根节点。
通过这种方式,编译器可以遍历整个 AST,为每个表达式分配合适的类型,从而在程序运行前发现潜在的类型错误。

课程 P47:类型环境 🧭

在本节课中,我们将继续开发 Cool 语言的类型检查器,并重点讨论一个核心概念——类型环境。我们将学习如何通过类型环境来记录和管理程序中自由变量的类型信息,从而能够为包含变量的表达式赋予类型。

上一节我们介绍了为常量、new表达式等定义类型规则。本节中,我们来看看如何处理包含变量的表达式,并引入类型环境的概念。

更多基础类型规则
以下是几个基础表达式的类型规则:
- 常量
false:可证明常量false的类型为Bool。- 规则:
|- false : Bool
- 规则:
- 字符串字面量
s:可证明其类型为String。- 规则:
|- s : String
- 规则:
new表达式:表达式new T产生类型为T的对象。- 规则:
|- new T : T - 注:此处暂时忽略
SELF_TYPE,后续课程会专门处理。
- 规则:
- 布尔补运算:若表达式
e的类型为Bool,则not e的类型也为Bool。- 规则:
|- e : Bool=>|- not e : Bool
- 规则:
while循环:这是目前较复杂的规则。while e1 loop e2 pool中,e1是循环条件,e2是循环体。e1的类型必须为Bool。e2可以是任意类型T,但其具体类型不重要。- 整个
while表达式的类型为Object。这是一个设计决定,目的是避免程序员依赖循环体可能不产生的值(例如,当首次进入循环时条件e1就为false,e2根本不会执行),从而防止运行时错误。
到目前为止,我们为每个已查看的语言结构定义了合理的类型规则。但现在我们遇到了一个问题。
变量类型的问题

假设有一个仅包含单个变量名 x 的表达式。这是一个完全有效的表达式,但问题在于:仅查看 x 本身,我们无法确定它的类型。
推理规则要求所有信息都必须是局部的。执行规则所需的一切都必须在规则本身中体现,不能依赖外部数据结构。目前,我们的规则中还没有包含如何确定变量类型的信息。
解决方案是向规则中添加更多信息,这就是类型环境。

引入类型环境
类型环境为表达式中的自由变量提供类型信息。
- 自由变量:在一个表达式中,如果一个变量没有在该表达式内部被定义(例如,通过
let绑定),那么它就是该表达式的自由变量。- 例如,在表达式
x + y中,如果x和y都没有在表达式内部定义,那么它们都是自由变量。 - 在表达式
let y: Int <- 5 in x + y中,y在内部被定义,因此是绑定变量;x未被定义,因此是自由变量。
- 例如,在表达式
类型环境本质上是一个从变量名(对象标识符)映射到类型的函数。我们通常用符号 O 来表示一个类型环境。
现在,我们将扩展我们的证明逻辑语句。原来的 |- e : T 表示“可证明表达式 e 具有类型 T”。现在我们将它扩展为:

O |- e : T
这个符号应解读为:在假设自由变量的类型由环境 O 给出的前提下,可证明表达式 e 具有类型 T。它很好地区分了输入(假设 O)和输出(证明 e : T)。
更新现有规则

我们需要将类型环境添加到所有现有的规则中。
- 整数字面量:即使有关于变量类型的假设,整数字面量的类型也不变。
- 规则:
O |- 5 : Int
- 规则:
- 加法表达式:对于表达式
e1 + e2,我们需要在相同的环境O下分别检查e1和e2的类型。- 规则:如果
O |- e1 : Int且O |- e2 : Int,那么O |- e1 + e2 : Int。
- 规则:如果
新的变量规则

有了类型环境,自由变量的问题就迎刃而解了。要确定变量 x 的类型,只需在环境 O 中查找。
- 变量引用:如果环境
O中包含x : T的映射,那么就可以证明x具有类型T。- 规则:
O |- x : T(当O(x) = T时)
- 规则:
let 表达式的规则
let 表达式引入了一个新的变量绑定,这会影响类型环境。回顾一下,let x: T0 <- e0 in e1 的作用是:声明一个新变量 x(类型为 T0,初始值为 e0),该变量在表达式 e1 中可见。
如何类型检查 e1?我们需要在一个修改后的环境中检查它。
- 规则:
- 首先,在原始环境
O下检查初始化表达式e0,确保其类型为T0:O |- e0 : T0。 - 然后,为了检查
e1,我们创建一个新的环境O[x -> T0]。这个新环境是原始环境O的扩展,它将变量x映射到类型T0,而其他变量的映射保持不变。 - 在这个新环境下检查
e1,得到其类型T1:O[x -> T0] |- e1 : T1。 - 整个
let表达式的类型就是e1的类型T1。
- 首先,在原始环境
形式化规则:
O |- e0 : T0 O[x -> T0] |- e1 : T1
--------------------------------------
O |- let x: T0 <- e0 in e1 : T1
这个规则体现了类型环境的一个重要特点:类型环境从抽象语法树(AST)的根部向下传递到叶子。当我们遇到像 let 这样的定义时,类型环境会用新的定义进行扩展。因此,随着我们向 AST 的叶子节点移动,环境会不断增长。而类型则是从叶子向上计算到根,我们从简单的叶节点(如常量)开始获取类型,或根据环境查找变量的类型,然后根据规则组合出更复杂表达式的类型。

总结
本节课中,我们一起学习了:
- 类型环境的核心作用:为当前作用域中的自由变量提供类型信息。没有这些信息,我们无法对包含变量的表达式进行有意义的类型检查。
- 类型环境的形式化定义:它是一个从变量名到类型的映射函数(
O)。 - 如何使用类型环境:通过形如
O |- e : T的断言,在给定环境O的前提下推导表达式e的类型T。 - 类型环境在 AST 遍历过程中的动态变化:它从根向下传递并随着变量定义的引入而扩展;类型信息则从叶子向上计算并汇总。
- 类型环境实质上形式化了编译器符号表所携带的信息,是连接变量声明与其使用点的关键桥梁。

理解类型环境是构建任何静态类型检查器的基石。在接下来的课程中,我们将利用这个概念来处理更复杂的语言特性。

课程 P48:子类型化 🧬

在本节课中,我们将要学习面向对象编程中的一个核心概念——子类型化。我们将探讨它在类型系统中的作用,以及如何利用它来编写更灵活、更强大的类型检查规则。
从 Let 初始化规则开始
上一节我们介绍了基本的 let 表达式规则。本节中,我们来看看当 let 语句包含初始化器时,规则会发生什么变化。
首先,规则的主体部分与之前几乎完全相同。我们在类型环境 Γ 中检查表达式 e1,并得到类型 t1,这将是整个表达式的类型。
新的部分在于对初始化器的检查。以下是其工作原理:

- 我们在环境
Γ中检查初始化表达式e0,得到其类型t0。 - 请注意,新定义的变量
x在e0中并不可用。如果e0中使用了名称x,它引用的是let语句外部定义的x。 - 此规则的核心要求是:
e0的类型t0必须与变量x声明的类型T完全相同。
这个规则相当严格,因为它要求初始化器的类型必须与变量声明的类型精确匹配。
引入子类型关系
如果我们引入类之间的子类型关系,就能制定出更宽松且依然正确的规则。
最明显的子类型形式基于类的继承:
- 如果类
X直接继承自类Y(即代码中有X inherits Y),那么X是Y的子类型。 - 子类型关系具有传递性:如果
X是Y的子类型,且Y是Z的子类型,那么X也是Z的子类型。 - 子类型关系具有自反性:每个类都是其自身的子类型。

利用子类型,我们可以改进 let 的初始化规则。新规则如下:
- 规则主体部分的处理与之前完全一致。
- 我们检查初始化表达式
e0,得到其类型t0。 - 新规则只要求:
t0必须是T的子类型(记作t0 ≤ T)。
这里的 T 是变量 x 声明的类型。这个更宽松的规则允许 e0 的类型与 x 的类型不同,只要前者是后者的子类型即可。因此,更多程序能够通过类型检查并正确运行。
子类型在其他规则中的应用
子类型化概念会出现在类型系统的许多地方。接下来,我们看看它在赋值和属性初始化规则中的作用。
赋值规则

赋值规则在很多方面与 let 规则相似。对于语句 x <- e1:
- 在环境
Γ中查找变量x的类型,得到t0。 - 在相同的环境
Γ中检查表达式e1,得到其类型t1。 - 为了使赋值正确,
x必须能够持有类型t1的值。因此,约束条件是:x的类型t0必须是t1的超类型(即t1 ≤ t0)。
属性初始化规则

在类定义中初始化属性时,规则与普通赋值非常相似,主要区别在于检查时所处的环境。
回忆一下,COOL 语言中的类定义包含属性和方法。一个属性定义如下所示:
x : T <- e1;
属性初始化表达式 e1 的类型检查在一个特殊的环境 Γ_{sub C} 中进行,这个环境记录了类 C 中所有已声明属性的类型。
检查过程如下:
- 在环境
Γ_{sub C}中查找属性x的类型为t0。 - 在相同环境中检查初始化表达式
e1,得到其类型t1。 - 与赋值类似,要求
t1是t0的子类型(即t1 ≤ t0)。
条件表达式的类型检查

现在看另一个有趣的例子:如何检查 if-then-else 表达式的类型。
关于 if-then-else 重要的是,在类型检查时,我们无法知道程序运行时将执行哪个分支。因此,整个 if 表达式的类型必须是两个分支类型的某种“公共”类型。
最好的做法是取两个分支类型的最小上界。两个类型 X 和 Y 的最小上界 Z(记作 Z = lub(X, Y))满足:
Z是X和Y的上界(即X ≤ Z且Y ≤ Z)。Z是所有上界中最小的一个(即对于任何其他上界Z‘,有Z ≤ Z’)。
在 COOL 和大多数面向对象语言中,两个类型的最小上界就是它们在继承树中的最近共同祖先。
基于此,我们可以给出 if-then-else 的类型检查规则:
- 所有子表达式都在相同的环境
Γ中进行类型检查,因为if表达式不会改变环境。 - 条件表达式
e0必须具有布尔类型Bool。 - 检查
then分支e1,得到类型t1。 - 检查
else分支e2,得到类型t2。 - 整个
if表达式的类型就是t1和t2的最小上界:lub(t1, t2)。

Case 表达式的类型检查
case 表达式的规则是我们目前见过最复杂的,但它本质上是 if-then-else 的一个变体。让我们先回顾一下 case 的作用:
- 它计算表达式
e0的值。 - 获取
e0的运行时类型(动态类)。 - 按顺序检查各个分支
(x1 : T1 => e1, ..., xn : Tn => en)。 - 选择第一个其声明类型
Ti是e0运行时类型的超类型,并且是所有匹配分支中最具体的(即最小超类型)的那个分支。 - 执行该分支,将变量
xi绑定到e0的值(并将其类型视为Ti),然后计算表达式ei。
其类型检查规则如下:
- 首先,在环境
Γ中检查表达式e0,得到其静态类型t0。 - 对于每个分支
(xi : Ti => ei):- 创建一个扩展的环境
Γ‘ = Γ ∪ {xi : Ti}。 - 在这个新环境
Γ‘中检查分支体ei,得到类型ti‘。
- 创建一个扩展的环境
- 由于在编译时无法确定运行时将匹配哪个分支,整个
case表达式的类型就是所有分支体类型t1‘, t2‘, ..., tn‘的最小上界(lub)。
总结
本节课中,我们一起学习了子类型化这一核心概念。我们看到了如何利用类之间的继承关系来定义子类型,从而使类型系统更加灵活。具体内容包括:
- 改进了
let语句的初始化规则,允许初始化器类型是变量声明类型的子类型。 - 将子类型应用于赋值规则和类属性初始化规则。
- 学习了如何为
if-then-else和case这类条件表达式确定类型,即通过计算分支类型的最小上界。 - 理解了最小上界在继承树中对应的是最近共同祖先。

掌握子类型化是理解面向对象语言类型系统如何支持多态和代码复用的关键。

课程 P49:类型检查方法调用 🧪

在本节课中,我们将学习如何对面向对象语言中的方法调用进行类型检查。我们将探讨动态调度和静态调度的类型规则,并理解如何通过环境来管理方法和对象的类型信息。
方法调用的类型检查问题

上一节我们介绍了变量引用的类型检查。本节中我们来看看如何类型检查一个方法调用。
假设我们有一个表达式 e0.f(e1, ..., en)。我们需要类型检查接收者表达式 e0 和所有参数 e1 到 en。e0 将有一个类型 T0,每个参数也有其类型。核心问题是:这个方法的调用返回什么类型?我们无法仅凭方法名 f 就知道其行为,除非我们了解其定义所在的类 T0 的行为。
Cool语言中的命名空间
在Cool语言中,方法与对象标识符分属不同的命名空间。这意味着,在同一作用域内,可以同时存在一个名为 foo 的方法和一个名为 foo 的对象,而不会产生混淆。因此,在类型规则中,我们需要两个独立的环境来分别记录对象和方法的类型信息。
以下是两个关键的环境映射:
- 对象环境 (O):将对象标识符映射到其类型。
- 方法环境 (M):记录每个方法的签名。
一个方法的签名定义了其输入和输出类型。在方法环境 M 中,一项记录的形式为:M(C, f) = [T1, T2, ..., Tn, T_return]。这表示在类 C 中,方法 f 接受 n 个参数,其类型依次为 T1 到 Tn,并返回类型为 T_return 的值。

动态调度的类型规则
在引入了方法环境 M 后,我们现在可以为动态调度(即普通的方法调用 e0.f(...))编写类型规则。
首先,我们使用当前环境(包含 O 和 M)对子表达式进行类型检查,得到 e0 的类型 T0 以及各个参数 ei 的类型 Ti。
然后,我们在方法环境 M 中查找类 T0 中名为 f 的方法签名。该签名必须存在,并且参数数量必须匹配。接着,我们检查每个实际参数的类型 Ti 是否是其对应形式参数声明类型 T'i 的子类型(即 Ti ≤ T'i)。
如果所有这些检查都通过,那么整个调度表达式的类型就是方法签名中声明的返回类型 T_return。
该规则可以用以下形式化描述:
O, M, C ⊢ e0 : T0
O, M, C ⊢ e1 : T1
...
O, M, C ⊢ en : Tn
M(T0, f) = [T'1, ..., T'n, T_return]
T1 ≤ T'1, ..., Tn ≤ T'n
——————————————————————————————————————
O, M, C ⊢ e0.f(e1, ..., en) : T_return

静态调度的类型规则
静态调度(即 e0@T.f(...))的类型规则与动态调度非常相似。
语法上的唯一区别是程序员显式指定了希望调用方法 f 的类 T,而不是由 e0 的运行时类型决定。
在类型规则中,我们同样先对 e0 和所有参数进行类型检查。额外的要求是:e0 的类型 T0 必须是显式指定的类 T 的子类型(即 T0 ≤ T)。这意味着 T 必须是 T0 在继承层次中的祖先类。

然后,我们检查类 T 中是否存在方法 f,其签名匹配,并且实际参数类型是形式参数类型的子类型。如果满足所有条件,则整个表达式的类型就是该方法签名的返回类型。
该规则形式化如下:
O, M, C ⊢ e0 : T0
O, M, C ⊢ e1 : T1
...
O, M, C ⊢ en : Tn
T0 ≤ T
M(T, f) = [T'1, ..., T'n, T_return]
T1 ≤ T'1, ..., Tn ≤ T'n
——————————————————————————————————————
O, M, C ⊢ e0@T.f(e1, ..., en) : T_return

完整类型检查环境
方法环境 M 需要被添加到整个类型检查系统中。对于大多数表达式(如加法),其类型规则本身不直接使用 M,但需要将 M 传递给其子表达式进行类型检查。

因此,Cool语言完整的类型检查环境由三部分组成:
- 对象环境 (O):对象标识符到类型的映射。
- 方法环境 (M):方法签名映射。
- 当前类 (C):正在被类型检查的表达式所在的类。
一个完整的类型判断语句读作:在假设对象标识符具有 O 给出的类型、方法具有 M 给出的签名、且表达式位于类 C 中的前提下,我们可以证明表达式 e 具有类型 T。

例如,带有完整环境的加法规则如下:
O, M, C ⊢ e1 : T1 O, M, C ⊢ e2 : T2 T1 = Int T2 = Int
———————————————————————————————————————————————————————————————
O, M, C ⊢ e1 + e2 : Int
类型检查的普遍主题
虽然不同语言的规则各异,但类型检查有一些普遍主题:
- 结构归纳:类型规则通常基于表达式的结构进行归纳定义。一个表达式的类型取决于其子表达式的类型。
- 环境建模:表达式中的自由名称(如变量、方法名)的类型由环境(映射)来建模和提供假设。
- 规则紧凑性:类型规则用紧凑的符号包含了大量信息,需要仔细阅读和理解。

本节课中我们一起学习了如何对方法调用进行类型检查。我们理解了动态调度与静态调度的区别及其对应的类型规则,认识了Cool语言中分离的对象环境与方法环境,并了解了类型检查系统如何通过环境传递和利用这些信息来保证程序的安全性。掌握这些规则是构建类型检查器的核心基础。

Cool编程教程 P5:阶乘函数实现 🧮

在本节课中,我们将学习如何在Cool编程语言中实现一个阶乘函数。我们将从简单的输入输出开始,逐步构建一个能够读取用户输入、进行整数计算并输出结果的完整程序。课程将涵盖基本的I/O操作、字符串与整数的转换、递归与循环结构,并指出一个常见的编程错误。
概述与程序骨架
上一节我们介绍了Cool语言的基本结构。本节中,我们来看看如何构建一个完整的程序。
每个Cool程序都需要一个主类,主类中必须包含一个main方法。我们首先搭建程序的基本骨架。
class Main {
main(): Object {
-- 程序主体将写在这里
};
};
main方法返回一个Object类型的对象。现在,我们可以在其中编写代码了。
基础输入与输出

在编写阶乘函数之前,我们需要掌握如何与用户交互,即输入和输出。
为了进行I/O操作,我们需要一个IO对象。IO对象提供了诸如打印字符串等方法。
以下是一个简单的程序,用于打印数字“1”。
class Main {
main(): Object {
(new IO).out_string("1\n")
};
};
编译并运行此程序,它将在屏幕上输出“1”。
读取用户输入
接下来,我们学习如何读取用户输入的字符串。

IO对象有一个in_string方法,用于读取字符串。我们将读取的字符串与换行符连接,以确保输出格式整洁。

class Main {
main(): Object {
(new IO).out_string((new IO).in_string().concat("\n"))
};
};
编译并运行此程序。程序会等待用户输入。输入“1”则返回“1”,输入“42”则返回“42”。


字符串与整数的转换


阶乘计算需要处理整数,而非字符串。因此,我们需要将字符串转换为整数,计算后再转换回字符串。
Cool语言提供了一个名为A2I的库(ASCII to Integer),用于字符串和整数之间的转换。主类需要继承A2I的功能。
以下程序读取一个字符串,将其转换为整数并加1,然后再转换回字符串输出。
class Main inherits A2I {
main(): Object {
(new IO).out_string(
i2a(
a2i((new IO).in_string()) + 1
).concat("\n")
)
};
};

在编译时,需要同时指定主程序文件和A2I库文件。
coolc fact.cl A2I.cl

运行程序,输入“3”将输出“4”,输入“1”将输出“2”。

实现递归阶乘函数
现在,我们可以开始实现阶乘函数了。首先,我们定义一个fact方法,它接受一个整数参数并返回一个整数。


我们从一个简单的版本开始:让fact方法返回参数加1。

class Main inherits A2I {
main(): Object {
(new IO).out_string(
i2a(
fact(a2i((new IO).in_string()))
).concat("\n")
)
};
fact(i: Int): Int {
i + 1
};
};
编译并运行,输入“4”将返回“5”。
编写递归阶乘逻辑
阶乘的递归定义是:如果i等于0,则阶乘为1;否则,阶乘为i乘以(i-1)的阶乘。
在Cool中,我们使用if、then、else、fi关键字来构建条件语句。
class Main inherits A2I {
main(): Object {
(new IO).out_string(
i2a(
fact(a2i((new IO).in_string()))
).concat("\n")
)
};
fact(i: Int): Int {
if i = 0 then
1
else
i * fact(i - 1)
fi
};
};
编译并运行程序。输入“3”输出“6”,输入“6”输出“720”。递归阶乘函数工作正常。
实现迭代阶乘函数
作为练习,我们将递归阶乘函数改写为迭代版本,使用循环而非递归。
我们需要一个累加器变量来保存阶乘计算结果。在Cool中,使用let表达式声明局部变量。
循环使用while、loop、pool关键字构建。在循环体内,我们更新累加器和计数器。

class Main inherits A2I {
main(): Object {
(new IO).out_string(
i2a(
fact(a2i((new IO).in_string()))
).concat("\n")
)
};
fact(i: Int): Int {
let fact: Int <- 1 in {
while (not (i = 0)) loop
{
fact <- fact * i;
i <- i - 1;
}
pool;
fact;
}
};
};

注意,在Cool中,赋值使用<-操作符。let表达式的主体是一个代码块,其最后一个表达式的值(此处为fact)成为整个let表达式的结果。

编译并运行,迭代版本同样能正确计算阶乘。

常见错误:误用等号

来自C或Java背景的程序员可能习惯使用等号=进行赋值。但在Cool中,单个等号=是比较运算符,而非赋值运算符。

以下是一个错误示例:

fact(i: Int): Int {
let fact: Int <- 1 in {
while (not (i = 0)) loop
{
fact = fact * i; -- 错误:这是比较,不是赋值
i = i - 1; -- 错误:这是比较,不是赋值
}
pool;
fact;
}
};
此代码可以编译,但运行时会陷入无限循环。因为fact = fact * i和i = i - 1并没有修改变量的值,它们只是进行布尔比较,导致循环条件永远为真。
总结
本节课中,我们一起学习了如何在Cool语言中实现阶乘函数。我们从程序的基本骨架开始,逐步引入了输入输出操作、字符串与整数的转换。我们分别用递归和迭代两种方式实现了阶乘计算,并理解了它们的工作原理。最后,我们指出了一个常见的错误,即混淆赋值运算符<-和比较运算符=,这可能导致程序逻辑错误或无限循环。

通过本课,你应该对Cool语言的基本控制流、I/O操作和类型转换有了更深入的理解。

课程 P50:静态类型与动态类型 🧠

在本节课中,我们将要学习编程语言中两个核心概念:静态类型与动态类型。我们将探讨它们的定义、区别、目的以及它们如何影响程序的编写与执行。
概述

类型系统的主要美学目标之一是防止常见的编程错误。它们通过在编译时检查代码来实现这一点,并且是在不了解程序任何输入的情况下进行的。因此,检查的唯一依据就是程序文本本身。这就是我们称之为“静态”的原因,因为它不涉及任何动态行为。
上一节我们介绍了类型系统的静态检查特性,本节中我们来看看动态类型检查的概念。
静态类型与动态类型的定义
程序的实际执行行为发生在运行时。任何正确的静态类型系统都必须在编译时做出判断,但它无法完全精确地推理程序运行时可能发生的一切。这意味着,一些实际上能够正确运行的程序会被类型检查器禁止。
以下是两种不同的类型概念:

- 动态类型:指的是对象或值在运行时实际具有的类型。
- 静态类型:这是一个编译时的概念,是类型检查器所知道的关于对象的信息。
为了使静态类型检查器正确工作,静态类型和动态类型之间必须存在某种关系。这种关系可以通过一个定理来形式化证明。

类型正确性定理
我们想了解的是,对于编程语言中的每个表达式 e,其静态类型(编译器推断的类型)与动态类型(运行时实际值的类型)之间的关系。
一种表述是:如果你实际运行程序,得到的结果应该与静态类型检查器预期的结果一致。静态类型检查器应该能够正确预测运行时将出现的值。
在早期简单的编程语言中,定理可以表述为:对于每个表达式 e,其静态类型等于其动态类型。用公式可以表示为:
type_static(e) == type_dynamic(e)

然而,对于像 Cool 这样支持继承和子类型的语言,情况变得更加复杂。
Cool 语言中的类型示例
让我们看一个典型 Cool 程序的执行示例。这里有两个类:类 A 和继承自 A 的类 B。因此,B 是 A 的子类型,我们写作 B <: A。
class A {};
class B inherits A {};
-- 在某个方法中
let x: A <- new A in -- x 的静态类型是 A,此时动态类型也是 A
x <- new B; -- 执行后,x 的动态类型变为 B,但静态类型仍是 A
在这个例子中:
x的静态类型是A。这是编译器在整个x作用域内所知道并使用的类型。- 在运行时,由于赋值操作,
x可以持有不同类型的对象。因此,x的动态类型可以是A或B。
这是一个非常重要的区别:静态类型在编译时是恒定且已知的,而动态类型在运行时可以变化。

子类型化与类型安全
这意味着,Cool 类型系统的正确性定理比简单类型系统的定理更复杂。在存在子类型的情况下,我们想要的属性是:对于一个给定的表达式 e,其静态类型 S 必须是其所有可能动态类型 D 的“超类型”。用公式可以表示为:
对于所有可能的执行路径,type_dynamic(e) <: type_static(e)

这基于子类型的一个关键特性:如果 C' 是 C 的子类(C' <: C),那么 C' 的对象必须能用于任何期望 C 类型对象的上下文中。因为子类只会添加属性和方法,而不会移除或改变父类中已有方法的类型签名(尽管可以重写方法实现)。
这是许多面向对象语言的标准设计。
总结
本节课中我们一起学习了静态类型与动态类型的核心区别:
- 静态类型是编译时的概念,用于在程序运行前检查类型错误。
- 动态类型是运行时的概念,描述了值在内存中的实际类型。
- 在支持继承的语言(如 Cool)中,对象的静态类型(声明类型)可以是其动态类型(实际类型)的超类型,这通过子类型化(
<:)关系来保证类型安全。 - 类型系统的目标是减少错误,但静态类型系统可能会拒绝一些实际上能正确运行的程序,这是其设计上的权衡。

理解这两种类型系统有助于我们更好地理解不同编程语言的设计哲学,并写出更健壮的代码。

课程 P51:自类型 (Self Type) 🧬

在本节课中,我们将学习一个名为“自类型”的静态类型系统特性。我们将了解它要解决的问题、它的基本概念以及它如何增强类型系统的表达能力。

在上个视频中,我们讨论了静态和动态类型之间的区别,以及静态类型系统越来越具表现力的趋势。本节中,我们将通过“自类型”来具体感受这种更具表现力的类型系统。
一个简单的问题场景 🔍

首先,让我们通过一个简单的类定义来了解自类型要解决的问题。这里有一个 Count 类,它实现了一个计数器功能。
class Count:
i: int = 0
def increment(self) -> Count:
self.i += 1
return self
这个类有一个整数字段 i,初始化为 0。increment 方法将计数器加一并返回 self 对象。这可以被视为一个提供计数功能的基类。
继承带来的类型问题 ⚠️
现在,考虑定义一个 Count 的子类 Stock,用于跟踪库存物品。
class Stock(Count):
name: str

# 尝试使用
s: Stock = Stock()
s = s.increment() # 这里会出现类型错误!
这段代码在静态类型检查下会报错。原因是:increment 方法在父类 Count 中被声明为返回 Count 类型。即使 Stock 继承了该方法,其返回类型签名也不会改变,仍然是 Count。因此,尝试将 Count 类型的返回值赋给 Stock 类型的变量 s 会导致类型不匹配。
实际上,在运行时,s.increment() 返回的确实是 Stock 对象(因为返回的是 self)。但静态类型检查器丢失了这个信息,它只知道方法签名返回的是 Count。
自类型的引入 💡

为了解决这个问题,我们需要扩展类型系统。关键在于,increment 方法返回的是 self 对象,其类型应该与调用该方法的对象类型相同(可能是 Count,也可能是其任何子类型,如 Stock)。
为此,我们引入一个新的类型关键字:自类型 (Self Type)。我们将 increment 方法的返回类型改为 Self。

class Count:
i: int = 0
def increment(self) -> Self: # 使用 Self 类型
self.i += 1
return self

Self 类型表示方法的返回值具有与 self 参数相同的类型。当我们进行这个更改后,类型系统就能进行更精确的推理:
- 当在
Count对象上调用increment时,self类型为Count,因此返回类型也是Count。 - 当在
Stock对象上调用increment时,self类型为Stock,因此返回类型也是Stock。
这样,之前的 Stock 示例代码就能通过类型检查了。
关于自类型的重要说明 📝
需要明确两点:
- 自类型是静态类型:
Self是静态类型系统的一部分,用于在编译时进行更精确的类型推理,而不是一个运行时概念。 - 自类型不是类名:与
int、Count等类型不同,Self是一个特殊的类型占位符,其具体含义取决于使用的上下文(即哪个类的self)。

通过引入自类型,类型系统能够接受更多在逻辑上正确、但之前因类型信息不足而被拒绝的程序,从而增强了表达能力和实用性。


本节课中,我们一起学习了自类型 (Self Type)。我们从一个简单的继承问题出发,看到了静态类型系统在表达方法返回“自身类型”时的局限性。通过引入 Self 类型,我们允许方法的返回类型随调用者的实际类型而动态变化,从而解决了子类中继承方法返回类型不精确的问题,增强了类型系统的表达能力。

课程 P52:Self 类型操作详解 🧩

在本节课中,我们将深入探讨 Self 类型,并学习其核心操作。理解这些操作对于掌握 Self 类型在类型系统中的作用至关重要。

回顾与引入
上一节我们介绍了 Self 类型的基本概念。本节中,我们来看看如何通过定义子类型关系和上确界操作来形式化地处理 Self 类型。
让我们从回顾上节课的例子开始。我们有一个名为 Count 的类,它包含一个初始化为零的整数字段 i,以及一个名为 inc 的方法。
class Count {
int i = 0;
Self inc() {
this.i++;
return this;
}
}
inc 方法的返回类型是 Self。问题是,inc 实际返回的对象的动态类型是什么?答案是,它可以是 this 对象的动态类型。在一个大型程序中,可能有多个类继承自 Count,因此 inc 可以返回 Count 或其任何子类。

Self 类型的含义
让我们考虑一个通用情况。假设在类 C 中,有一个表达式 e 具有 Self 类型。那么表达式 e 的可能动态类型是什么?根据之前的讨论,e 的动态类型将是包含 Self 类型的类 C 的子类型。
这表明,Self 类型的含义实际上取决于其所在的上下文。在类 C 中,Self 类型意味着 C 的某个子类型。这引出了一个非常简单的类型规则:Self 类型是其所处类的子类型。

一个关键的想法是:Self 类型 C 是 C 的某个子类型。这有助于说明,Self 类型最好被视为一个类型变量,其取值范围覆盖了其出现所在类的所有子类。

子类型关系操作
为了将 Self 类型纳入类型系统,我们需要扩展两个核心操作:子类型关系和上确界操作。首先,让我们从子类型关系开始。
在我们的定义中,我们将使用一些类型 T 和 T',它们代表正常的类名,而不是 Self 类型。
以下是处理 Self 类型的子类型关系规则:
-
两边都是
Self类型:Self类型C是Self类型C的子类型。这很容易理解,因为我们可以为这个“变量”插入C的任何子类型,关系依然成立。 -
左边是
Self类型,右边是常规类型:Self类型C是T的子类型,当且仅当C是T的子类型。这是因为C是任何Self类型C可能代表的超类。

-
左边是常规类型,右边是
Self类型:常规类名T永远不是Self类型C的子类型。即使T是C的子类,我们也不能允许这种关系成立,因为Self类型C可以代表C的任何子类(比如另一个子类A),而T不一定是A的子类型。 -
两边都是常规类型:使用之前为正常类名定义的子类型规则,没有改变。
上确界操作
接下来,我们讨论上确界操作。上确界操作 lub(T, T') 返回比两个参数类型都“大”的最小类型。
以下是处理 Self 类型的上确界规则:
-
两个参数都是
Self类型:Self类型C和Self类型C的上确界就是Self类型C。 -
一个参数是
Self类型,另一个是常规类型:Self类型C和T的上确界是类C和T的上确界。这是因为C是Self类型可能成为的最大类型。 -
两个参数都是常规类型:使用之前定义的类名上确界规则,没有改变。
总结

本节课中,我们一起学习了 Self 类型的核心操作。
- 我们明确了
Self类型是其所在类的子类型变量。 - 我们定义了扩展后的子类型关系,明确了
Self类型与常规类型、以及Self类型自身之间如何进行比较。 - 我们定义了扩展后的上确界操作,说明了如何计算包含
Self类型的类型的最小上界。

理解这些操作是构建支持 Self 类型的健壮类型系统的关键。下一节,我们将利用这些规则来实际进行类型检查。

课程 P53:自类型 (Self) 的使用规则 🧩

在本节课中,我们将要学习 Scala 中自类型(Self 类型,通常写作 this.type 或 T 在上下文中)的具体使用规则。自类型是一个特殊的类型,它代表当前对象的精确类型,对于实现一些高级的面向对象模式非常有用。我们将详细探讨它可以在哪些地方使用,以及为什么在某些地方被禁止。
上一节我们介绍了自类型的基本概念,本节中我们来看看它在代码中的具体应用场景和限制。
自类型的基本规则 📏

自类型不是一个具体的类名,因此它不能出现在类定义中作为类名或继承的父类。然而,在声明属性时,属性的类型可以是自类型。
以下是自类型允许出现的位置:

- 属性声明:声明一个类型为自类型的属性是允许的。
class MyClass { val mySelf: this.type = this // 允许 } - 局部变量:在方法内部,使用
let或val绑定一个自类型的局部变量是允许的。 - 对象创建:使用
new T创建一个新的自类型对象是允许的,并且它会创建一个与当前self对象动态类型相同的新对象。
自类型的禁止场景 🚫
解析器检查自类型时,规则比实际更宽松。有些地方看似可以出现其他类型,但自类型不行。
以下是自类型不允许出现的位置:
- 类定义:自类型不能作为类的名称,也不能出现在
extends或with后面作为父类。 - 美学调度(Aesthetic Dispatch):在类似模式匹配或类型调度的场景中,命名的类型必须是一个具体的类名,不能是自类型。
- 方法参数类型:方法的形式参数类型不能是自类型。这是一个关键限制。
为什么方法参数不能是自类型? 🤔
上一节我们列出了规则,本节中我们重点理解为什么方法参数禁止使用自类型。这主要有两个原因。
首先,从类型系统的角度考虑。假设我们有一个方法调用 e.m(e'),其中实际参数 e' 的类型为 T0。根据方法调用规则,T0 必须是形式参数声明类型的子类型。如果允许形式参数为自类型 S,那么就需要证明 T0 <: S。然而,自类型 S 可以代表当前类及其所有子类型,我们通常无法证明一个具体的类型 T0 是这种“动态范围”类型的子类型,这会导致类型系统无法进行安全的推导。
其次,通过一个代码示例可以更直观地理解。考虑以下类结构:
class A {
def comp(other: this.type): Boolean = { ... } // 假设允许
}
class B extends A {
val b: Int = 10
override def comp(other: this.type): Boolean = {
this.b == other.b // 这里会出问题!
}
}
现在看使用代码:
val x: A = new B() // 静态类型A,动态类型B
x.comp(new A()) // 传递一个A的实例
类型检查时,由于 x 的静态类型是 A,参数 new A() 也是 A 类型,所以通过。但在运行时,由于 x 的动态类型是 B,会调用 B 类中的 comp 方法。该方法试图访问参数 other 的 b 字段,但传入的参数动态类型是 A,根本没有 b 字段,这将导致程序崩溃。
因此,禁止自类型作为方法参数类型,是保证类型安全和避免运行时错误的关键设计。
方法返回类型的特殊允许 ✅
与方法参数不同,方法的返回类型可以是自类型。这是允许且常见的,常用于实现链式调用(Fluent Interface)等模式。
考虑一个简单的方法定义:
def exampleMethod(x: SomeType): this.type = {
// ... 一些操作
this // 返回当前对象自身
}
这里,形式参数 x 的类型 SomeType 必须是具体类型,而返回类型 this.type 是允许的自类型。调用该方法后,返回值的精确类型与调用者对象的动态类型一致,这非常有用。

本节课中我们一起学习了 Scala 自类型(Self)的核心使用规则。我们明确了它可以在属性、局部变量和对象创建中使用,也可以作为方法的返回类型,以实现灵活的设计。同时,我们重点理解了它不能用于类定义、类型调度,尤其是不能作为方法参数类型的原因——这是为了维护类型系统的健全性和避免运行时错误。掌握这些规则,能帮助你在实践中更安全、有效地使用自类型这一强大特性。

课程 P54:自类型检查规则详解 🧩

在本节课中,我们将学习如何将自类型(Self Type)的概念融入 COOL 语言的类型检查规则中。我们将回顾现有的规则,并详细解释在引入自类型后,哪些规则需要调整以及如何调整。
回顾 COOL 类型检查规则

上一节我们介绍了自类型的基本概念,本节中我们来看看如何将其融入类型检查。
COOL 的类型检查规则在逻辑上表现为一系列判断句,用于证明在特定环境下,表达式具有某种类型。这个环境包括:
- 对象标识符的类型假设(记作
o)。 - 方法的签名和所属类(记作
m)。 - 当前进行类型检查的类(记作
c)。

引入“当前类 c”这一环境信息至关重要,因为自类型 SELF_TYPE 的具体含义依赖于它所在的类。回忆一下,我们用 SELF_TYPE_C 来表示在类 C 中出现的自类型。因此,在类型检查过程中,我们必须时刻跟踪当前所在的类 c,以便正确理解遇到的 SELF_TYPE。

融入自类型后的通用规则
在引入了自类型并扩展了子类型(<:)和最小上界(lub)操作的定义后,许多类型检查规则的形式可以保持不变。
例如,赋值规则看起来和之前一样:
Γ, o, m, c ⊢ e0 : T0
Γ, o, m, c ⊢ e1 : T1
T1 <: T0
------------------------------------ [ASSIGN]
Γ, o, m, c ⊢ e0 <- e1 : T1
但请注意,这里的子类型关系 T1 <: T0 现在使用的是包含了自类型的新定义。因此,这条规则现在同样适用于 SELF_TYPE 和普通类名。

需要调整的规则:动态分发
对于动态分发(即普通的方法调用),当方法的返回类型不是 SELF_TYPE 时,规则无需改变。这条规则的核心限制是:方法的返回类型不能是 SELF_TYPE。而这正是自类型想要突破的限制,以实现更丰富的表达能力。
因此,我们需要为返回类型是 SELF_TYPE 的情况制定新规则。以下是动态分发的新规则:
检查形如 e0.f(e1, ..., en) 的表达式:
- 在环境
(Γ, o, m, c)下,分别对表达式e0,e1, ...,en进行类型检查。 - 设
e0的类型为T0。 - 在类
T0中查找方法f,获取其签名(T1, ..., Tn) -> T_r。 - 检查每个实际参数
ei的类型是否与对应的形式参数类型Ti兼容(即满足子类型关系T_ei <: Ti)。 - 如果以上检查都通过,并且返回类型
T_r是SELF_TYPE,那么整个表达式的类型就是T0(即e0的类型)。

公式描述:
Γ, o, m, c ⊢ e0 : T0
Γ, o, m, c ⊢ e1 : T1'
...
Γ, o, m, c ⊢ en : Tn'
mbody(f, T0) = (x1:T1, ..., xn:Tn): SELF_TYPE
∀i. Ti' <: Ti
------------------------------------ [DISPATCH-SELF]
Γ, o, m, c ⊢ e0.f(e1, ..., en) : T0

关键点解析:
- 方法的正式参数类型不能是
SELF_TYPE,但实际参数的类型可以是。扩展后的子类型关系会处理这种情况。 - 如果
e0的类型就是SELF_TYPE_C(在当前类c中),那么在查找方法时,我们可以安全地将SELF_TYPE_C替换为C来进行查找。
需要调整的规则:静态分发

静态分发(即 e0@T.f(e1, ..., en))的规则也需要类似的更新。
当方法返回类型不是 SELF_TYPE 时,规则不变。当返回类型是 SELF_TYPE 时,规则如下:

检查形如 e0@T.f(e1, ..., en) 的表达式:
- 在环境
(Γ, o, m, c)下,分别对表达式e0,e1, ...,en进行类型检查。 - 设
e0的类型为T0,且必须满足T0 <: T(T是静态指定的类名)。 - 在类
T(而不是T0)中查找方法f,获取其签名(T1, ..., Tn): SELF_TYPE。 - 检查参数类型兼容性:
∀i. T_ei <: Ti。 - 如果检查通过,整个表达式的类型是
T0。
公式描述:
Γ, o, m, c ⊢ e0 : T0
T0 <: T
Γ, o, m, c ⊢ e1 : T1'
...
Γ, o, m, c ⊢ en : Tn'
mbody(f, T) = (x1:T1, ..., xn:Tn): SELF_TYPE
∀i. Ti' <: Ti
------------------------------------ [STATIC-DISPATCH-SELF]
Γ, o, m, c ⊢ e0@T.f(e1, ..., en) : T0
关键点解析:
- 这里的结果类型是
T0而不是T。因为即使我们静态调用了类T中的方法,self参数(即e0)的运行时类型仍然是T0。方法的返回类型SELF_TYPE指的是这个self参数的类型。

关于 self 和 new SELF_TYPE 的规则
此外,还有两条直接涉及自类型的简单规则:
self表达式:在类c中,self具有类型SELF_TYPE_C。------------------------------ [SELF] Γ, o, m, c ⊢ self : SELF_TYPE_Cnew SELF_TYPE表达式:在类c中,new SELF_TYPE创建一个类型为SELF_TYPE_C的对象。------------------------------ [NEW-SELF] Γ, o, m, c ⊢ new SELF_TYPE : SELF_TYPE_C

实现自类型检查的要点总结
本节课中我们一起学习了如何为包含自类型的 COOL 语言设计类型检查规则,以下是核心总结:
- 基础工作:扩展子类型和最小上界操作的定义是核心。完成这一步后,大多数类型规则无需修改。
- 主要更改点:需要为动态分发和静态分发中,方法返回类型为
SELF_TYPE的情况制定特殊规则。这两条规则的结果类型都是调用者表达式(e0)的类型T0。 - 使用限制:自类型只能在语言允许的少数几个地方(如
self、new SELF_TYPE、方法返回类型)使用。类型检查器必须确保它没有在其他地方被非法使用。 - 类型查找中的自类型:在分发规则的方法查找步骤中,我们可能会在某个类
T中找到返回类型为SELF_TYPE的方法。这个SELF_TYPE指的是类T中的自类型,与当前类型检查的类c可能无关。幸运的是,在 COOL 的规则中,我们不需要比较不同类上下文中的SELF_TYPE,避免了复杂的交叉处理。

自类型是一个增强类型系统表达能力的学术概念,它展示了类型检查不仅仅是简单的类型匹配,还涉及复杂的上下文推理。在实际语言设计中,需要在表达能力和类型系统的复杂性之间取得平衡。通过本课程,我们深入理解了这一复杂但强大的特性是如何被整合到形式化类型检查框架中的。

课程 P55:类型检查中的错误恢复 🛠️

在本节课中,我们将结束关于类型检查的系列讨论,重点学习如何从类型检查过程中遇到的错误中恢复。我们将探讨两种主要的恢复策略,并分析它们的优缺点。
与词法分析、语法分析等所有前端阶段一样,从类型检查中恢复错误非常重要。但与语法分析不同,从类型检查器中恢复错误要容易得多,因为我们已经有了抽象语法树,因此无需像在语法分析之前那样跳过代码的部分。
在知道程序的结构存在问题之前,类型检查器通过结构归纳工作,它不能停止。因此,如果我们发现某个子表达式没有可以赋予它的有意义的类型,我们仍然必须对它进行处理,以便可以继续类型检查它周围的表达式。
策略一:使用 object 类型进行恢复
一种可能性是简单地分配类型 object 作为任何错误类型表达式的类型。

这里的直觉是,即使我们无法确定表达式的类型应该是什么,可以肯定的是,它是某种 object 的子类型。因此,将任何表达式分配为 object 类型肯定是安全的。
让我们考虑这种策略在简单代码片段中的应用。
以下是示例代码片段:
int y = x + 2;
我们假设这里的 x 未定义。实际上代码中有一个错误,那就是 x 没有绑定,所以 x 没有任何类型。
那么当我们类型检查这个时会发生什么?我们将递归地向下遍历抽象语法树,最终会到达叶子节点并尝试类型检查 x。然后我们会发现 x 没有任何地方有类型,这将导致一个错误消息,说 x 未定义。
为了继续类型检查以恢复,我们将不得不分配一个类型。因此,我们将假设 x 的类型为 object,因为那是我们的恢复策略。
然后我们将继续类型检查。当我们向上遍历抽象语法树时,接下来将尝试类型检查这个加法操作。我们将看到我们正在将类型为 object 的东西添加到整数。当然,加法不适用于类型为 object 的东西。
因此我们将得到一个错误,类似于“加法应用于 object”。然后我们现在必须决定,既然我们不能类型检查这个加法,那么 x + 2 的类型是什么?当然,我们的恢复策略是,它也有类型 object。
现在抽象语法树中的下一个部分是这里的初始化赋值。我们将 y 赋值为这个表达式的结果,但我们无法类型检查这个表达式,所以它具有 object 类型。现在,类型检查器看到,我们将类型为 object 的东西赋值给类型为 int 的东西。我们得到了第三个错误,说我们有一种错误的赋值。
所以这里的问题是,这种简单的恢复策略虽然奏效,如果我们恢复,我们继续类型检查,但一个错误可能引发更多错误。这是一个可行的解决方案,它实现了恢复目标,但通常会导致连锁错误。一旦有一个类型错误,该类型错误将导致更多错误,因为 object 类型的东西能做的操作不多,而代码可能假设更特定的类型。这些错误将向上传播至抽象语法树。
策略二:引入 Untyped 类型
另一种可能是引入新类型,专为错误类型表达式设计的节点类型 Untyped。Untyped 并不特殊,不是程序员可用的类型,仅编译器可用,用于错误恢复和类型检查。

Untyped 的特殊性质是,它将是所有其他类型的子类型。如果你记得,object 是相反的,object 是所有类型的超类型。object 有坏处,因为 object 上定义的方法很少。所以,如果你将类型 object 插入到期望其他类型的地方,很可能类型检查不会通过。
我们可以通过引入 Untyped 来解决这个问题。Untyped 将有特殊属性,即每个操作都定义于 Untyped。此外,我们将说它产生 Untyped 作为结果。所以,语言中任何接受类型参数的操作,如果参数是 Untyped,将产生类型结果为 Untyped。因此 Untyped 类型将向上传播。
现在让我们看看相同的代码片段,分析一下如果我们使用 Untyped 类型会发生什么。
我们再次遍历抽象语法树,到达这个叶子 x。我们看到 x 未定义,我们产生一个错误,说 x 未定义。然后需要给 x 赋类型,因此我们说 x 的类型为 Untyped。
现在考虑加法操作。加法接受类型为 Untyped 和整数,这样不错,不会报错,被认为是类型正确,结果也为 Untyped 类型。
现在进行赋值。Untyped 与任何类型不兼容,Untyped 不是任何类型的子类型。此赋值也类型正确,该阶段也不会报错。
所以你可以看到,Untyped 类型向上传播到抽象语法树,就像 object 类型之前一样。但由于 Untyped 是一种特殊类型,仅用于错误恢复,我们可以将其与其他常规类型区分开来。我们知道不应该在产生第一个错误消息后打印出后续的错误消息。
两种策略的对比与选择
真正的编译器,生产编译器将使用类似 Untyped 的东西进行错误恢复。但 Untyped 存在实现问题。

特别是,Untyped 是所有其他类的子类型这一事实,意味着类层次结构不再是树。如果你考虑一下,你有一个 object 在顶部,然后有一个树形结构向外分支。但 Untyped 是所有类型的子类型,所以 Untyped 成为底部元素。现在是一个有向无环图,而不是树。
这使得实现稍微困难一些,而不是只能使用树算法。现在你必须有,要么为 Untyped 设置特殊情况,要么做更一般的事情,这只是额外的麻烦。
我个人认为不值得为课程项目做。我建议你使用 object 解决方案。

本节课中我们一起学习了类型检查中的错误恢复。我们介绍了两种主要策略:使用 object 类型和使用 Untyped 类型。object 策略简单但可能导致连锁错误;Untyped 策略更优雅但实现更复杂。对于课程项目,建议采用简单的 object 类型恢复策略。

课程 P56:运行时组织基础 🧠

在本节课中,我们将要学习编译器后端工作的起点——运行时系统。我们将探讨程序在内存中是如何被组织和管理的,这是理解代码生成和程序优化的基础。

概述:从编译器前端到后端
上一节我们介绍了编译器前端的全部工作,包括词法分析、语法解析和语义分析三个阶段。这三个阶段共同完成了检查程序是否符合语言定义的任务。
一旦前端工作完成且没有发现错误,程序就被确认为有效的。此时,编译器的工作重心转向生成可执行代码,这属于后端的范畴。代码生成是后端的一部分,另一大部分是程序优化。
但在讨论如何生成代码之前,我们需要先明确要生成什么。因此,我们首先需要理解翻译后的程序及其在运行时的组织结构。

为什么需要了解运行时组织? 🤔
理解运行时组织至关重要,因为它定义了代码生成的目标。我们需要先知道程序在内存中如何布局,然后才能讨论生成这些布局的算法。这是一个有成熟标准技术的领域,本课程将涵盖这些内容,并鼓励你在项目中使用。

核心概念:静态结构与动态结构
本系列视频的核心内容是运行时资源管理。其中,最关键的是理解静态结构与动态结构的区别:
- 静态结构:在编译时就存在并确定的结构。
- 动态结构:在程序运行时才创建和存在的结构。
清晰地区分编译器所做的工作(编译时)和生成的目标程序所做的工作(运行时),是真正理解编译器原理的关键。我们还将讨论存储组织,即内存如何被用来存储程序运行时的各种数据。

程序如何开始运行? 🚀
最初,只有操作系统在机器上运行。当用户要求运行一个程序时,会发生以下步骤:
- 操作系统为程序分配内存空间。
- 程序的代码被加载到该内存空间中。
- 操作系统执行跳转,指向程序的入口点(例如
main函数)。 - 你的程序开始运行。

内存布局一览
当操作系统开始执行一个编译好的程序时,内存的组织大致如下图所示。我们通常将内存画成一个矩形,低地址在顶部,高地址在底部,这只是一种绘图惯例。
+------------------+ 低地址
| 代码区 |
| (程序指令) |
+------------------+
| |
| 数据区 |
| (程序数据) |
| |
+------------------+ 高地址

- 整个矩形代表操作系统分配给该程序的所有内存。
- 其中一部分空间包含程序的代码(即编译后的机器指令),通常位于内存空间的一端(例如低地址端)。
- 剩余的大部分空间则用于存储程序的数据。
关于图示的说明:这些内存布局图都是简化的。在实际的虚拟内存系统中,并不能保证这些区域在物理上是连续的。但这种简化有助于我们理解不同类型的数据及其管理方式。
代码与数据的协同设计
回到运行时组织图,我们有存放代码的区域和存放数据的区域。代码生成的难点在于,编译器需要同时负责:
- 生成操作代码。
- 编排数据布局。

编译器必须决定数据在内存中如何摆放,并生成能够正确操作这些数据的代码。代码中会包含对数据的引用。因此,代码生成和数据布局必须共同设计,以确保生成的程序能够正确运行。
实际上,程序中不止有一种类型的数据。在下一个视频中,我们将深入探讨不同种类的数据,以及数据区内不同区域的区别。
总结

本节课中,我们一起学习了运行时组织的基本概念。我们了解到编译器后端的工作始于对程序运行环境的理解,关键是要区分编译时的静态结构和运行时的动态结构。程序启动时,其内存被划分为代码区和数据区,而编译器的核心任务之一就是协同设计代码生成与数据布局,为后续的代码生成算法打下基础。

课程 P57:运行时结构与过程激活 🧠

在本节课中,我们将学习代码生成中的核心概念——过程激活。我们将探讨如何通过理解程序的运行时结构,来生成既正确又高效的代码。课程将从激活的基本定义开始,逐步深入到其实现方式,特别是如何利用栈来管理激活。
概述:代码生成的两个目标
在代码生成中,我们有两个总体目标。
第一个目标是正确生成代码,即忠实实现程序员的程序。
第二个目标是高效,生成的代码应充分利用资源,特别是要运行快速。
这两个目标需要同时解决。
如果只关心正确性,生成代码很简单,但可能很慢。
如果只关心速度而不关心正确性,生成代码更容易,但会得到错误答案。
因此,代码生成的所有复杂性都来自于试图同时解决这两个问题。
一个复杂的框架已经发展出来,用于说明如何生成代码和构建运行时结构以实现这两个目标。
谈论它的第一步是谈论激活。

基本假设
我们将对生成代码的编程语言类型做出两个假设。
第一个假设是执行是顺序的。
给定我们执行了一个语句,下一个将被执行的语句很容易预测。
实际上,它只是我们刚刚执行的语句的函数。
因此,控制将从程序中的一个点移动到另一个点,遵循某种明确的顺序。
第二个假设是当过程被调用时,控制将始终返回调用点后的点。
也就是说,如果我执行一个过程 F,一旦 F 完成,执行控制将始终返回调用 F 的点的下一句。
当然,存在违反这些假设的编程语言和特性。
违反假设一的最重要的编程语言类别是那些具有并发性的。
在并发程序中,仅仅因为我执行了一个语句,没有简单的方法可以预测下一个将被执行的语句,因为它可能在完全不同的线程中。
对于假设二,高级控制结构如异常和 call/cc(如果你知道它是什么)会违反假设。
特别是,在 Java 和 C++ 中抛出异常时,异常可能在被捕捉前逃逸多个过程。
因此,当你调用一个过程时,无法保证该过程抛出异常后控制会立即返回过程后的点。
本课余下部分将使用这些假设。
未来视频中会简略讨论如何适应这些高级特性。
我们将涵盖的内容是所有实现的基础,即使有并发和异常的语言,也基于我们将讨论的想法。

激活与生命周期的定义
首先定义,当我们调用过程 p 时,将称其为过程 p 的激活。
过程 p 激活的寿命,将是执行过程 p 所涉及的所有步骤,包括 p 调用的所有步骤。
所以将是所有从 p 被调用至返回的所有语句,包括所有 p 自身调用的函数或过程。

我们可以定义变量的类似生命周期。
所以变量 x 的生命周期,将是 x 被定义的执行部分。
这意味着从 x 首次创建,至被销毁或分配的所有执行步骤。
注意,生命周期是动态的,这适用于正在执行的程序。
我们讨论的是变量首次存在的时刻,直到它消失并超出范围的时刻。
另一方面,作用域是一个静态概念,它指的是程序文本中变量可见的部分。
这与变量的生命周期是完全不同的概念。
再次强调,在脑海中区分运行时(动态)和编译时(静态)发生什么是很重要的。
激活的嵌套与激活树

结合我们之前的假设,我们可以做一个简单观察:当过程 p 调用过程 q 时,q 将在 p 返回之前返回。
这意味着过程的生命周期将正确嵌套。
此外,这意味着我们可以用激活树来形象地表示这种嵌套关系。
以下是一个简单的例子来说明激活树。

// 示例程序
class Example {
void main() {
g();
f();
}
void f() {
g();
}
void g() {
// 做一些事情
}
}
对于这个程序,第一个激活(激活树的根)是 main 方法。
main 将调用方法 g,g 的生存期完全包含在 main 的执行期间。
因此,我们可以使 g 成为 main 的子节点,表明 main 调用 g。
g 返回后,main 将调用 f,因此 f 也将是 main 的子节点。
然后 f 本身将再次调用 g,所以它将有一个 g 的另一个激活作为子节点。
这棵树说明了若干件事:
- 它显示了生存期的包含(例如,
g的生存期包含在main内)。 - 它显示了一些生存期关系(例如,
g的激活和f的激活的生存期完全不重叠,因为它们是树中的兄弟节点)。 - 激活树中可以有相同方法的多次出现(每次方法被调用都是一个单独的激活)。
这是一个涉及递归函数的更复杂例子:
// 递归示例
class RecursiveExample {
void main() {
f(3);
}
void f(int n) {
if (n == 0) {
g();
} else {
f(n - 1);
}
}
void g() {
// 做一些事情
}
}
激活树如下:
- 根节点:
main main的子节点:f(3)f(3)的子节点:f(2)f(2)的子节点:f(1)f(1)的子节点:f(0)f(0)的子节点:g()
请注意,程序的同一运行中可以有多个过程激活,这仅仅表明同一个过程可以被多次调用。
递归过程将导致激活的嵌套(相同的函数调用自身)。
使用栈管理激活
由于激活是正确嵌套的,我们可以使用栈来实现或跟踪当前活动的激活。
栈不会跟踪整个激活树,它只会跟踪当前正在运行的激活。
在程序的每一步,栈应包含所有当前活动或运行的激活。
让我们用之前的非递归例子,看看如何使用栈来跟踪激活。
执行步骤与栈的变化:
- 开始执行
main。栈:[main] main调用g。将g推入栈。栈:[main, g]g执行完毕返回。将g弹出栈。栈:[main]main调用f。将f推入栈。栈:[main, f]f调用g。将g推入栈。栈:[main, f, g]g执行完毕返回。将g弹出栈。栈:[main, f]f执行完毕返回。将f弹出栈。栈:[main]main执行完毕返回。将main弹出栈。栈:[]

核心操作:
- 过程调用:为该过程在栈上推入一个激活。
- 过程返回:从栈中弹出该激活。
由于激活的生命周期正确嵌套,这种方法将完美工作。
运行时内存组织

现在,让我们将激活栈的概念放入程序的运行时内存组织中。
你可能还记得,我们为程序分配了一块内存。
典型的内存布局如下:
+-------------------+
| 代码区 | <-- 程序本身的代码
+-------------------+
| 数据区 | <-- 全局/静态数据等
+-------------------+
| 堆区 | <-- 动态分配的内存(向上增长)
+-------------------+
| 栈区 | <-- 激活栈(向下增长)
+-------------------+
栈区通常在代码和数据区之后开始,并向程序内存空间的另一端(通常是低地址方向)增长。
- 当过程被调用时,栈将增长(推入新的激活记录)。
- 当过程返回时,栈将收缩(弹出激活记录)。
正如我们将看到的,数据区还有其他内容(如全局变量),但激活栈是管理过程调用和局部数据的核心运行时结构。
总结
本节课中,我们一起学习了代码生成中关于运行时结构的基础——过程激活。
我们首先明确了代码生成正确性与高效性的双重目标。
然后,基于顺序执行和控制流返回的假设,定义了激活和生命周期这两个动态概念,并与静态的作用域进行了区分。
我们了解到,由于过程调用的嵌套特性,激活的生命周期也正确嵌套,这可以用激活树来形象表示。
更重要的是,这种嵌套特性使得我们可以使用栈这一数据结构来高效地管理正在运行的激活。
最后,我们将激活栈置于程序的运行时内存布局中,看到它通常与代码区、全局数据区和堆区共同构成程序的内存空间,并通过增长和收缩来响应过程的调用与返回。

理解过程激活和栈机制,是后续学习活动记录(或栈帧)、参数传递、局部变量存储等更具体实现细节的基石。🚀

课程 P58:激活记录详解 📚


在本节课中,我们将要学习程序执行过程中的一个重要概念——激活记录。我们将了解什么是激活记录、它包含哪些信息、以及它在函数调用和返回过程中扮演的角色。
什么是激活记录?📖

上一节我们介绍了函数激活的概念,本节中我们来看看管理这些激活所需的信息。
激活记录是管理过程激活所需的所有信息。这通常也称为帧,与激活记录是完全相同的概念,只是同一事物的两个不同名称。
关于过程激活的一个有趣事实是,它们包含的信息比你预期的要多。特别是,当过程 f 调用过程 g 时,g 的激活记录不仅包含关于 g 的信息,也经常包含关于调用函数 f 的信息。通常,过程的激活记录将包含关于该过程的信息,以及关于调用它的过程的信息。

为什么需要激活记录?🤔
到目前为止,我们还没有说过为什么要保留关于激活的信息。原因是每个过程都有一个与之相关的状态(激活),为了正确执行该过程,我们必须在某处跟踪它。这就是激活记录的作用,它将用于存储正确执行过程所需的信息。
让我们更详细地看一下。考虑过程 f 调用过程 g 的情况。概念上,当 f 调用 g 时,f 被暂停。f 将在 g 运行时停止执行,所以 g 将使用处理器和机器的所有资源。但当 g 完成时,我们希望再次执行 f,f 将恢复。所以在中间,当 g 运行时,我们必须将过程 f 的状态(激活)保存在某个地方,以便我们正确地恢复它。这又是激活记录的作用。
因此,g 的激活记录将必须包含信息,这将帮助我们完成 g 的执行(所以会有一些关于 g 的信息),但 g 的激活记录还必须存储我们需要能够恢复过程 f 执行的任何东西。
一个具体的例子 🔍
以下是我们在上一期视频中看到的一个程序,这是过程 f 的具体激活记录设计。
我们将有一个位置用于 f 的结果,这将持有 f 执行完成后返回的值。这里有一个位置用于 f 的参数(f 只接受一个参数,所以只需要一个字来存储)。将有一个控制链接,指向前一个(调用者)的激活。我们还将有一个用于返回地址的插槽,即内存中的地址,或我们应在 f 执行完成后跳转到的指令的地址。
现在让我们手动执行这个程序,并计算出栈上的激活记录将是什么样子。
当程序首次被调用时,它将调用 main,将有一个 main 的激活记录。然后 main 将调用 f。当 main 调用 f 时,一个激活记录将被推入栈中,它有四个字段:

- 结果:函数刚开始运行,此处暂无内容,将在
f返回时填充。 - 参数:这将是数字
3。 - 控制链接:这将指向
main的激活。 - 返回地址:这取决于函数被调用的位置。在
main中调用f后,我们希望返回到调用f后的指令地址(标记为*)。
然后 f 的主体执行,参数 3 不是 0,因此我们最终会再次调用 f。这意味着另一个激活记录将被推入堆栈,它也有四个插槽:
- 结果:最初为空。
- 参数:这将是
2。 - 控制链接:这将指向
f的前一个激活。 - 返回地址:这将是
f内部条件语句结束后需要返回的地址(标记为**)。

在两次调用 f 之后,堆栈将呈现这种特定的激活记录设计。
激活记录在内存中的布局 🧱
这个激活记录堆栈,并不是像你在数据结构课上学到的那种抽象堆栈。运行时系统将它们视为明确的激活记录,但它们也像一个巨大的数组,所有这些数据都只是连续地排列在内存中。这些都是连续的地址,一个激活记录紧接着在先前激活记录的下一个地址之后。
编译器作者将经常利用这些激活在内存中相邻的事实来设计一些技巧。
总结一下这个例子的亮点:
main的激活记录(包含参数、结果等)在本例中并不重要,我们主要关注f的激活记录。- 示例中使用的
*和**都是实际的内存地址,指向代码中调用f后需要继续执行的指令地址。 - 这真的只是许多可能激活记录设计中的一种。你可以为
f设计不同的激活记录,其中包含不同的信息。这取决于其余代码生成器和运行系统的结构。

关于激活记录的重要一点是,它只需要包含足够的信息,以使生成的代码能够正确执行被调用的过程,以及恢复调用程序的执行。
函数返回时发生了什么?🔄
到目前为止我们只看了程序调用时的激活记录,我们还没有谈论激活返回时会发生什么。

让我们考虑在我们的例子中会发生什么。在第二次调用 f 之后,栈顶的激活(参数为 2 的那个)返回。此时,调用者(参数为 3 的那个激活)成为当前激活,即栈的新顶部。

有趣的是要注意,虽然一个激活已恢复为当前过程,但之前被“弹出”的数据(如下层激活的结果)实际上仍在内存中。当 f 再次执行时,它需要查找该结果,以了解被调用过程的结果。
将返回值放在帧首位的优点是,调用者可以从自己的帧的固定偏移处找到它。例如,当第二次调用 f 返回,第一个调用已恢复执行时,该调用的代码知道此激活记录的大小为 4 个字。因此它可以找到被调用过程的结果,在偏移 4 个字(即第 5 个字)的位置。即使该数据已从逻辑栈中弹出,我们仍可以立即读取函数调用结果,并在调用过程继续执行中使用它。

设计考量与总结 🎯

再次强调,激活记录的组织方式绝对没有魔力。我们可以重新排列帧中元素的顺序,可以不同地分配调用者和被调用者的责任。实际上,唯一的衡量标准是,如果一个组织能导致更快的代码或更简单的代码生成器,那么它就更好。

我之前也提到过,但在生产编译器中也是一个重要点:我们将尽可能多地将帧内容放入寄存器中。特别是,将方法结果和方法参数传递到寄存器中,因为这些被频繁访问。

最后,总结我们对激活和激活记录的讨论。核心问题是编译器必须在编译时确定激活记录的静态布局,并且还需要生成正确访问该激活记录位置的代码。这意味着激活记录布局和代码生成器必须一起设计。你不能只设计你的代码生成器,然后后来再决定你的激活记录布局将会是什么,反之亦然。这两件事需要一起设计,因为它们相互依赖。

本节课中我们一起学习了激活记录的概念、作用、内存布局以及在函数调用与返回过程中的行为。理解激活记录是理解程序运行时行为、栈管理和编译器代码生成的关键一步。

课程 P59:运行时组织 - 全局变量与堆 🧠
在本节课中,我们将继续探讨程序的运行时组织,重点讲解编译器如何处理全局变量和堆这两种数据结构。理解这些概念对于掌握程序如何在内存中运行至关重要。


全局变量与静态分配 🌍
上一节我们介绍了栈和激活记录,本节中我们来看看全局变量。全局变量的核心特性是:程序中的所有引用都指向同一个对象。这意味着它们不能被存储在激活记录中,因为激活记录会在过程调用结束后被释放。
因此,全局变量通过静态分配来实现。以下是其关键点:
- 固定地址:所有全局变量在程序启动时被分配一个固定的内存地址。
- 编译时决定:编译器在编译阶段就决定了这些变量的存储位置。
- 生命周期:它们在程序的整个执行期间都存在于这个固定位置。
除了全局变量,某些语言中的其他值(如静态局部变量)也可能采用静态分配,其行为与全局变量一致。

运行时组织图的更新 🗺️
引入了全局变量后,我们的运行时内存组织图需要更新。

内存布局现在如下所示:
[ 代码区 | 静态数据区(全局变量等) | 堆 | ...空闲内存... | 栈 ]
- 代码区:存放程序指令。
- 静态数据区:紧接着代码区,存放全局变量等静态分配的数据。
- 栈:从内存的另一端(通常是高地址端)开始,向低地址方向增长,用于存储激活记录。
- 堆:位于静态数据区之后,向高地址方向增长,用于动态分配的数据。
栈的起始位置现在是静态数据区的末尾。
堆的引入与必要性 📦
现在转向堆。任何比创建它的过程生命周期更长的值,也不能存储在激活记录中。

考虑以下伪代码场景:
Object foo() {
Object bar = new Object(); // 动态分配一个对象
return bar; // 返回这个对象
}
在 foo 函数的激活记录中分配 bar 对象是不行的。因为当 foo 返回时,其激活记录被释放,bar 对象也会随之消失。但 bar 需要作为返回值,在 foo 调用结束后依然可用。
因此,动态分配的数据必须存储在激活记录之外。支持动态数据分配的语言(如 Java, C++ 等)通常使用堆来实现这一点。
内存区域总览与堆栈协作 🤝
此时,语言实现需要管理几种不同的内存区域:
- 代码区:存放程序指令。通常是只读的。
- 静态数据区:存放全局变量等静态分配、固定大小的数据。
- 栈:用于存储每个当前活跃过程的激活记录。每个激活记录大小固定,包含局部变量和临时数据。
- 堆:用于存储所有不属于以上类别的数据,即动态分配、生命周期不确定的对象。
在像 C 这样的语言中,堆由程序员通过 malloc 和 free 显式管理。在 Java 等语言中,使用 new 进行分配,并由垃圾回收器自动管理回收。

堆和栈都在增长,因此必须防止它们相互覆盖。一个经典且高效的解决方案是:让堆和栈从内存的两端开始,向中间方向增长。
堆栈协作的详细机制 ⚙️
让我们回顾并细化这个运行时组织图:
低地址
|
[ 代码区 ]
[ 静态数据区 ]
[ 堆 ] ---> (向高地址增长)
|
| (空闲内存)
|
[ 栈 ] <--- (向低地址增长)
|
高地址
- 堆的增长:堆从静态数据区末尾开始,向高地址增长。它的大小会随着
new或malloc而增加,也可能随着垃圾回收或free而减少。 - 栈的增长:栈从内存高端开始,向低地址增长,随着函数调用和返回而伸缩。
- 协作与溢出:系统维护两个指针:
- 堆指针:指向下一个可分配堆内存的地址。
- 栈指针:指向当前栈顶(下一个栈帧将分配的位置)。
只要这两个指针不相等(即没有相遇),程序就有内存可用。如果它们相遇,则意味着内存耗尽,运行时系统可能报错或尝试回收内存。
这种设计的优势在于,它能自动适应不同程序的内存使用模式(需要大堆小栈,或大栈小堆),只要堆和栈的总和不超过程序可用的总内存。
总结 📚
本节课中我们一起学习了:
- 全局变量通过静态分配实现,在编译时获得固定地址,生命周期贯穿整个程序。
- 堆用于存储动态分配、生命周期不确定的数据,解决了数据比其创建过程存活更久的问题。
- 程序运行时内存被划分为代码区、静态数据区、堆和栈。
- 堆和栈通常从内存两端向中间增长,通过两个指针的协作来高效共享内存空间,并检测内存溢出。

理解全局变量和堆的管理机制,是深入理解程序内存布局和运行原理的关键一步。

Cool 语言教程 P6:一个综合示例 🧩

在本节课中,我们将通过一个综合性的示例程序来结束对 Cool 语言的概述。这个程序将操作一个有趣的数据结构——链表,并演示如何定义类、方法、处理多种数据类型以及使用 case 表达式进行类型分发。
程序概览与基础结构
首先,我们创建一个名为 list.cl 的新文件,并定义主类和 main 方法。为了让程序能够进行输入/输出操作,我们的主类将继承自 IO 类。
class Main inherits IO {
main() : Object {
{
-- 程序主体将在这里编写
}
};
};

构建字符串列表
上一节我们介绍了程序的基本结构,本节中我们来看看如何手动构建一个字符串链表。我们将首先定义几个字符串变量,然后将它们连接并打印出来。
以下是使用单个 let 表达式进行多个变量绑定的方法:
let hello : String <- "Hello",
world : String <- "World",
newline : String <- "\n"
in
out_string(hello.concat(world.concat(newline)))
注意:在 let 表达式中,多个绑定之间使用逗号 , 分隔,而不是分号。
定义链表抽象
现在,我们不直接连接字符串,而是创建一个链表抽象。链表中的每个节点包含一个数据项(item)和一个指向下一个节点的指针(next)。
以下是 List 类的定义,包含一个初始化方法 init:
class List {
item : Object; -- 存储任意类型的对象
next : List; -- 指向下一个链表节点
-- 初始化方法,设置 item 和 next,并返回对象本身
init(i : Object, n : List) : List {
{
item <- i;
next <- n;
self; -- 返回初始化后的对象本身
}
};
-- 其他方法将在这里定义
};
使用链表并实现展平功能
有了链表类,我们可以在 main 方法中构建一个包含三个字符串的链表。链表的末尾需要一个空指针,在 Cool 中,我们通过声明一个未初始化的 List 类型变量来实现,其值为 void。
以下是构建链表并调用展平方法的代码:

let hello : String <- "Hello",
world : String <- "World",
newline : String <- "\n",
nil : List, -- 一个空指针,用于链表末尾
mylist : List <- (new List).init(hello,
(new List).init(world,
(new List).init(newline, nil)))
in
out_string(mylist.flatten())

接下来,我们需要在 List 类中实现 flatten 方法。该方法将链表展平为一个字符串。逻辑是:如果当前节点是最后一个(next 为 void),则返回其 item 的字符串表示;否则,返回当前 item 的字符串表示与剩余链表展平结果的连接。

flatten() : String {
if isvoid(next) then
-- 处理最后一个元素
else
-- 连接当前元素和剩余链表
fi
};

处理多种数据类型
目前,我们的链表只能很好地处理字符串。为了让它能处理任何类型的对象(如整数),我们需要在 flatten 方法中根据 item 的实际类型进行不同的操作。Cool 语言提供了 case 表达式来实现运行时类型检查。
以下是使用 case 表达式改进后的 flatten 方法核心逻辑:

flatten() : String {
let s : String in
{
-- 根据 item 的类型决定如何获取字符串表示
s <- case item of
i : Int => i2a(i); -- 如果是整数,转换为字符串
s : String => s; -- 如果是字符串,直接使用
o : Object => { -- 其他类型(默认情况)
abort(); -- 终止程序
""; -- 仅为满足类型检查,返回空字符串
};
esac;
-- 判断是否是链表末尾
if isvoid(next) then
s
else
s.concat(next.flatten())
fi;
}
};
注意:为了使 i2a(整数转字符串)方法可用,List 类需要继承自 A2I 类。
测试泛型链表
最后,我们可以测试这个泛型链表。在 main 方法中,我们可以构建一个包含字符串和整数的链表。
let mylist : List <- (new List).init("Hello",
(new List).init("World",
(new List).init(42, -- 插入一个整数
(new List).init("\n", nil))))
in
out_string(mylist.flatten())
编译并运行程序,它应该能成功输出 “HelloWorld42”。
本节课中我们一起学习了如何用 Cool 语言构建一个泛型链表数据结构。我们涵盖了类的定义、方法的实现、let 表达式的多绑定、递归方法调用以及使用 case 表达式处理多种数据类型。通过这些练习,你应该对 Cool 语言的核心特性有了更深入的理解。要了解更多细节和未在本课展示的特性,可以参考 Cool 语言示例目录中的其他程序。

课程 P60:内存对齐详解 🧱
在本节课中,我们将学习计算机体系结构中一个非常底层但至关重要的细节——内存对齐。理解对齐对于编写高效且正确的程序至关重要。


现代机器的属性回顾
上一节我们引入了对齐的概念,本节中我们来看看为什么对齐如此重要。首先,我们需要回顾现代计算机的一些基本属性。
目前大多数现代机器是32位或64位的。这意味着机器的“字长”是32位或64位。字是机器处理数据的基本单位,它可以被细分为更小的单位。
以下是关于内存组织的基本事实:
- 一个字节有8位。
- 4个字节(32位)或8个字节(64位)组成一个字,具体取决于机器。

另一个关键属性是机器的寻址方式。机器可以是“字节寻址”或“字寻址”。
- 在机器码中,字节寻址的机器可以引用内存中的单个字节。
- 字寻址的机器可能只能以整个字为单位来命名或引用内存。
什么是数据对齐?🎯
理解了字和字节的关系后,我们现在可以正式定义“数据对齐”。
我们说数据是字对齐的,当且仅当它从一个字的边界开始。为了更直观地理解,让我们想象一下内存的组织方式。
假设这是一台32位机器,因此4个字节构成一个字。内存被划分为连续的字节。一个字从这里开始,下一个字从这里开始。
- 如果一块数据被分配在字的边界上(例如,占据这连续的4个字节),那么它就是字对齐的。
- 如果一块数据开始于一个字的中间(例如,从这个位置开始),那么它就不是字对齐的。
为什么对齐很重要?⚠️

对齐之所以重要,是因为机器本身有对齐限制。这些限制主要带来两类问题:
-
正确性问题:有些机器严格要求数据必须正确对齐。如果你试图访问一个未按机器要求方式对齐的数据,机器可能无法执行该指令,导致程序甚至系统挂起。因此,未正确对齐的数据会导致程序无法正确运行。
-
性能问题:另一些机器虽然允许数据任意存放,但需要付出巨大的性能代价。访问字边界对齐的数据通常比访问非对齐的数据要快得多。这种性能惩罚往往是巨大的,访问错位数据的速度可能比访问对齐数据慢十倍。
对齐问题的常见场景:字符串分配 📝
对齐问题最常出现在处理可变长度数据时,例如字符串分配。让我们通过一个例子来具体看看。
假设我们有一个字符串 "Hello",我们想将它存入内存。我们将内存画成一个字节序列,并标出字边界(假设是32位机器)。
如果我们希望数据对齐,我们会将字符串分配在字边界开始。因此,字符 H 会放入第一个字节,接着是 e, l, l, o。这里可能还有一个终止空字符 \0。
现在问题来了:下一个数据项应该放在哪里?
如果我们非常注重节省内存,可能会紧挨着字符串结束的位置开始存放下一个数据项。但请注意,那个位置很可能不在字边界上,这会导致我们之前提到的正确性或性能问题。
简单的解决方案是:直接跳到下一个字边界,再从那里开始分配下一个数据项。那么这两个字节(或更多)会怎样?它们就变成了未被使用的“填充”字节。程序永远不会引用它们,它们的值无关紧要。
总结一下,当机器有对齐限制时,处理流程如下:
- 数据必须从要求的边界(通常是字边界)开始分配。
- 如果分配的数据长度不是字的整数倍,没有直接结束在下一个边界上。
- 那么我们就跳过中间的任何字节(填充),让下一个数据从下一个边界开始。
总结 📚

本节课中,我们一起学习了内存对齐的核心概念。我们了解到,现代计算机有字长的概念,并且数据从字边界开始存放称为“对齐”。对齐至关重要,因为它关系到程序的正确性和运行效率。未对齐的数据可能导致程序错误或严重的性能下降。在处理像字符串这样的可变长度数据时,我们经常需要通过添加“填充”字节来确保每个数据项都正确对齐,从而为后续操作奠定良好的基础。

课程 P61:代码生成与堆栈机 🧱

在本节课中,我们将要学习代码生成的基础知识,特别是最简单的代码生成模型——堆栈机。我们将了解它的工作原理、核心属性以及如何用它来评估表达式。

什么是堆栈机?📚

上一节我们介绍了代码生成的主题,本节中我们来看看堆栈机的基本概念。
堆栈机的主要存储结构是一个堆栈。它的工作方式是执行指令,所有指令都遵循一种形式:它们接受一些参数,并产生一个结果。
具体来说,指令会从堆栈顶部弹出操作数(参数 a1 到 an),然后使用这些操作数计算函数 f,最后将结果 r 推回到堆栈顶部。
公式:指令执行: pop(a1, ..., an) -> compute f(a1, ..., an) -> push(r)

一个简单的例子:计算 7 + 5 ➕
为了理解堆栈机如何工作,让我们看一个计算 7 + 5 的例子。
首先,我们需要将数字 7 和 5 放入堆栈。假设堆栈初始时可能已有其他内容,但我们不关心它们。

以下是计算步骤:
- 将
7推入堆栈。 - 将
5推入堆栈。 - 执行加法指令。该指令会从堆栈弹出两个参数(
5和7),执行加法运算,然后将结果12推回堆栈顶部。
在这个过程中,堆栈机有一个关键属性:在评估表达式后,结果位于栈顶,而评估开始前的栈内容保持不变。

堆栈机编程与指令集 💻
现在思考如何为堆栈机编程。假设我们有一种简单的语言,只有两个指令:
push n: 将整数n推入堆栈。add: 将堆栈顶部的两个整数相加。
以下是一个计算 7 + 5 的程序:
push 7
push 5
add

程序执行过程如下:
- 执行
push 7,栈顶为7。 - 执行
push 5,栈顶变为5和7。 - 执行
add,弹出5和7,相加得到12,并将12推回栈顶。原始栈内容得以保留。

堆栈机 vs. 寄存器机 ⚖️
堆栈机代码有一个有趣的属性:指令中不明确指定操作数和结果的位置,因为它们总是与栈顶相关。

这与寄存器机(或传统的汇编代码)形成对比。在寄存器机中,一条加法指令通常会明确指定两个源寄存器和一个目标寄存器。
代码对比:
- 堆栈机指令:
add(隐含操作数来自栈顶,结果放回栈顶) - 寄存器机指令:
add r1, r2, r3(明确表示r1 = r2 + r3)
堆栈机指令更紧凑,程序体积更小,这是 Java 字节码早期采用栈评估模型的原因之一。然而,寄存器机代码通常执行更快,因为它能更精确地控制数据位置,减少不必要的栈操作(如推入和弹出)。
混合模型:单寄存器堆栈机(累加器)🔋
介于纯堆栈机和纯寄存器机之间,有一种有趣的中间状态,称为 n 寄存器堆栈机。其中一种特别重要的变体是 单寄存器堆栈机,也称为 累加器架构。

在这种模型中:
- 一个专用的寄存器(称为累加器)用于保存堆栈的“顶部”元素。
- 其他所有数据都存储在内存中的堆栈上。
累加器直观地用于累积操作的结果。这种设计减少了内存访问次数,从而提升了性能。
例如,在纯堆栈机中,add 指令需要三次内存操作(加载两个参数,存储一个结果)。而在单寄存器堆栈机中,add 指令可能只需要一次内存引用(从内存堆栈加载第二个参数),因为一个参数和结果都在累加器寄存器中。
评估任意表达式的通用策略 🧮
上一节我们介绍了累加器的概念,本节中我们来看看如何使用单寄存器堆栈机评估任意复杂的表达式。

假设有一个操作需要 n 个参数,且这些参数本身可能是需要评估的表达式。通用策略如下:
- 评估前 n-1 个参数:按顺序递归评估每个子表达式。每个子表达式评估后,结果在累加器中。然后,将这个结果从累加器推入内存堆栈保存起来,以便为评估下一个参数腾出累加器。
- 评估最后一个参数:递归评估最后一个子表达式。评估后,结果保留在累加器中,不需要推入堆栈。
- 执行操作:此时,累加器中是最后一个参数的值,内存堆栈顶部依次保存着前
n-1个参数的值。执行操作指令(如add),它会从内存堆栈弹出所需的值,与累加器中的值组合计算,并将最终结果存回累加器。

这个策略维护了一个重要的不变性:在评估任何表达式 e 之后,评估结果 v 位于累加器中,而内存堆栈的内容与开始评估 e 之前完全相同。
复杂表达式评估示例:3 + (7 + 5) 🧩
让我们通过一个更复杂的例子 3 + (7 + 5) 来演示上述策略。这里,外加的一个参数本身是一个复合表达式(内加)。
初始状态:累加器为空,堆栈有初始内容。
执行步骤:
- 评估外加的第一个参数
3:将3加载到累加器。 - 保存第一个参数:将累加器中的
3推入内存堆栈。 - 开始评估外加的第二个参数
(7 + 5),这本身是一个加法:- 评估内加的第一个参数
7:将7加载到累加器。 - 保存内加的第一个参数:将累加器中的
7推入内存堆栈。 - 评估内加的第二个参数
5:将5加载到累加器。 - 执行内加:从内存堆栈弹出
7,与累加器中的5相加,结果12存回累加器。此时内加评估完成,堆栈恢复到评估内加之前的状态(顶部是3)。
- 评估内加的第一个参数
- 执行外加:从内存堆栈弹出
3,与累加器中的12相加,结果15存回累加器。
最终状态:累加器中为整个表达式的结果 15,内存堆栈与评估表达式前完全相同。
你可以观察到,在评估子表达式 7 + 5 的过程中,同样遵守了“结果在累加器,堆栈不变”的规则。
总结 📝

本节课中我们一起学习了代码生成的基础模型——堆栈机。我们了解了纯堆栈机的工作原理,它使用栈作为唯一存储,指令隐含地对栈顶进行操作。接着,我们对比了堆栈机与寄存器机的优缺点。然后,我们介绍了一种性能更好的混合模型:单寄存器堆栈机(累加器架构),并详细讲解了使用它评估任意表达式所遵循的递归策略和重要的栈不变性。最后,通过一个复杂表达式的例子,我们完整演示了评估过程。掌握堆栈机模型是理解许多现代虚拟机(如 JVM)执行机制的重要基础。

课程 P62:代码生成简介 🚀

在本节课中,我们将学习如何为栈式虚拟机生成实际的机器代码。我们将以 MIPS 处理器为目标,使用其模拟器来运行生成的代码,并理解将抽象栈机指令映射到具体 CPU 指令的基本策略。
目标与策略 🎯

在之前的课程中,我们讨论了运行时环境、组织结构和栈式虚拟机。现在,我们终于可以开始讨论代码生成了。
我们将聚焦于为栈式虚拟机生成代码。这可能是最简单的策略,通常不会产生最高效的代码,但这种方法非常有趣,也并非完全不切实际。对于我们的目的来说,它足够复杂。我们将在真实的硬件模拟器上运行生成的代码,具体将使用 MIPS 处理器及其模拟器。
它几乎可以在任何硬件上运行,这对于课程项目来说非常方便。基本思想和策略是使用 MIPS 指令来模拟栈式虚拟机的操作。
MIPS 架构与寄存器设计 🏗️

上一节我们介绍了代码生成的目标,本节中我们来看看目标机器的架构。MIPS 架构设计于 20 世纪 80 年代,是典型的精简指令集计算机(RISC)。其理念是使用相对简单的指令集,大多数操作以寄存器作为操作数和结果,然后通过加载和存储指令在寄存器和内存之间移动数据。
MIPS 有 32 个通用寄存器(32位机器)。我们只会用到其中三个:
$sp(栈指针):指向内存中栈的下一个未使用位置。$a0(累加器):模拟栈机中逻辑栈的顶部。为避免混淆,我们称其为累加器,以区别于内存中的栈。$t1(临时寄存器):用于存储临时值,例如进行运算时的第二个操作数。
在 MIPS 中,栈向低地址增长,这是标准惯例。栈指针 $sp 指向栈上下一个未分配的字(word)的地址。因此,当前的栈顶实际上位于 $sp + 4 的地址处。
核心 MIPS 指令集 ⚙️

为了为 MIPS 生成代码,我们只需要很少的几条指令。以下是实现我们第一个示例所需的五个核心指令:
1. 加载字 (lw)
lw $rt, offset($rs)
将寄存器 $rs 中的值加上 offset(代码中嵌入的常数)作为内存地址,将该地址的值加载到寄存器 $rt 中。
2. 加法 (add)
add $rd, $rs, $rt
将寄存器 $rs 和 $rt 中的值相加,结果存入寄存器 $rd。
3. 存储字 (sw)
sw $rt, offset($rs)
将寄存器 $rt 中的值存储到内存中,内存地址为寄存器 $rs 中的值加上 offset。
4. 无符号立即数加法 (addiu)
addiu $rt, $rs, immediate
将寄存器 $rs 中的值与 immediate(代码中嵌入的常数)相加,结果存入寄存器 $rt。“无符号”意味着不检查溢出。
5. 加载立即数 (li)
li $rt, immediate
将常数 immediate 直接加载到寄存器 $rt 中。

第一个代码生成示例:7 + 5 ➕
现在我们可以尝试生成第一个程序了。不出所料,它就是我们在之前视频中看过的那个计算 7 + 5 的程序。以下是栈机指令序列:
push 7
push 5
add
我们的目标是使用上述 MIPS 指令来实现这个程序。以下是具体的映射和解释:
1. 将 7 加载到累加器 (push 7 的第一步)
li $a0, 7
使用 li 指令将立即数 7 加载到累加器寄存器 $a0 中。
2. 将累加器的值压入栈 (push 7 的第二步)
这需要两步完成:
sw $a0, 0($sp) # 将 $a0 的值存储到栈指针指向的内存地址
addiu $sp, $sp, -4 # 栈向低地址增长,因此栈指针减4,指向新的“下一个未使用位置”
第一条指令将累加器的值存到栈上(偏移量为0)。第二条指令将栈指针下移4个字节,以维持“$sp 指向下一个未使用位置”的不变性。
3. 将 5 加载到累加器 (push 5 的第一步)
li $a0, 5
与第一步类似,将立即数 5 加载到 $a0。
4. 执行加法 (add)
加法操作需要两个操作数:一个在累加器 ($a0) 中,另一个需要从栈顶加载。
lw $t1, 4($sp) # 从栈顶加载值。因为 $sp 指向未使用位置,所以栈顶在 $sp+4
add $a0, $a0, $t1 # 执行加法,$a0 = $a0 + $t1,结果存回累加器
首先,使用 lw 指令将栈顶的值(位于 $sp + 4)加载到临时寄存器 $t1。然后,使用 add 指令将 $a0 和 $t1 相加,结果存回 $a0。
5. 弹出栈 (add 操作的收尾)
栈顶的值已经被使用,需要弹出栈。这只需调整栈指针即可:
addiu $sp, $sp, 4 # 栈指针加4,相当于丢弃已使用的栈顶元素
总结 📝

本节课中,我们一起学习了代码生成的第一步:将简单的栈式虚拟机程序映射到真实的 MIPS 指令。我们了解了 MIPS 作为 RISC 架构的基本特点,定义了用于模拟的寄存器($sp, $a0, $t1),并学习了五个核心的 MIPS 指令(lw, sw, add, addiu, li)。最后,我们通过 7 + 5 这个具体示例,一步步演示了如何将栈机指令翻译成等价的 MIPS 指令序列,包括值的压栈、运算和弹栈过程。这为我们后续生成更复杂程序的代码奠定了基础。

课程 P63:代码生成基础 🧠

在本节课中,我们将学习如何为一种包含整数、运算和条件判断的简单编程语言生成MIPS汇编代码。我们将从核心概念和约定开始,逐步讲解如何为不同类型的表达式(如常量、加法、减法、条件判断)生成代码,并理解代码生成过程中的关键原则,如栈的使用和递归下降。

概述 📋

代码生成是将高级语言结构转换为目标机器指令的过程。本节我们将研究一种比栈机语言更高级的语言的代码生成。这种语言包含整数、基本运算和函数定义。我们的目标是为每个表达式生成MIPS代码,该代码计算表达式的值并将其存储在累加器 $a0 中,同时保持栈的状态不变。
语言语法与示例 📝

程序由一系列函数声明组成。每个函数定义包括函数名、参数列表(仅为标识符)和一个作为函数体的表达式。
语法可以概括为:
程序 -> 声明列表
声明 -> 函数定义
函数定义 -> 函数名(参数列表) { 表达式 }
表达式 -> 整数 | 标识符 | if (表达式 == 表达式) then 表达式 else 表达式 | 表达式 + 表达式 | 表达式 - 表达式 | 函数调用(参数列表)
列表中的第一个函数是程序的入口点(主函数)。这种语言足以编写如斐波那契数列这样的函数。
斐波那契函数示例:
fib(x) {
if (x == 1) then 0 else
if (x == 2) then 1 else
fib(x-1) + fib(x-2)
}

代码生成的核心约定 🎯
我们定义一个代码生成函数 cgen(e),它为表达式 e 生成MIPS代码。生成的代码需要满足两个核心不变量:
- 计算结果:代码执行完毕后,表达式
e的值将存储在累加器$a0中。 - 栈不变性:代码执行前后,栈指针
$sp和栈的内容必须保持一致。
在后续讲解中,我们使用颜色来区分不同阶段:
- 红色:表示在编译时由编译器执行的操作(如调用
cgen函数)。 - 蓝色:表示生成的、将在运行时由目标程序执行的指令。
为常量生成代码 🔢
为整数常量 i 生成代码是最简单的情况。我们只需要一条指令将常数值加载到累加器中。
代码生成公式:
cgen(i) = li $a0, i
li $a0, i是一条MIPS指令,将立即数i加载到寄存器$a0。- 这条指令不修改栈指针或栈内容,完美满足我们的两个不变量。
在编译时,我们执行 cgen(i),它会生成这条蓝色的运行时指令。
为加法表达式生成代码 ➕

上一节我们介绍了最简单的常量代码生成。本节中我们来看看如何处理二元操作,以加法表达式 e1 + e2 为例。其核心挑战是:我们只有一个累加器 $a0,但需要计算两个子表达式的值。
思路:
- 先计算
e1,将其值临时保存。 - 再计算
e2,此时e2的结果在$a0中。 - 取出之前保存的
e1的值,与$a0相加,结果存回$a0。
我们应该把 e1 的值保存在哪里?答案是栈。
代码生成模板:
cgen(e1 + e2) =
cgen(e1) // 计算 e1,结果在 $a0
sw $a0, 0($sp) // 将 $a0 的值压入栈顶
addiu $sp, $sp, -4 // 调整栈指针
cgen(e2) // 计算 e2,结果在 $a0
lw $t1, 4($sp) // 从栈中取回 e1 的值到临时寄存器 $t1
add $a0, $t1, $a0 // 执行加法:$a0 = $t1 + $a0
addiu $sp, $sp, 4 // 弹出栈,恢复栈指针
关键点:
- 这是一个代码模板:固定指令(如
sw,add,lw)与可插入的子表达式代码(cgen(e1),cgen(e2))的结合。 - 递归下降:
cgen(e1 + e2)的实现递归调用了cgen(e1)和cgen(e2)。 - 栈的作用:栈为嵌套表达式的中间结果提供了安全的存储空间,避免了寄存器冲突。
为什么必须使用栈?🚫
一个自然的优化想法是:不用栈,而用另一个固定的临时寄存器(如 $t1)保存 e1 的结果。让我们看看为什么这行不通。
考虑表达式 (1 + 2) + 3。如果使用固定寄存器 $t1:
- 计算
1,存入$a0,然后move $t1, $a0。 - 开始计算
(2 + 3)。计算2,存入$a0,然后move $t1, $a0。问题出现:这里覆盖了$t1中原来保存的1! - 继续计算
+ 3,得到5在$a0。 - 执行外层加法
add $a0, $t1, $a0。此时$t1中是2,所以结果是2 + 5 = 7,而不是正确的6。
这揭示了在递归生成代码时,同类型的嵌套表达式会竞争相同的临时寄存器。栈提供了动态的、后进先出的存储,完美匹配了表达式的递归求值顺序。

为减法表达式生成代码 ➖
理解了加法后,减法表达式的代码生成就非常类似了。模式完全相同,只是最后的运算指令不同。
代码生成模板:
cgen(e1 - e2) =
cgen(e1) // 计算 e1
sw $a0, 0($sp) // 压栈保存 e1
addiu $sp, $sp, -4
cgen(e2) // 计算 e2
lw $t1, 4($sp) // 取回 e1
sub $a0, $t1, $a0 // 执行减法:$a0 = $t1 - $a0
addiu $sp, $sp, 4 // 弹栈

可以看到,除了将 add 指令替换为 sub 指令,其余部分与加法完全一致。这体现了代码模板的复用性。
为条件表达式生成代码 ⚖️

现在我们来处理更复杂的控制流结构:if (e1 == e2) then e3 else e4。这需要引入MIPS的跳转指令。
需要用到的MIPS指令:
beq $rs, $rt, label:如果寄存器$rs和$rt的值相等,则跳转到label。j label:无条件跳转到label。

生成代码的思路:
- 计算条件
e1 == e2。这需要先后计算e1和e2。 - 比较两者的值。
- 如果相等,跳转到计算
e3的代码块(真分支)。 - 如果不相等,则顺序执行计算
e4的代码块(假分支)。 - 两个分支最终需要汇合到同一个结束点。
代码生成模板:
cgen(if (e1 == e2) then e3 else e4) =
cgen(e1) // 计算 e1
sw $a0, 0($sp) // 保存 e1 到栈
addiu $sp, $sp, -4
cgen(e2) // 计算 e2,结果在 $a0
lw $t1, 4($sp) // 取回 e1 到 $t1
addiu $sp, $sp, 4 // 弹栈
beq $a0, $t1, TRUE_LABEL // 比较:若 e2($a0) == e1($t1),跳转到真分支
cgen(e4) // 假分支:计算 e4
j END_IF_LABEL // 跳过真分支
TRUE_LABEL:
cgen(e3) // 真分支:计算 e3
END_IF_LABEL:
执行逻辑:
- 如果条件为真,执行
beq跳转到TRUE_LABEL,计算e3,然后到达END_IF_LABEL。此时$a0中是e3的值。 - 如果条件为假,则顺序执行
cgen(e4),然后通过j指令跳过真分支,到达END_IF_LABEL。此时$a0中是e4的值。 - 无论走哪个分支,最终
$a0中都存储了整个条件表达式的正确结果,并且栈状态得以保持。
总结 🎓
本节课中我们一起学习了为一种简单语言生成MIPS代码的基础知识。我们掌握了以下核心内容:
- 代码生成函数
cgen(e)的目标是生成计算表达式值并保持栈不变的代码。 - 代码生成是递归的,遵循抽象语法树的结构进行下降。
- 栈的核心作用是安全地存储嵌套表达式求值过程中的中间结果,避免寄存器冲突。
- 代码生成遵循固定模板:对于每种表达式类型(常量、加法、减法、条件判断),我们都有一套固定的指令框架,其中“插入”了子表达式的生成代码。
- 我们详细分析了加法、减法和条件表达式的代码生成过程,理解了其背后的模式和原理。

这些概念是理解更复杂代码生成(如函数调用、变量访问)的基石。下一节,我们可以在此基础上探讨如何为函数调用和标识符生成代码。

编译器构建课程 P64:简单语言的代码生成(续)🚀

在本节课中,我们将继续学习简单语言的代码生成。我们将重点完成函数调用、函数定义以及变量引用的代码生成。理解这些概念对于构建一个完整的编译器至关重要。

概述 📋

本视频是前一个视频的续集,我们将完成简单语言的代码生成,处理函数调用、函数定义和变量引用。
为了回顾,我们的简单语言包含多种表达式。在上次课程中,我们处理了除变量引用和函数调用之外的所有表达式。此外,我们还有一个函数定义结构。
核心结构与设计挑战 ⚙️

我们将要查看的三个核心结构是:
- 变量引用
- 函数调用
- 函数定义
设计函数调用和函数定义代码生成的主要挑战在于,它们都紧密依赖于激活记录的布局。因此,函数调用的代码生成、函数定义的代码生成以及激活记录的布局需要作为一个整体来协同设计。
对于这种特定语言,一个非常简单的激活记录就足够了。因为我们使用栈机模型,函数调用的结果将始终存放在累加器(A0)中,这意味着无需在激活记录中存储函数调用的结果。激活记录将主要保存实际参数。
当我们计算一个带有参数 x1 到 xn 的函数调用时,我们会将这些参数推入栈中。在这个语言中,除了函数调用的参数外,没有其他局部或全局变量。因此,这些参数是激活记录中唯一需要存储的变量。

激活记录布局设计 🏗️
栈机模型保证,在函数调用期间栈指针保持不变。这意味着当从函数调用退出时,栈指针与进入时完全相同。因此,我们不需要在激活记录中存储控制链接(用于寻找前一个激活记录)。

然而,我们需要存储返回地址。此外,一个指向当前激活记录的指针(帧指针)将非常有用。这个指针通常存放在寄存器 fp 中,它指向当前栈帧的顶部。
以下是该语言激活记录的总结:
- 调用者的帧指针(旧的
fp) - 函数的实际参数(按逆序推入栈中)
- 返回地址
考虑一个调用函数 f 的例子,它有两个参数 x 和 y。在执行函数体之前,激活记录将如下布局:
| ... | 高地址
| 参数 y |
| 参数 x |
| 旧帧指针 | <-- 调用者的 fp 指向这里
| 返回地址 | <-- 当前 fp 指向这里
| ... | 低地址
参数按逆序(最后一个参数先入栈)推入,这使得后续通过索引访问参数变得更容易。

函数调用的代码生成(调用者侧)📞
调用序列是调用者和被调用者共同设置函数调用的指令序列。我们将使用 jal(跳转并链接)指令。jal L 会跳转到标签 L,并将下一条指令的地址保存在 ra(返回地址)寄存器中。
假设我们有一个函数调用 f(e1, e2, ..., en)。以下是调用者侧代码生成的步骤:
- 保存当前帧指针(调用者的
fp)到栈上,并移动栈指针。 - 为参数生成代码(从最后一个参数
en开始到第一个参数e1结束):- 计算表达式
ei,结果在累加器A0中。 - 将
A0的值存储到栈上,并移动栈指针。
- 计算表达式
- 执行
jal f_entry指令,跳转到函数f的入口点。
此时,栈上已经构建了部分激活记录(旧帧指针和所有参数),ra 寄存器中保存了返回地址。
函数定义的代码生成(被调用者侧)🏠
被调用者侧是函数定义本身的代码。我们需要 jr(跳转寄存器)指令来返回到调用者。
函数 f 的代码生成如下:

f_entry: # 函数入口标签
move $fp, $sp # 设置当前帧指针,指向栈顶(返回地址将存放的位置)
sw $ra, 0($sp) # 将返回地址保存到栈帧中(0($fp)的位置)
addi $sp, $sp, -4 # 移动栈指针
# ... 为函数体生成代码 ...
# 函数返回序列
lw $ra, 4($sp) # 从栈帧中恢复返回地址
addi $sp, $sp, z # 弹出整个激活记录,z = 4*n + 8
lw $fp, 0($sp) # 恢复旧的帧指针
jr $ra # 跳转回调用者
注意:z 是激活记录的总大小。对于有 n 个参数的函数,z = 4*n + 8(n个参数占 4*n 字节,加上旧帧指针和返回地址各占4字节)。
调用者负责在函数调用后,通过调整栈指针来清理(弹出)传递的参数和保存的旧帧指针。

变量引用的代码生成 🔍
在这种简单语言中,变量即函数的参数。它们存储在激活记录中。由于函数体执行时,栈指针会因中间计算结果而变动,我们不能依靠栈指针来定位参数。

解决方案是使用帧指针(fp)。帧指针在函数执行期间固定指向当前激活记录的顶部(返回地址处),因此我们可以相对于它来定位变量。

对于函数的第 i 个参数 xi(按参数列表顺序,从1开始计数),它在激活记录中的位置是:
偏移量 = 4 * i
因为参数是按逆序压栈的,最后一个参数在 fp + 4,倒数第二个在 fp + 8,以此类推。
因此,为变量引用 xi 生成代码非常简单:
lw $a0, offset($fp) // offset = 4 * i

示例:对于函数 f(x, y):
- 变量
x(第一个参数)的偏移量是 4。代码为:lw $a0, 4($fp) - 变量
y(第二个参数)的偏移量是 8。代码为:lw $a0, 8($fp)

总结与要点 📝
本节课我们一起学习了简单语言中函数调用、函数定义和变量引用的完整代码生成过程。

核心要点总结:
- 协同设计:激活记录的布局必须与代码生成协同设计,两者不能孤立进行。
- 递归遍历:代码生成可以通过抽象语法树(AST)的递归遍历来实现,这与类型检查类似,是一种清晰的方法。
- 栈机模型:对于学习和实现编译器项目,栈机模型是一个优秀的起点。它概念简单,为问题分解提供了清晰的框架。
- 生产编译器差异:需要了解生产级编译器与教学用栈机编译器的主要区别。生产编译器更注重寄存器分配,尽可能将值(包括临时变量和局部变量)保留在寄存器中,而不是频繁地访问栈内存。它们会为所有局部数据在激活记录中分配固定的位置。

通过掌握这些知识,你已经为理解更复杂的代码生成技术和优化策略奠定了坚实的基础。

课程 P65:代码生成基础 🧩

在本节课中,我们将学习如何为一个简单的递归函数生成汇编代码。我们将以计算从 0 到 x 的整数和为例,逐步剖析代码生成的完整过程,并理解如何将高级语言结构转换为底层的机器指令。
概述
我们将要分析的程序接受一个正整数 x,并计算从 0 到 x 的所有数字之和。其逻辑可以描述为:如果 x 为 0,则结果为 0;否则,结果为 x 加上从 0 到 x-1 的所有数字之和。虽然程序逻辑简单,但它完整地展示了代码生成中的核心概念,如函数调用、条件判断和算术运算。
函数入口与调用者序言
上一节我们介绍了程序的基本逻辑,本节中我们来看看如何为函数设置入口点并生成调用者序言代码。
首先,为函数的入口点定义一个标签,例如 sum_to_entry。调用者序言代码负责建立新的栈帧。
以下是生成调用者序言代码的步骤:
- 设置帧指针。其值等于当前栈指针的值,标记此激活记录的起始位置。
- 存储返回地址。将返回地址保存在当前栈指针指向的位置。
- 移动栈指针。每当在栈上存储数据后,都需要将栈指针移动到下一个未使用的位置。
sum_to_entry:
move $fp, $sp # 设置帧指针
sw $ra, 0($sp) # 存储返回地址
addi $sp, $sp, 4 # 移动栈指针
生成条件判断(if-then-else)的代码
上一节我们设置了函数框架,本节中我们来看看如何为 if-then-else 结构生成代码。这需要为条件谓词和两个分支生成相应的指令。
首先,需要为条件谓词的第一个子表达式生成代码。在本例中,谓词是判断 x == 0,因此第一个子表达式是变量 x。
为变量生成代码意味着在栈帧的特定偏移量处查找其值。由于 x 是此过程的唯一参数,它存储在帧指针偏移 4 的位置。
计算完第一个子表达式后,需要将其值临时保存在栈上,因为接下来要为第二个子表达式(立即数 0)生成代码。这是一个二元运算符(相等比较)的标准处理流程。
以下是生成条件判断代码的步骤:
- 加载变量
x的值到累加器。 - 将该值保存到栈上,并移动栈指针。
- 加载立即数
0到累加器。 - 将之前保存的
x的值从栈中加载到临时寄存器。 - 比较两个值是否相等。
- 根据比较结果,使用分支指令跳转到真分支或假分支的标签。
# 谓词:x == 0
lw $a0, 4($fp) # 加载变量 x
sw $a0, 0($sp) # 保存 x 到栈
addi $sp, $sp, 4 # 移动栈指针
li $a0, 0 # 加载立即数 0
lw $t1, -4($sp) # 重新加载 x 到临时寄存器 t1
addi $sp, $sp, -4 # 弹出栈(回收临时空间)
beq $a0, $t1, true1 # 如果 x == 0,跳转到真分支
j false1 # 否则,跳转到假分支
生成假分支(Else Branch)的代码
上一节我们完成了条件跳转,本节中我们来看看假分支的代码生成。假分支对应 x 不为 0 的情况,需要计算 x + sum_to(x-1)。
这是一个加法表达式,因此需要先生成第一个操作数 x 的代码,然后生成第二个操作数(函数调用 sum_to(x-1))的代码。
生成函数调用代码的第一步是设置新的激活记录。这包括保存旧的帧指针、计算参数(x-1)并将其压栈,最后执行跳转链接指令。

以下是生成假分支代码的步骤:
- 加载
x的值并保存到栈上(作为加法的第一个操作数)。 - 为函数调用
sum_to(x-1)生成代码:- 保存旧的帧指针。
- 计算参数
x-1(这本身是一个减法表达式)。 - 将计算结果(参数)压入新激活记录。
- 执行
jal sum_to_entry进行函数调用。
- 函数返回后,从栈中重载加法的第一个操作数。
- 执行加法操作,得到最终结果。
- 清理栈上的临时值。
false1:
# 计算 x + sum_to(x-1)
lw $a0, 4($fp) # 加载 x (加法第一操作数)
sw $a0, 0($sp) # 保存 x 到栈
addi $sp, $sp, 4
# 开始设置函数调用 sum_to(x-1)
sw $fp, 0($sp) # 保存旧帧指针
addi $sp, $sp, 4
# 计算参数 x-1
lw $a0, 4($fp) # 加载 x (减法第一操作数)
sw $a0, 0($sp) # 保存 x 到栈
addi $sp, $sp, 4
li $a0, 1 # 加载立即数 1
lw $t1, -4($sp) # 重载 x 到 t1
addi $sp, $sp, -4 # 弹出栈
sub $a0, $t1, $a0 # 计算 x - 1
# 参数计算完毕
sw $a0, 0($sp) # 将参数 (x-1) 存入新帧
addi $sp, $sp, 4
jal sum_to_entry # 调用函数
# 函数调用返回,继续执行加法
lw $t1, -8($sp) # 重载之前保存的 x
add $a0, $t1, $a0 # 计算 x + sum_to(x-1)
addi $sp, $sp, -8 # 弹出栈上临时值
j if_done1 # 跳转到 if 结构结束处
生成真分支(Then Branch)与函数返回
上一节我们生成了复杂的假分支,本节中我们来看看相对简单的真分支以及函数如何返回。真分支对应 x == 0 的情况,只需返回 0。
真分支的代码非常简单,仅需一条加载立即数指令。之后,程序流会汇聚到 if 结构的结束标签。
函数定义的结尾部分需要生成返回序列。这包括从栈中恢复返回地址和旧的帧指针,调整栈指针以销毁当前激活记录,最后跳转回调用者。
以下是生成真分支及返回序列的步骤:
- 真分支:加载立即数
0到累加器。 if结构结束标签。- 返回序列:
- 从栈中加载返回地址。
- 弹出当前激活记录(返回地址、旧帧指针、参数,共
12字节)。 - 恢复旧的帧指针。
- 使用
jr指令跳转回返回地址。
true1:
li $a0, 0 # 真分支:返回 0
if_done1:
# 函数返回序列
lw $ra, -4($fp) # 加载返回地址
addi $sp, $sp, 12 # 弹出整个激活记录 (3个字)
lw $fp, -8($sp) # 恢复旧帧指针
jr $ra # 跳转回调用者
总结与关键点
本节课中,我们一起学习了为一个递归求和函数生成完整汇编代码的过程。我们从函数入口和调用者序言开始,逐步生成了条件判断、真假分支以及函数返回的代码。
总结关键点如下:
- 代码由模板拼接:生成的代码是多个标准模板(如函数调用、算术运算、条件分支)的组合结果。
- 线性指令序列:尽管生成过程是结构化的,但最终产物是一个线性的机器指令序列。
- 简单策略的效率问题:我们采用的这种简单直接的代码生成策略会产生许多低效操作,例如重复加载和存储同一变量。这为后续学习更智能的代码优化技术(如寄存器分配)提供了动机。
通过这个例子,你应该对如何将高级语言结构系统地翻译为底层代码有了基本的理解。理解这些模板的组合方式是掌握编译器代码生成阶段的基础。

课程 P66:临时变量管理优化 🧠

在本节课中,我们将学习编译器如何更有效地管理临时变量。我们将探讨一种改进方法,即预先在函数的激活记录中为临时变量分配固定位置,从而避免在运行时频繁操作堆栈指针,以生成更高效的代码。


背景与问题引入
上一节我们讨论了简单编程语言的代码生成。在之前的视频末尾提到,实际编译器的处理方式略有不同,特别是会更有效地将值保存在寄存器中。

此外,编译器还需要管理在激活记录中必须存储的临时变量。本节将重点讨论第二个问题,即如何更好地管理这些临时值。
基本思想是,将临时值保存在激活记录中。虽然这不如将临时变量直接保存在寄存器中高效(那是未来课程的主题),但我们可以改进在激活记录中管理临时变量的方式。
核心思想:预分配固定位置
我们要做的改进是,让代码生成器为每个临时变量在激活记录中分配一个固定的位置。我们将预先分配内存(即激活记录中的位置),然后就可以保存和恢复临时变量,而无需在运行时动态操作堆栈指针。
让我们看一个简单编程语言的典型程序,这是斐波那契函数:
function fib(x):
if x < 2 then
return x
else
return fib(x-1) + fib(x-2)
我们需要思考,评估这个函数体需要多少个临时变量。如果我们能提前知道所需临时变量的数量,就可以在激活记录中预先分配空间,而不是在运行时通过堆栈推入和弹出操作来分配。
以下是分析过程:

- 评估谓词
x < 2需要一个临时变量来保存比较结果。 - 评估
fib(x-1)时,需要先计算x-1,这需要一个临时变量。 - 调用
fib(x-1)的结果需要被保存,以便后续进行加法运算,这又需要一个临时变量。 - 在评估
fib(x-2)的参数x-2时,我们仍然需要保留fib(x-1)的结果,因此这两个临时变量需要同时存在。
经过分析,这个特定函数可以用两个临时变量来评估。这就是计算该函数体所需的所有临时空间。
计算所需临时变量数

一般来说,我们可以定义一个函数 nt(e),它计算评估表达式 e 所需的最小临时变量数。
以下是描述所需临时变量数量的方程系统:
- 整数或变量引用:不占用临时空间。
nt(int) = 0nt(id) = 0
- 二元操作(如
e1 + e2):需要评估e1和e2。评估e1后,其使用的临时空间可以被回收,用于评估e2,但需要额外一个位置来保存e1的结果。nt(e1 op e2) = max(nt(e1), 1 + nt(e2))
- 条件表达式(
if e1 then e2 else e3):需要分别考虑评估条件e1、then分支e2和else分支e3所需的空间,并取最大值。nt(if e1 then e2 else e3) = max(nt(e1), 1 + nt(e2), 1 + nt(e3))
- 函数调用(
f(e1, ..., en)):参数的计算结果保存在新的激活记录中,因此不计入当前激活记录的临时变量需求。只需考虑评估各个参数时所需临时空间的最大值。nt(f(e1, ..., en)) = max(nt(e1), ..., nt(en))
让我们使用这个方程系统,系统地计算斐波那契函数体所需的临时变量数 nt(fib-body)。
以下是计算步骤:
- 计算外层
if的谓词部分x < 2:nt(x) = 0,nt(2) = 0,所以nt(x<2) = max(0, 1+0) = 1。 then分支x:nt(x) = 0。else分支fib(x-1) + fib(x-2):- 计算
fib(x-1):- 参数
x-1:nt(x)=0,nt(1)=0,所以nt(x-1) = max(0, 1+0) = 1。 - 因此
nt(fib(x-1)) = max(nt(x-1)) = 1。
- 参数
- 计算
fib(x-2):同理,nt(fib(x-2)) = 1。 - 计算加法
... + ...:nt(e1 + e2) = max(nt(e1), 1 + nt(e2)) = max(1, 1+1) = 2。
- 计算
- 因此,整个
else分支需要 2 个临时变量。 - 最后,计算整个
if表达式:nt(if...) = max(nt(谓词), 1+nt(then分支), 1+nt(else分支)) = max(1, 1+0, 1+2) = 3。
经过计算,该函数体评估所需的最小临时变量数为 3。(注:此结果与之前直观分析的2个略有出入,展示了系统化计算的精确性)。
激活记录的新布局

一旦我们计算出函数体所需临时变量数 nt,就可以相应地扩展激活记录。
现在,激活记录需要 2 + n + nt 个存储单元(每个单元通常是一个字长,如4字节):
2:用于返回地址和帧指针。n:用于函数的n个参数。nt:用于预分配的临时变量。
新的激活记录布局如下(地址从高到低增长):
| ... | 高地址
| 临时变量 #nt |
| ... |
| 临时变量 #1 |
| 返回地址 |
| 参数 n |
| ... |
| 参数 1 |
| 前一帧的帧指针 | <- 当前帧指针 (FP) 指向这里
| ... | 低地址
临时变量区域位于返回地址之后。这样,通过帧指针(FP)加上固定的偏移量,就可以直接访问任何一个临时变量或参数。

代码生成策略

知道了函数所需临时变量总数以及它们在激活记录中的固定位置后,我们还需要知道在生成代码时,程序每一点正在使用哪些临时变量。

为此,我们为代码生成函数添加一个新的参数:next_temp。它表示激活记录中下一个可用的临时变量位置的偏移量。
临时变量区域将被用作一个小型固定栈。当一个表达式需要使用临时空间时,它就占用 next_temp 指示的位置,然后将 next_temp 增加(例如一个字的长度)。当该表达式计算完毕,其占用的临时空间被释放,next_temp 相应减少。这本质上与我们之前使用的栈纪律相同,但所有关于栈指针的计算现在都由编译器在编译时完成,生成的代码中只剩下简单的、固定偏移量的加载(LOAD)和存储(STORE)指令。
让我们对比一下优化前后,为表达式 e1 + e2 生成代码的差异:
旧方案(动态栈操作):
code(e1) # 计算 e1,结果在累加器 ACC 中
PUSH ACC # 将结果压入运行时栈
code(e2) # 计算 e2,结果在 ACC 中
POP temp # 弹出 e1 的结果到临时寄存器 TEMP
ADD ACC, TEMP, ACC # ACC = TEMP + ACC

新方案(固定偏移存取):
code(e1, next_temp) # 计算 e1,传入当前临时变量偏移量
STORE ACC, FP(next_temp)# 将结果存入固定位置 FP+next_temp
code(e2, next_temp+4) # 计算 e2,传入下一个临时位置偏移量
LOAD TEMP, FP(next_temp)# 从固定位置加载 e1 的结果
ADD ACC, TEMP, ACC # ACC = TEMP + ACC
可以看到,新方案用一条 STORE 指令替换了旧方案的 PUSH 和后续调整栈指针的隐式操作;用一条 LOAD 指令替换了 POP 指令。生成的代码序列更短,且完全避免了运行时对栈指针的修改,因此更加高效。

总结
本节课中,我们一起学习了编译器优化临时变量管理的一种重要方法。
- 核心目标:通过预先分析确定函数所需临时变量的最大数量(
nt),并在激活记录中为其分配固定位置,避免运行时堆栈指针操作。 - 关键计算:我们学习了递归函数
nt(e),用于系统化地计算任何表达式e所需的最小临时变量数。 - 记录布局:激活记录布局被修改,在参数区和控制信息(返回地址、帧指针)之后,包含一个固定大小的临时变量区。
- 代码生成:代码生成过程引入
next_temp参数来管理临时变量的分配与释放,生成的代码使用基于帧指针的固定偏移量来存取临时变量,从而生成更短、更快的指令序列。

这种方法将管理临时变量的开销从运行时转移到了编译时,是编译器生成高效代码的常见优化手段之一。


课程 P67:对象布局与动态分派 🧱

在本节课中,我们将学习面向对象编程中对象的代码生成策略,特别是对象在内存中的布局和动态方法分派的实现机制。这是对之前简单语言代码生成知识的扩展。

对象布局的基本原则 📐

上一节我们介绍了代码生成的基础,本节中我们来看看如何为对象生成代码。关于对象,面向对象编程的核心特性之一是“替代能力”:如果类B是类A的子类,那么类B的对象可以在期望类A对象的地方使用。这意味着为类A生成的代码,必须能不加修改地应用于类B的对象。
为了实现这一点,我们需要解决两个核心问题:
- 对象在内存中如何表示(对象布局)。
- 动态分派(即根据对象实际类型调用正确的方法)如何实现。
我们将通过一个具体的例子来贯穿讲解。假设我们有三个类:
- 类A:基类,定义了属性
a、d和方法f。 - 类B:继承自A,添加了属性
b和方法g,并重写了方法f。 - 类C:继承自A,添加了属性
c和方法h。
一个关键点是,所有类中的方法(包括继承的)都可能引用属性 a。因此,无论对象是A、B还是C的实例,属性 a 在每个对象中都必须位于相同的内存位置,这样编译好的方法代码才能正确找到它。

对象的内存表示
对象在内存中被安排在一块连续的区域中。对象的每个属性(字段)在这块内存中都有一个固定的偏移量。

核心概念:当方法被调用时,对象本身作为隐含的 self 参数传入。self 是一个指向整个对象内存块的指针。通过 self 指针加上属性的固定偏移量,就能访问到具体的属性。

以下是COOL语言采用的一种典型对象布局:
对象内存布局示例:
+----------------+ 偏移量 0
| 类标记 | (Class Tag)
+----------------+ 偏移量 4
| 对象大小 | (Object Size)
+----------------+ 偏移量 8
| 分派表指针 | (Dispatch Pointer)
+----------------+ 偏移量 12
| 属性 a | (Attribute a)
+----------------+ 偏移量 16
| 属性 d | (Attribute d)
+----------------+
| ... | (其他属性)
+----------------+
- 类标记:一个唯一标识对象类的整数(例如,A=1, B=2, C=3)。
- 对象大小:对象占用的总字数(word)。
- 分派表指针:指向该类方法表的指针。
- 属性:按照编译器确定的顺序(通常是类定义中的文本顺序)依次排列。

继承与布局扩展

理解了单个类的布局后,我们来看看继承如何工作。核心思想是:子类的对象布局通过扩展其父类的布局来实现。父类的属性位置保持不变,子类新增的属性简单地附加在末尾。
这保证了“替代能力”:任何为父类编译的、访问父类属性的代码,在子类对象上运行时,依然能在相同的位置找到那些属性。
让我们看看示例中三个类的布局:
- 类A的布局:
[类标记,大小,分派指针, a, d]。大小字段为5。 - 类B的布局:
[类标记,大小,分派指针, a, d, b]。它继承了A的布局,并在末尾添加了新属性b。大小字段为6。 - 类C的布局:
[类标记,大小,分派指针, a, d, c]。它同样继承了A的布局,并在末尾添加了新属性c。大小字段为6。
可以看到,属性 a 和 d 在A、B、C的所有对象中,偏移量都是相同的(12和16)。因此,类A的方法 f 可以无缝地作用于B或C的对象。
更一般地,对于一个继承链 A1 <- A2 <- A3 ... <- An,类 An 的对象布局将是:头部信息,后跟 A1 的所有属性,然后是 A2 的所有新增属性,以此类推,最后是 An 的新增属性。这个布局的每一个前缀都构成了其某个超类的有效对象布局。
动态分派的实现 🎯

现在我们已经处理了对象属性的布局,可以转向讨论方法的布局和动态分派的实现。考虑动态调用 e.f(),我们需要根据 e 运行时的实际类型(可能是A、B或C)来决定调用哪个类中定义的 f 方法。
方法表(Dispatch Table)

每个类都有一个固定的方法集合(包括继承的方法)。编译器可以在编译时为每个类确定其方法表。方法表是一个代码指针数组,每个条目指向一个方法的入口地址。
核心原则:一旦某个方法在继承体系中的某个偏移量被确定,这个偏移量将在所有相关类的方法表中保持不变。如果子类重写了该方法,只是该偏移量处的内容(指针)被替换为子类方法的地址。
以下是示例中三个类的方法表:

- 类A的方法表:
[ *A.f ] - 类B的方法表:
[ *B.f, *B.g ]// f被重写,g是新增 - 类C的方法表:
[ *A.f, *C.h ]// f继承自A,h是新增
注意,即使类B重写了 f,f 在方法表中的位置(偏移量0)与在类A中是一致的。不同的是,该位置存储的指针指向了 B.f 的代码。

分派指针与共享
为什么对象头里需要一个指向方法表的指针,而不是把方法表直接嵌入每个对象?这是因为:
- 属性是每个对象独有的,所以每个对象都需要自己的属性副本。
- 方法是同类所有对象共享的。所有类A的实例都执行相同的
A.f代码。
因此,让同类所有对象共享一个方法表可以节省大量内存。对象头中的分派指针就是用来找到这个共享方法表的。
动态分派步骤
现在,我们可以描述 e.f() 这个动态调用在运行时是如何执行的:

- 求值:计算表达式
e,得到一个对象x(即self)。 - 取表:从对象
x的头部(固定偏移量,如8)取出分派表指针。 - 查偏移:在分派表指向的方法表中,根据方法
f的预编译偏移量(如0)找到对应的条目。 - 跳转:跳转到该条目存储的代码地址,开始执行方法。执行时,
self被绑定为对象x。
用伪代码描述这个过程:
// 假设 e 的求值结果在寄存器 $a0 中
lw $t1, 8($a0) // $t1 = 对象的分派表指针
lw $t1, 0($t1) // $t1 = 分派表中f方法对应的代码地址(偏移量0)
jalr $t1 // 跳转到该地址执行,$a0 作为 self 参数传递
总结 📝

本节课中我们一起学习了面向对象语言中对象代码生成的两个核心部分:
- 对象布局:对象在内存中连续存储,包含头部信息(类标记、大小、分派指针)和属性列表。子类通过扩展父类布局来实现继承,确保父类属性偏移量不变,从而支持替代能力。
- 动态分派:通过方法表实现。每个类有一个方法表,存储该类所有方法的代码指针。同类对象共享此表。对象头中的分派指针指向它。方法调用时,通过对象得分派指针找到方法表,再根据编译时确定的方法偏移量找到正确的方法入口并跳转。

这种布局和分派机制是许多面向对象语言(如Java、C++)运行时系统的基础,它高效地支持了封装、继承和多态这三大特性。

课程 P68:编程语言语义概述 🧠
在本节课中,我们将要学习编程语言语义的基本概念,特别是关于COOL语言的语义。我们将探讨什么是语义、为什么需要它,以及如何用不同的方式来描述程序的行为。


什么是编程语言语义?🤔
我们需要解决的问题是:当我们运行一个COOL程序时,我们期望的行为是什么?因此,对于COOL中的每一种表达式,我们必须说明它在被评估(执行)时会发生什么。我们可以将此视为表达式的“含义”,并通过某种规则来指定特定表达式会进行何种计算。
回顾一下我们之前是如何处理类似问题的,对于定义COOL的其他部分很有帮助。
语言定义的不同层面 📝
在之前的课程中,我们已经学习了如何定义语言的各个层面。
- 词法分析:我们使用正则表达式来定义一组标记(Token)。
- 语法:我们使用上下文无关文法来指定单词如何组合成COOL中有效的句子。
- 语义分析:我们给出了正式的类型规则。

现在,我们到了必须谈论程序实际运行的时候。因此,我们必须给出一些评估规则。这些规则将指导我们如何进行代码生成和优化,以决定程序应该做什么,以及我们可以对程序进行哪些转换以使其运行更快或使用更少空间。
间接指定评估规则的局限性 ⚠️
到目前为止,我们一直在间接地指定评估规则。我们通过给出完整的编译策略(一直到栈机代码)来做到这一点,然后我们讨论了栈机的评估规则(实际上是将栈机代码翻译成汇编代码)。这当然是一个完整的描述:你可以取生成的汇编代码并在机器上运行它,看看程序做了什么。这将是一个关于程序行为的合法描述。

那么问题来了:为什么这还不够好?为什么仅仅有一个语言的代码生成器,还不是关于如何执行代码的足够好的描述?
答案可能有点难以理解。如果没有写过几个编译器,人们从经验中得知,汇编语言描述的语言实现包含很多无关细节。当你得到如此完整的可执行描述时,有很多关于程序如何执行的事情你不得不说,但这些并非必要。
以下是几个例子:
- 我们使用栈机的事实,并非特定编程语言实现的固有属性。我们本可以使用其他代码生成策略。
- 栈的增长方向(向高地址还是低地址增长),你可以用两种方式实现。
- 整数的具体表示。
- 执行或实现特定语言结构的特定指令。
所有这些都是实现语言的一种方式,但我们不想它们被作为语言实现的唯一方式。所以我们真正想要的是一个完整的描述,但不要过于限制,一个允许不同实现的方式。
当人们没有尝试找到相对高级的方式来描述语言行为时,他们不可避免地陷入了一种情况:人们不得不去运行参考实现以决定它做什么。这并不令人满意,因为参考实现并不完全正确,会有漏洞,会有特定实现方式的痕迹(你并不想成为语言的一部分),但因为没有更好的定义,最终成为了语言形成中的意外。
指定语义的不同方法 🛠️
有很多方法可以实际指定适合任务的语义。这些方法同样强大,但有些更适合某些任务。
我们将使用的方法称为操作语义。操作语义通过抽象机器上的执行规则来描述程序评估。我们给出一些规则,假设你知道特定表达式的执行方式。可以将其视为非常高级的代码生成。这对于指定实现非常有用,也是我们将用来描述COOL语义的方法。

我想提及两种其他指定编程语言语义的方式,因为它们很重要,你可能在课程之外遇到它们。

谓词语义
在谓词语义中,程序的意义实际上被给定为一个数学函数。因此,程序文本被映射到一个从输入到输出的函数,这个函数是数学意义上的实际函数。这是一种非常优雅的方法,但它在定义适当函数类时引入了复杂性。我们实际上不需要考虑这些复杂性,只是为了描述实现。

公式:[[Program]] : Input -> Output
公理语义
在公理语义中,程序行为用某种逻辑描述。你在这个语言中写的基本陈述是:如果执行从满足条件X的状态开始,那么它将结束于满足条件Y的状态。其中X和Y是某种逻辑中的公式。这是许多自动分析程序的系统的基础,试图证明程序的事实,要么证明它们是正确的。

公式:{X} Program {Y}
总结 📚

本节课中我们一起学习了编程语言语义的基本概念。我们了解到,语义定义了程序运行时的预期行为。我们回顾了通过低级实现(如汇编)来间接定义语义的局限性,因为它引入了过多实现细节。最后,我们介绍了三种主要的语义描述方法:我们将要使用的操作语义,以及作为重要补充的谓词语义和公理语义。理解这些概念是深入掌握编程语言设计和实现的关键一步。

课程 P69:形式操作语义入门 🧠
在本节课中,我们将学习形式操作语义的基本概念。我们将了解如何用逻辑推理规则来形式化地描述程序的执行过程,包括环境、存储和对象的表示方法。


定义形式操作语义的第一步
就像我们处理词法分析、解析和类型检查一样,定义形式操作语义的第一步是引入符号。

我们发现,操作语义使用的符号与类型检查中使用的符号相同或非常相似。我们将使用逻辑推理规则。
推理规则与评估上下文
以类型检查为例,我们展示的推理规则类型证明了一些东西。在某些上下文中,我们可以显示某些表达式具有特定类型。

对于评估,我们将做非常相似的事情。我们现在将在某种上下文中显示。这将不同于我们在类型中拥有的上下文。因此,这将是评估上下文而不是类型上下文。
上下文中实际包含的内容将不同。但目前真正重要的是存在某种上下文。在那个上下文中,我们将能够显示某些表达式评估为特定值。
一个简单的评估例子
让我们看看这个简单的表达式 E1 + E2。
假设我们有一堆规则,并且我们可以显示,在初始上下文中,E1 在同一上下文中评估为值 5。E2 也在同一上下文中评估为值 7。然后我们可以证明 E1 + E2 评估为值 12。
这条规则说的是,如果 E1 评估为 5,E2 评估为 7,那么如果你评估表达式 E1 + E2,你将得到值 12。
那么上下文在做什么呢?在这条特定规则中它并没有做很多。但记住类型检查中的上下文是做什么的。上下文是为表达式的自由变量赋予值的。
因此,我们需要对像 E1 + E2 这样的表达式说些什么。关于可能出现在 E1 中的变量的值,你需要说,以便于说它们评估为什么。因此,可以说整个表达式 E1 + E2 将评估为什么。

评估上下文的具体内容
现在让我们更精确地谈谈上下文中将包含什么。
让我们考虑表达式或语句 y = x + 1 的评估。我们将把 y 的值设置为 x + 1。
为了评估这个表达式,我们需要知道两件事。
首先,要知道变量在内存中的位置。例如,变量 x 的值需要查找,然后加 1。该值需存入 y 的内存位置。变量与内存位置有映射。在操作语义中称为环境。

环境可能有点混淆,因为我们曾用环境指代其他事物。现在忘掉其他环境用法。谈论操作语义时,环境指映射,变量与内存位置关联。
此外,需要存储。存储将告诉我们内存中的内容。仅知道变量位置不够。若知道 x 的值,若知道 x 的位置,这是获取 x 值的方法。还需知道确切存储的值。
存储是内存位置到值的映射。这些是存储在内存中的值。所以是两级映射。为每个变量关联内存位置,然后每个内存位置有值。
环境与存储的符号表示

现在谈谈使用的符号。
记录环境和存储。如前所述,变量环境映射变量到位置。我们将以如下方式书写,以变量和位置对列表形式,用冒号分隔。
例如这个环境,说变量 a 在位置 l1,变量 b 在位置 l2。
环境的一个方面是跟踪在作用域内的变量。环境中提到的变量仅是当前范围内的。
存储映射内存位置到值。我们还将存储作为成对的列表写出。在这种情况下,存储 s 中的内存位置 l1 包含值为 5,内存位置 l2 包含值为 7。我们用箭头分隔这些对,只是为了使存储看起来与环境不同,这样我们不会混淆两者。
存储有一种操作,即替换值或更新值。在这种情况下,我们取存储 s,并将位置 l1 的值更新为 12。这定义了一个新的存储 s'。

存储只是函数。至少在我们的模型中,我们可以通过取旧存储 s 的旧函数,并在一点上进行修改来定义新的存储 s'。这定义了一个新的存储 s',使得如果我应用 s' 到新的位置 l1,我得到新的值 12。如果我应用 s' 到任何其他位置,任何不同于 l1 的位置,我得到存储 s 中位置的值。
Cool语言中对象的表示

现在在 Cool 中,我们有更复杂的值和整数。特别是我们有了对象,并且所有对象都是某个类的实例。我们将需要一种表示对象的操作语义符号。
因此我们将使用以下方式写下对象。一个对象将以其类名开始。在这种情况下类名 X。它将跟随属性的列表。类 X 有 n 个属性 a1 到 an。与每个属性相关联的是存储该属性的内存位置。
属性 a1 存储在位置 l1,一直到属性 an 存储在位置 ln。这将是一个完整的对象描述。因为一旦我们知道对象在内存中的存储位置,我们可以使用存储查找每个属性的值。

基本类型的特殊表示
Cool 中有无属性名的特殊类。我们将有一种特殊的书写方式。

整数仅有一个值,将写成 int 和一个整数值。
布尔值类似,它们只有一个值,真或假。
字符串有两个属性:字符串长度和字符串常量。

还有一个特殊值为 void 的 object 类型。我们将使用 void 术语表示。简而言之,void 特殊在于无法操作,除了测试是否为 void。特别地,不能派发 void,即使类型为 object 也会报错。唯一能做的是测试是否为 void。具体实现通常使用空指针表示 void。
操作语义的判断形式
现在可以详细讨论,操作语义中的判断将如何。
上下文将包含三部分:
- 第一部分是当前
self对象。 - 第二部分是环境,从变量到存储位置的映射。
- 第三部分是内存,存储,从内存位置到值的映射。
在一个上下文中,表达式 e 将评估为两件事。首先你会产生一个值。例如,我们之前看到 7 + 5 产生 12。这是评估的一个结果。
但第二件事是它将产生一个修改后的存储。表达式 e 可能是一段复杂的代码,可能本身就是整个程序。它可能包含赋值语句更新内存内容。所以评估后,将有一个新的内存状态需要表示。所以 s' 代表评估后的内存状态。
现在注意几件事。
首先,当前 self 对象和环境不会改变。它们不会被评估改变。所以哪个对象是 self 参数,和当前方法,以及变量和内存位置映射不会被运行表达式改变。这很合理。你不能在 Cool 中更新 self 对象。你没有以任何形式访问变量存储位置的权利。因此,这两件事是不变的。它们在评估下是不变的。当你运行一段代码时,它们不会改变。
然而,存储会改变。内存的内容可能会被修改。这就是为什么我们需要在评估前后存储的原因。
还有一个细节。这种形式的判断总是有一个限定,即判断仅在 e 终止时成立。因此,如果 e 进入无限循环,那么你将不会得到一个值,你也不会得到一个新存储。因此,这种判断应该总是被理解为说,如果 e 终止,那么 e 产生一个值 v 和一个新存储 s'。
总结评估结果

总结一下,评估的结果是一个值和一个新存储。

新的存储模型表达式的副作用。再次注意,一些事情在评估结果中不会改变。
这实际上对于编译很重要,因为我们将能够利用它们不变的事实来生成高效代码。
因此,变量环境不会改变。self 的值,我们谈论的对象不会改变。并且注意这里还有一个细节,self 对象的内容,self 对象中的属性可能会改变,它们可能会被更新。但是,属性存储的位置不会改变。因此,对象存储的布局不会改变。这就是我们在这里所说的。实际对象的内容,是存储映射的一部分。这些可能会通过评估被更新。
操作语义还允许非终止评估。这是这里的最后一点。那些判断仅在假设下成立。

本节课中我们一起学习了形式操作语义的基本框架。我们了解了如何用环境、存储和对象来描述程序状态,以及如何用推理规则来形式化地定义表达式的评估过程。评估的结果包括一个值和一个更新后的存储,而环境和 self 对象在评估过程中保持不变。

编译器实现 P7:词法分析入门 🧩

在本节课中,我们将要学习编译器实现的第一步:词法分析。我们将了解词法分析器如何将源代码字符串分解为有意义的单元,并为每个单元分类。
概述
词法分析是编译器的第一个阶段。它的核心任务是将源代码(一个长长的字符串)分解成一系列称为“标记”的基本单元,并识别每个单元的类型(如关键字、标识符、数字等)。这个过程就像阅读时把句子拆分成单词并理解每个词的词性。
什么是词法分析?
回忆上次内容,编译器通常分为五个阶段。我们将从第一个阶段——词法分析开始讨论。这个过程可能需要三到四个视频才能详细讲完,之后我们会按顺序继续其他阶段。

让我们先看一个小代码片段:
if (i == j) x = 1; else x = 0;
词法分析的目标是将这段代码,分解成其词法单元,例如关键字if、变量名i和j、关系运算符==等等。
对人类而言,这是一件很容易的事,因为有各种各样的视觉线索(如空格、括号)来提示单元的位置和边界。但程序(即词法分析器)没有那种视觉能力。
词法分析器的视角

实际上,词法分析器看到的源代码更像这样的东西(一个包含所有空白字符的线性字符串):
"if (i == j) x = 1; else x = 0;"
从这个字符串表示(你可以认为这就是文件中的字节序列)开始工作,词法分析器必须识别出不同单元之间的分隔符。
它会识别出空白字符与关键字if之间有一个分隔,关键字if之后与左括号(之间也有分隔。它就这样持续地“划出”这些分割线,将字符串分解成一个个词法单元。虽然这里没有完成整个分析,但你应该能理解这个过程。
标记与标记类
然而,词法分析器不仅仅是在字符串中放置分隔符。它还需要根据这些子字符串在程序中的“角色”对它们进行分类。我们称这些角色为标记类(有时也简称为标记的类型)。
在自然语言中,单词的角色可能是动词、形容词等。在编程语言中,标记类可能是:
- 标识符:如变量名。
- 关键字:如
if、else。 - 标点符号:如左括号
(、右括号)、分号;。 - 数字:如整数。
- 操作符:如
==、=。
有一组固定的标记类,每个类对应程序中可能出现的特定字符串集合。

标记类的定义
标记类对应着字符串的集合,这些集合可以用相对直接的方式描述。
以下是几个例子:
- 标识符:在大多数语言中,标识符通常是以字母开头的,由字母或数字组成的字符串。
- 例如:
a1、foo、b17。 - 公式描述:
[a-zA-Z][a-zA-Z0-9]*
- 例如:
- 整数:通常定义为非空的数字字符串。
- 例如:
0、12、001(注意,根据此定义,001也是有效的整数字符串)。 - 公式描述:
[0-9]+
- 例如:
- 关键字:通常只是一组保留字。
- 例如:
else、if、begin等。
- 例如:
- 空白符:空格、换行符、制表符等序列本身也是一个标记类。
因此,词法分析器需要说明源代码字符串中的每一个字符(包括空白符)属于哪个标记类,以及它属于哪个子串。例如,三个连续的空格" "会被分组为一个“空白符”标记。
词法分析器的输出
上一节我们介绍了标记类的概念,本节中我们来看看词法分析器的具体工作流程和目标。

词法分析的目标是按照子字符串在程序中的作用(即标记类)对它们进行分类,然后将这些标记传递给编译器的下一个阶段——语法分析器(解析器)。
我们可以这样描述这个过程:
源代码字符串 (输入) --> [词法分析器] --> 标记序列 (输出) --> [语法分析器]
词法分析器接收一个字符串(通常来自文件),处理后输出一系列标记。每个标记是一个对(Pair),包含:
- 标记类:该子串在语言中的角色。
- 词素:原始输入中的那个子串本身。
例如,对于输入字符串 "foo = 42",词法分析器会输出三个标记:
(标识符, "foo")(赋值运算符, "=")(整数, "42")(注意,这里的"42"是字符串,不是数字值)
实例分析
让我们回到视频开头的示例代码,并将其视为一个字符串。我们的目标是完成对它的词法分析。
首先,我们需要定义一些标记类来工作:
W:空白符(空格、换行、制表符等序列)。K:关键字(如if,else)。I:标识符(变量名)。N:数字(整数)。P:标点符号(每个单字符一类,如(,),;)。O:操作符(如==,=)。
现在,我们遍历字符串并将其标记化。对于每个识别出的子串(词素),我们标注其标记类(用首字母简写表示)。
以下是分析过程:
输入字符串: "if (i == j) x = 1; else x = 0;"
标记化结果:
(W, " ") // 开头的空格
(K, "if") // 关键字 if
(W, " ") // 空格
(P, "(") // 左括号
(I, "i") // 标识符 i
(W, " ") // 空格
(O, "==") // 操作符 ==
(W, " ") // 空格
(I, "j") // 标识符 j
(P, ")") // 右括号
(W, " ") // 三个空格
(I, "x") // 标识符 x
(W, " ") // 空格
(O, "=") // 赋值操作符 =
(W, " ") // 空格
(N, "1") // 数字 1
(P, ";") // 分号
(W, " ") // 空格
(K, "else") // 关键字 else
(W, " ") // 空格
(I, "x") // 标识符 x
(W, " ") // 空格
(O, "=") // 赋值操作符 =
(W, " ") // 空格
(N, "0") // 数字 0
(P, ";") // 分号
通过这个过程,我们识别了输入中的所有子字符串(词素),并为每一个都标注了其标记类。
总结

本节课中我们一起学习了词法分析的基础知识。
总结来说,词法分析器的实现必须完成两件核心工作:
- 识别词素:在输入字符串中识别出对应标记的子字符串。这些子字符串在编译器术语中称为词素。
- 分类标记:对于每个识别出的词素,判断它属于哪个标记类。

词法分析器的最终输出是一系列标记,每个标记都是一个(标记类, 词素)对。这个标记序列将成为语法分析器(解析器)的输入,驱动编译流程进入下一个阶段。

课程 P70:Cool 语言操作语义详解 🧠

在本节课中,我们将深入研究 Cool 语言的操作语义。我们将从最简单的表达式开始,逐步讲解到更复杂的结构,如赋值、块、条件语句和循环。通过理解这些规则,你将能够清晰地把握 Cool 程序中表达式的求值顺序和状态变化。

常量求值规则 📊
上一节我们介绍了课程概述,本节中我们来看看最简单的表达式:常量。常量求值不会修改存储状态。
以下是常量求值的具体规则:
- 布尔值
true:表达式true求值为布尔值true,且不修改存储。- 公式:
<self, E, S> ⊢ true ⇒ true, S
- 公式:
- 布尔值
false:表达式false求值为布尔值false,且不修改存储。- 公式:
<self, E, S> ⊢ false ⇒ false, S
- 公式:
- 整数字面量
i:整数字面量i求值为值为i的整数对象,且不修改存储。- 公式:
<self, E, S> ⊢ i ⇒ Int(i), S
- 公式:
- 字符串字面量
s:若s是长度为n的字符串,则其求值为具有长度n和内容s的字符串对象,且不修改存储。- 公式:
<self, E, S> ⊢ s ⇒ String(n, s), S
- 公式:
标识符与 self 表达式 🔍
了解了常量后,我们来看看如何求值变量和 self。这涉及到从环境(E)和存储(S)中查找信息。

以下是标识符与 self 表达式的求值规则:
- 标识符
id:要评估标识符(如变量名x),首先在环境E中查找其对应的存储位置L_id,然后在存储S中查找该位置的值v。这是一个“加载”操作,不修改存储。- 公式:
<self, E, S> ⊢ id ⇒ v, S,其中E(id) = L且S(L) = v
- 公式:
self表达式:self表达式直接求值为当前对象self,且不修改存储。- 公式:
<self, E, S> ⊢ self ⇒ self, S
- 公式:
赋值表达式 ✍️

现在,我们来看一个会修改存储的表达式:赋值。赋值表达式由标识符和提供新值的表达式组成。
以下是赋值表达式的求值步骤:
- 求值右表达式:首先,在相同的上下文(
<self, E, S>)中求值表达式e,得到值v和可能更新后的存储S1。 - 确定写入位置:在环境
E中查找标识符id对应的存储位置L_id。 - 更新存储:在存储
S1中,将位置L_id的值更新为v,得到新存储S2。 - 返回结果:整个赋值表达式返回右表达式的值
v和更新后的存储S2。- 公式:
<self, E, S> ⊢ id <- e ⇒ v, S2,其中<self, E, S> ⊢ e ⇒ v, S1,E(id) = L,且S2 = S1[L ↦ v]
- 公式:

加法操作 ➕
理解了赋值后,我们来看看二元操作,例如加法。加法表达式 e1 + e2 的求值定义了子表达式的执行顺序。
以下是加法表达式的求值规则:
- 首先,在原始上下文
<self, E, S>中求值e1,得到值v1和更新后的存储S1。 - 然后,在更新后的上下文
<self, E, S1>中求值e2,得到值v2和进一步更新后的存储S2。 - 整个表达式的结果是
v1与v2相加的值,以及最终的存储S2。- 公式:
<self, E, S> ⊢ e1 + e2 ⇒ v1 + v2, S2,其中<self, E, S> ⊢ e1 ⇒ v1, S1且<self, E, S1> ⊢ e2 ⇒ v2, S2
- 公式:
关键点:存储 S 的传递强制了求值顺序:必须先求值 e1,再求值 e2,e2 能看到 e1 产生的所有副作用。
语句块 📦
接下来,我们看看如何求值由多个表达式组成的语句块。块的值是最后一个表达式的值。
以下是语句块的求值规则:
- 按顺序求值块中的每个表达式
e1, e2, ..., en。 - 求值
e1使用原始存储S,产生存储S1和值v1。 - 求值
e2使用存储S1,产生存储S2和值v2,依此类推。 - 求值最后一个表达式
en使用存储S_{n-1},产生最终存储S_n和值v_n。 - 整个块的结果是值
v_n和存储S_n。- 公式:
<self, E, S> ⊢ {e1; e2; ...; en;} ⇒ v_n, S_n,其中求值过程链式进行。
- 公式:
关键点:存储的依赖关系强制了表达式必须按书写顺序执行。只有最后一个表达式的值被保留。

条件表达式(If-Then-Else) ⚖️
现在,我们引入控制流,从条件表达式开始。if 表达式根据谓词的布尔值选择执行的分支。
以下是条件表达式的求值规则:
- 首先求值谓词:在原始上下文
<self, E, S>中求值谓词表达式e1,得到布尔值b和更新后的存储S1。 - 根据结果选择分支:
- 如果
b为true,则在存储S1的上下文中求值then分支e2,得到值v和存储S2。整个表达式返回v和S2。- 公式:
<self, E, S> ⊢ if e1 then e2 else e3 fi ⇒ v, S2,当<self, E, S> ⊢ e1 ⇒ true, S1且<self, E, S1> ⊢ e2 ⇒ v, S2
- 公式:
- 如果
b为false,则在存储S1的上下文中求值else分支e3,得到值v和存储S2。整个表达式返回v和S2。- 公式:
<self, E, S> ⊢ if e1 then e2 else e3 fi ⇒ v, S2,当<self, E, S> ⊢ e1 ⇒ false, S1且<self, E, S1> ⊢ e3 ⇒ v, S2
- 公式:
- 如果

循环表达式(While) 🔄
最后,我们来看最复杂的控制流结构之一:while 循环。其语义描述了循环如何根据条件重复执行。
以下是 while 循环的求值规则:

- 情况一:谓词初始为假:首先在原始上下文
<self, E, S>中求值谓词e1。如果结果为false且存储变为S1,则循环体不执行,整个while表达式以void值和存储S1结束。- 公式:
<self, E, S> ⊢ while e1 loop e2 pool ⇒ void, S1,当<self, E, S> ⊢ e1 ⇒ false, S1
- 公式:
- 情况二:谓词初始为真:
- 在原始上下文
<self, E, S>中求值谓词e1,得到true和存储S1。 - 在存储
S1的上下文中求值循环体e2,得到值v(通常被忽略)和存储S2。 - 关键步骤:在新的存储
S2的上下文中,再次求值整个while表达式。这模拟了循环的迭代。 - 假设这个后续的求值最终终止(情况一),并产生
void值和存储S3。 - 那么最初的
while表达式也返回void值和最终的存储S3。
- 公式:
<self, E, S> ⊢ while e1 loop e2 pool ⇒ void, S3,当<self, E, S> ⊢ e1 ⇒ true, S1,<self, E, S1> ⊢ e2 ⇒ v, S2,且<self, E, S2> ⊢ while e1 loop e2 pool ⇒ void, S3
- 在原始上下文

Let 表达式(变量绑定) 🆕
我们来看最后一个核心表达式:let,它用于引入新的局部变量。这是最复杂的规则之一,因为它涉及扩展环境和分配新内存。

以下是 let 表达式的求值规则:
- 求值初始化器:首先,在原始上下文
<self, E, S>中求值初始化表达式e1,得到值v1和更新后的存储S1。 - 分配新位置:使用函数
newloc(S1)从存储S1中获取一个未被使用的新内存位置L_new。这模拟了内存分配。 - 扩展环境与存储:
- 创建一个新环境
E',它是在原环境E的基础上,将新变量id映射到新位置L_new,即E' = E[id ↦ L_new]。 - 创建一个新存储
S1',它是在存储S1的基础上,在新位置L_new存入值v1,即S1' = S1[L_new ↦ v1]。
- 创建一个新环境
- 求值主体:在新的上下文
<self, E', S1'>中求值let的主体表达式e2,得到值v2和最终存储S2。 - 返回结果:整个
let表达式返回v2和S2。- 公式:
<self, E, S> ⊢ let id: T <- e1 in e2 ⇒ v2, S2,其中<self, E, S> ⊢ e1 ⇒ v1, S1,L_new = newloc(S1),E' = E[id ↦ L_new],S1' = S1[L_new ↦ v1],且<self, E', S1'> ⊢ e2 ⇒ v2, S2
- 公式:
总结 📝

本节课中,我们一起学习了 Cool 语言操作语义的核心规则。我们从最简单的常量求值开始,逐步深入到会修改状态的赋值、定义了执行顺序的加法和块、控制程序流的条件与循环语句,最后学习了引入新变量的 let 表达式。理解这些规则是理解程序如何一步步执行、状态如何变化的基础。记住,存储(S)的传递是强制表达式求值顺序和传播副作用的关键机制。

课程 P71:Cool 操作语义 I - 对象分配与动态分发 🧠

在本节课中,我们将学习 Cool 语言操作语义中最复杂的两个部分:新对象的分配(new)和动态分发(方法调用)。我们将详细拆解这两个操作在运行时发生的每一个步骤。


概述 📋
上一节我们介绍了 Cool 语言的基本操作语义。本节中,我们将深入探讨两个核心且复杂的运行时操作:创建新对象和动态调用方法。理解这些规则对于实现一个正确的 Cool 编译器至关重要。
新对象分配(new)的语义 🆕

首先,我们非正式地讨论分配新对象时发生的事。必须为对象分配空间,本质上意味着为对象的属性留有足够空间。我们将为类 T 对象的每个属性分配一个存储位置。新 T 对象的属性首先被设置为默认值,然后评估其初始化表达式,最后返回新分配的对象。这个过程不仅仅是分配内存,还涉及相当多的计算。
默认值设定
每个类都有一个关联的默认值。规则如下:
- 对于
Int类型,默认值为0。 - 对于
Bool类型,默认值为false。 - 对于
String类型,默认值为空字符串""。 - 对于其他任何类(非基本类),默认值为
void。
类的属性列表
在操作规则中,我们需要一种方式来引用类的属性。因此,我们定义一个名为 class 的函数,它接受类名并返回该类的属性列表、类型及初始化表达式。
以下是关于属性列表的重要特征:
- 列表包含类
A的所有属性,包括继承的属性。 - 属性出现的顺序至关重要。规则是:属性按“最远祖先优先”的顺序列出。
举例说明,假设有三个类:A、B(继承自 A)和 C(继承自 B)。
A定义属性a1,a2。B定义属性b1,b2。C定义属性c1,c2。
那么类 C 的属性列表顺序将是:a1, a2, b1, b2, c1, c2。同一类中的属性按其在文本中出现的顺序排列。

new T 的正式语义
现在我们可以正式定义 new T 的语义了。我们将在具有 self 对象 s、环境 E 和存储 S 的上下文中,为类型 T 分配一个新对象。
步骤分解:
- 确定对象类型
T0:- 如果
T不是SELF_TYPE,则T0 = T。 - 如果
T是SELF_TYPE,则T0是self对象s的动态类型。
- 如果
- 获取属性信息:使用
class(T0)函数获取类T0的属性列表(a1, ..., am)、类型及初始化器(e1, ..., em)。 - 分配存储位置:为每个属性
ai分配一个新的存储位置li。 - 创建对象:创建一个类标签为
T0的新对象v,其属性ai绑定到位置li。 - 初始化默认值:更新存储
S为S1,使得每个新位置li持有属性ai的默认值。 - 评估初始化器:
- 初始化环境
E‘仅包含self对象v和所有属性名ai到其位置li的绑定。 - 将初始化表达式
(e1, ..., em)作为一个块({ e1; ...; em; })按顺序在环境E‘和存储S1中评估。这会得到一个新的存储S’。 - 注意:
self在初始化器中指向正在初始化的新对象v。
- 初始化环境
- 返回结果:
new T的结果是对象v,最终的存储是S‘。

用伪代码/规则描述核心过程:
new T 在 (E, S, s) 环境下:
T0 = if T == SELF_TYPE then class-of(s) else T
attrs = class(T0) // 获取属性、类型、初始化器列表
for each attr ai in attrs:
allocate new location li
v = new object with tag T0 and bindings ai -> li
S1 = S updated with li -> default-value(ai)
E‘ = { self -> v } ∪ { ai -> li }
evaluate block of initializers in (E’, S1) -> (_, S‘)
result = (v, S’)

总结 new 的语义

前三个步骤(确定类型、获取属性、分配位置)负责分配对象内存。剩余的步骤通过按顺序评估一系列赋值来初始化对象。关于初始化,最重要的一点是:初始化器评估的上下文中,只有属性(和 self)在作用域内,这与类型检查规则一致。属性的初始值是默认值,这是必要的,因为属性可以在其自身的初始化器中被引用(例如 a <- a)。

动态分发(方法调用)的语义 🔄

上一节我们介绍了对象创建,本节中我们来看看对象间如何通信——即动态分发。动态分发评估概述如下,然后我们查看正式操作规则。
评估分派时发生的步骤:
- 按顺序评估所有实际参数表达式
e1到en。 - 评估目标对象表达式
e0。 - 查看目标对象
v0的动态类型(类标签X)。 - 在类
X的方法表中查找被调用的方法f的定义。 - 为本次调用设置新的环境:为形式参数分配位置,并用实际参数值初始化这些位置;将
self设置为目标对象v0;将类的所有属性纳入作用域。 - 在新的环境和更新后的存储中,评估方法
f的函数体。
方法实现的表示
为了在类中查找方法,我们需要一个函数来表示类中存在哪些方法。我们定义一个名为 impl 的函数,impl(X, f) 返回类 X 中方法 f 的实现,包括其形式参数列表 (x1, ..., xn) 和函数体 ebody。
动态分发的正式语义
现在我们来讨论方法分派在 Cool 中的正式操作语义细节。考虑动态分发表达式:e0.f(e1, ..., en)。
步骤分解:
- 评估参数:按顺序评估所有实际参数
e1到en。每个评估都可能产生副作用,更新存储。最终得到值(v1, ..., vn)和存储S_{n}。 - 评估目标对象:评估表达式
e0,得到对象v0和更新后的存储S_{n+1}。 - 获取目标类型与方法:设
v0的类标签为X(即其动态类型)。查找impl(X, f),获得形式参数名(x1, ..., xn)和方法体ebody。 - 分配参数空间:为每个形式参数
xi分配一个新的存储位置l_{xi}。 - 构建调用环境
E‘:- 设类
X的属性为(a1, ..., am),它们在v0中的位置是(l_{a1}, ..., l_{am})。 - 初始环境包含
self -> v0和所有属性绑定ai -> l_{ai}。 - 重要:在此基础上,用形式参数绑定
xi -> l_{xi}更新环境。如果形式参数名与某个属性名相同,形式参数的绑定将覆盖(隐藏)属性的绑定。
- 设类
- 初始化参数存储:更新存储
S_{n+1}为S‘,使得每个新位置l_{xi}存储对应的实际参数值vi。 - 评估方法体:在环境
E‘和存储S’中评估方法体ebody。self对象是v0。这会得到结果值v和最终存储S_{final}。 - 返回结果:整个动态分发表达式的结果是
(v, S_{final})。
用伪代码/规则描述核心过程:
e0.f(e1, ..., en) 在 (E, S) 环境下:
evaluate e1 -> (v1, S1)
...
evaluate en -> (vn, Sn)
evaluate e0 -> (v0, S_{n+1})
X = class-tag-of(v0)
(params, ebody) = impl(X, f) // params = (x1, ..., xn)
for each param xi:
allocate new location l_xi
E_attr = { self -> v0 } ∪ { ai -> location-of(ai-in-v0) }
E‘ = E_attr updated with { xi -> l_xi }
S’ = S_{n+1} updated with l_xi -> vi
evaluate ebody in (E‘, S’) -> (v, S_final)
result = (v, S_final)
总结动态分发的语义
方法体在专门构建的环境 E‘ 中被调用,该环境定义了形式参数和 self 对象(v0)的属性。存储 S’ 继承了调用者的所有副作用,并额外包含了绑定到形式参数位置的实际参数值。规则中“栈帧”或“激活记录”的概念是隐式的,这给了实现者灵活性。
静态分发(e0@T.f(...))的语义与此非常相似,唯一的区别在于确定被调用方法的类:它使用指定的静态类型 T,而不是 e0 的动态类型。

规则完备性与运行时错误 ⚠️
值得指出的是,虽然操作规则非常详细,但它们有意省略了一些情况。例如,在动态分发规则中,我们假设在类 X 中查找方法 f 时它一定存在。这是因为 Cool 的类型检查器已经确保了这一点。类型系统的存在使得操作语义规则可以更简洁。

然而,类型检查无法防止所有运行时错误。在 Cool 中,主要有四种运行时错误:
- 对
void(空值)进行分发。 - 整数除以零。
- 子字符串索引超出范围。
- 内存耗尽(分配新对象时没有足够空间)。
一个正确的 Cool 实现必须能优雅地处理这些错误(例如,输出清晰的错误信息),而不仅仅是崩溃。

总结与思考 💎

本节课中,我们一起学习了 Cool 语言操作语义的两个核心部分:新对象分配和动态分发。
new T涉及确定类型、分配空间、设置默认值、按特定顺序(最远祖先优先)评估初始化器,最终返回新对象。- 动态分发
e0.f(...)涉及评估参数和目标对象、根据目标对象的动态类型查找方法、构建包含self、属性和(可能覆盖属性的)形式参数的新环境,最后在新的上下文中执行方法体。
这些操作语义规则非常精确和详细。理解它们就等于理解了如何实现一个正确的 Cool 编译器。虽然规则复杂且包含许多微妙之处(如属性顺序、初始化环境、形式参数对属性的覆盖),但仔细研究它们不仅能指导实现,也是深入理解编程语言设计形式化思维的良好方式。

大多数现实中的编程语言并没有如此明确的操作语义定义。但当软件的可移植性和行为一致性至关重要时,拥有一个独立于具体实现的环境定义就变得非常必要。Cool 的操作语义正是提供了这样一个精确的规范。

课程 P72:中间代码简介 🧩

在本节课中,我们将要学习编译器中的一个核心概念——中间代码。我们将了解什么是中间代码、为什么需要它、它的常见形式以及如何生成它。

什么是中间代码?🤔
上一节我们介绍了本课程的主题。本节中,我们来看看中间代码的定义。
中间代码,或称中间语言,是介于源语言和目标语言之间的一种语言。编译器的作用是将用源语言(如Cool)编写的程序翻译成目标语言(如MIPS汇编)。中间语言就存在于这个翻译过程的中间阶段。
使用中间语言的编译器会先将源语言翻译成中间语言,然后再将中间语言翻译成目标语言。
你可能会问,为什么要分两步进行?事实证明,引入中间层非常有用,因为它提供了一个适中的抽象级别。
具体来说:
- 中间语言比源语言包含更多细节。例如,像Cool这样的源语言没有寄存器的概念,因此无法在源代码层面进行寄存器优化。而中间语言可以包含寄存器,从而允许我们设计和实现寄存器优化算法。
- 中间语言比目标语言包含更少细节。它通常略高于特定机器的指令集级别,因此更容易将中间代码移植到不同的目标机器上,因为它不包含特定机器的所有细节。
经验表明,使用中间语言是一个好主意。几乎所有现代编译器都使用中间语言,有些编译器甚至使用不止一种中间语言。在本课程后续部分,我们将只考虑一种中间语言。

我们将使用的中间语言形式 🛠️
上一节我们了解了中间代码的作用。本节中我们来看看本课程将采用的具体中间语言形式。
我们将要使用的中间语言是一种“高级汇编”语言。它具有以下特点:
- 使用寄存器名,但寄存器数量不受限制(可以任意多)。
- 控制结构类似于汇编语言,包含明确的跳转(jump)和指令标签(label)。
- 包含操作码(opcode),其中一些是“高级”操作码。例如,可能有一个
push操作码,它最终会被翻译为目标机器上的多条具体汇编指令。
在这种中间代码中,每条指令只有两种形式:

- 二元操作:
x = y op z - 一元操作:
x = op y
其中,y和z可以是寄存器或常数(立即数)。x是目标寄存器。
这种形式非常常见,被称为三地址代码,因为每条指令最多涉及三个“地址”(两个操作数和一个结果存放地址)。
这种代码的层级很低。任何涉及多个操作的高级表达式都必须被翻译成一系列每次只执行一个操作的指令。
例如,对于表达式 x + (y * z),我们不能直接在中间代码中表示。必须将其重写为:
t1 = y * z
t2 = x + t1
这里,t1和t2是新的寄存器(或临时变量)。
将复合表达式重写为单操作指令序列的后果是:每个中间值都会获得自己的名字。这为程序的优化和分析提供了便利。
如何生成中间代码?⚙️
上一节我们了解了三地址代码的形式。本节中我们来看看生成中间代码的简要思路。

生成中间代码与生成汇编代码非常相似。主要区别在于,在中间语言中我们可以使用任意数量的寄存器来存储中间结果,这简化了生成过程。
我们可以编写一个名为igen的中间代码生成函数。它接受两个参数:要生成代码的表达式(e),以及用于存放表达式结果的寄存器(t)。

以下是生成加法表达式 e1 + e2 的中间代码的示例步骤:
- 为子表达式
e1生成代码,将其结果存入一个新的寄存器t1。 - 为子表达式
e2生成代码,将其结果存入一个新的寄存器t2。 - 生成一条三地址指令来计算总和:
t = t1 + t2。
由于寄存器数量无限,生成中间代码变得非常简单,甚至比为堆栈机器生成代码还要简单(因为无需管理栈指针,可以直接使用寄存器名)。
总结与展望 📚
本节课中,我们一起学习了编译器中的中间代码。
我们首先了解了中间代码是位于源语言和目标语言之间的桥梁,它既提供了比源语言更底层的细节(以支持优化),又保持了比目标语言更高的抽象(以支持可移植性)。

接着,我们介绍了本课程将使用的中间语言形式——三地址代码,其特点是每条指令最多执行一个基本操作,并为所有中间结果命名。
最后,我们简要探讨了中间代码的生成思路,其核心优势在于可以利用无限数量的寄存器来简化翻译过程。
对于本课程,你需要做到:
- 理解并能够使用这个级别的中间代码。
- 能够编写简单的中间代码程序。
- 能够设计在中间代码上运行的算法(尤其是优化算法)。
关于如何从源程序自动生成中间代码,我们不会深入讨论,因为它所使用的思想与我们已学过的代码生成技术相似。

在未来的课程中,我们将经常查看中间代码,并利用它来表达和实现各种程序优化。

课程 P73:程序优化概述 🚀

在本节课中,我们将要学习程序优化的基本概念,包括优化的原因、权衡以及编译器如何决定实施哪些优化。我们将从编译器的工作阶段开始,探讨优化的位置、执行优化的不同层次(如抽象语法树、汇编语言和中间语言),并介绍优化中两个重要的概念:基本块和控制流图。最后,我们会讨论优化的目标、不同粒度(局部、全局、跨过程)以及为什么并非所有已知的优化技术都会被实际应用。

编译器阶段回顾 🔄
上一节我们介绍了课程的整体目标,本节中我们来看看编译器的工作流程。优化是编译器讨论的最后一个阶段。

让我们简要回顾一下编译器的主要阶段:
- 词法分析
- 解析
- 语义分析
- 代码生成
- 优化
实际上,优化通常在代码生成之前进行,因为我们希望在将程序转换为机器码之前改进它。在现代编译器中,优化阶段通常位于语义分析和代码生成之间,这是编译器中最复杂、代码量最多的部分。

优化的执行时机 ⏱️
一个非常基本的问题是何时执行优化。我们实际上有几个选择。

在抽象语法树上进行优化
- 优势:它是机器无关的。
- 劣势:抽象语法树的层次太高,无法表达许多依赖于机器低级细节的优化。
在汇编语言上执行优化
- 优势:机器的所有细节都暴露出来,原则上任何优化都可以在此级别表达。
- 劣势:优化依赖于特定机器架构,需要为每种新架构重新实现。
使用中间语言进行优化
- 优势:如果设计得当,中间语言可以保持机器无关性(代表一大类机器),同时暴露足够的优化机会,让编译器有效提升程序性能。

中间语言与基本块 🧱

我们将研究对中间语言的优化。该中间语言的操作由以下语法描述:
程序 -> 语句序列
语句 -> x = y // 复制
| x = 一元操作 y // 一元操作
| x = y 二元操作 z // 二元操作
| push x // 压栈
| x = pop // 弹栈
| if x 比较操作 y goto L // 条件跳转
| goto L // 无条件跳转
| L: // 标签

其中,x, y, z 是寄存器名称,也可以在操作符右侧使用立即值。我们假设典型的操作符家族,如加、减、乘等。
优化通常针对语句组进行,最重要的分组是基本块。
基本块是一系列指令,我们希望它是最长可能的指令序列,并满足两个属性:
- 除了第一条指令外,序列中没有标签。
- 除了最后一条指令外,序列中没有跳转。
基本块背后的想法是保证执行流程:一旦从块的第一条语句开始执行,就保证会按顺序执行到最后一条语句。控制流在块内是完全可预测的。进入块的唯一方式是通过第一条指令,离开块的唯一方式是通过最后一条指令。
以下是一个基本块示例,展示了其有用性:
1. t = 2 * x
2. t = t + x
3. w = t + x
因为语句2总是在语句3之前执行,我们可以将第三条指令优化为 w = 3 * x。如果 t 是一个临时变量且只在此处使用,我们甚至可以删除前两条语句。

控制流图 🗺️
仅看单个基本块可能无法了解变量的全部用途。下一个重要的语句分组是控制流图。

控制流图是基本块构成的图。如果执行可以从块A的最后一条指令传递到块B的第一条指令,那么图中就存在一条从块A指向块B的边。控制流图总结了程序中块之间有趣的控制流决策点。
一个方法体可以表示为一个控制流图。我们通常约定控制流图有一个特殊的入口节点(起始节点),以及一些返回节点(退出点),这些节点没有出边。

优化的目标与权衡 ⚖️

优化的目的是提高程序的资源利用率。在本课程中,当我们谈论优化时,主要关注减少程序的执行时间,即让程序运行得更快。这是人们最关心的方面,大多数编译器也在此投入大量精力。
但需要认识到,还有许多其他资源可以优化,例如:
- 代码大小
- 网络消息数量
- 内存使用
- 磁盘访问次数
- 电源消耗(对电池供电设备尤为重要)
优化的一个重要原则是:不能改变程序计算的内容。答案必须保持不变。我们可以提高资源利用率,但不能改变程序的输出。

优化的粒度 📊
对于像C和Cool这样的语言,通常谈论三种优化粒度:
以下是三种主要的优化粒度:
- 局部优化:对单个基本块孤立进行的优化。
- 全局优化(实际指函数级优化):针对单个函数(即其整个控制流图)进行的优化。
- 跨过程优化:跨越方法边界,对多个函数进行整体优化的技术。
许多编译器都实现了局部优化,几乎所有现代编译器都实现了全局优化,但实际实现跨过程优化的编译器并不多。这是因为随着优化粒度的增加,实现的复杂度和难度也大幅增加,而许多性能收益在更局部的优化中已经可以获得。

优化实施的现实考量 🛠️
虽然我们知道如何实现许多优化,但编译器开发者经常有意识地决定不实现研究文献中已知的最先进优化。

这主要归结为软件工程的权衡:
- 实现复杂度高:一些优化算法非常复杂,难以正确实现和维护。
- 编译时间成本:一些优化非常耗时,可能导致编译过程需要几小时甚至几天,影响开发效率。
- 收益较低:一些复杂的优化可能只带来微小的性能提升。
因此,优化的真正目标是追求最大的收益与最小的成本(即高成本效益比)。优化带来的性能改进必须足够大,才能证明其在代码复杂性、编译时间等方面所付出的成本是值得的。文献中许多最复杂的优化往往同时具备高复杂度、长编译时间和低收益的特点,因此并未被广泛用于生产编译器。
总结 📝

本节课中我们一起学习了程序优化的概述。我们回顾了编译器阶段,了解了优化通常在中间代码上进行。我们学习了基本块和控制流图这两个用于分析和实施优化的重要概念。明确了优化的目标是提高资源利用率(尤其是运行速度)且不改变程序语义。我们还探讨了局部、全局和跨过程等不同粒度的优化,并理解了在实际编译器开发中,需要在优化收益与实现成本之间进行谨慎的权衡。


课程 P74:局部优化 🛠️

在本节课中,我们将要学习程序优化中最基础的形式——局部优化。局部优化专注于优化单个基本块内的代码,无需考虑复杂的控制流。我们将介绍多种简单但有效的优化技术,并通过实例演示它们如何相互作用,最终使程序变得更小、更快。
什么是局部优化? 🧱
上一节我们介绍了局部优化的基本概念,本节中我们来看看它的具体定义。
局部优化是最简单的程序优化形式,因为它只关注优化单个基本块。特别地,它无需担心复杂的控制流,也不会查看整个方法或过程体。
代数简化 ✂️

在了解了局部优化的范围后,我们来看看几种基于数学特性的简化优化。
以下是几种代数简化的例子:
- 删除无用操作:对于整型变量
x,语句x = x + 0或x = x * 1不会改变x的值,因此可以直接删除。 - 简化操作:语句
x = x * 0可以简化为x = 0。虽然仍需执行赋值,但可能更快或为后续优化创造条件。 - 替换幂运算:计算
y ** 2(y的平方)可以替换为y * y,以避免函数调用开销。 - 用移位替换乘法:对于乘以 2 的幂的操作,如
x * 8,可以用左移替换,即x << 3。对于其他乘数,也可用移位和加减法的组合来替换。
这些优化都利用了数学运算符的特性,用更简单或更高效的操作替换复杂的操作。

常量折叠 🔢
代数简化关注的是操作本身,而常量折叠则致力于在编译时完成计算。
常量折叠是指在编译时而非运行时计算操作的结果。这是编译器最重要和最常见的优化之一。
以下是常量折叠的应用场景:
- 计算常量表达式:对于指令
x = 2 + 2,可以在编译时计算出结果为 4,并替换为x = 4。 - 预计算条件跳转:如果条件语句的谓词仅由立即值组成,如
if (2 > 0) goto L1,编译器可以预计算结果。由于条件恒真,可以直接替换为无条件跳转goto L1;若恒假,则可删除该跳转指令。
注意:在交叉编译(为不同架构的机器生成代码)时,对浮点数的常量折叠需要特别小心。不同架构的浮点运算舍入方式可能不同。谨慎的编译器会在内部以高精度(如字符串形式)执行浮点运算,生成精确的字面量,由目标机器决定最终舍入,以保证结果的一致性。

消除不可达代码 🗑️
优化不仅能让代码运行更快,还能让代码变得更小。接下来我们看看如何删除永远不会执行的代码。

不可达的基本块是指没有任何跳转指令能跳转到其开头,且它不是前一条指令顺延执行目标的基本块。这样的代码永远不会被执行,可以从程序中删除。
删除不可达代码能减小程序体积,可能因更好的缓存局部性而间接提升运行速度。

不可达代码的出现有几种常见原因:
- 条件编译:例如在 C 语言中,通过预定义宏(如
#ifdef DEBUG)来包含或排除调试代码。当DEBUG未定义时,对应的代码块就成为不可达的。 - 库函数未使用:程序可能只使用了通用库中的一小部分函数,其余未使用的函数代码可以被删除。
- 其他优化的结果:其他优化可能导致某些基本块变得冗余和不可达。
单赋值形式 📝
为了更清晰地表达和实现某些优化,我们常常需要将代码转换为一种标准形式。
单赋值形式要求每个寄存器(或变量)在基本块内最多被赋值一次。如果代码不满足此条件,我们可以通过引入新的寄存器名来重写。

例如,对于以下两次对 x 的赋值:
x = y + z
...
x = a + b
我们可以将第一个赋值重写为:
b = y + z
...
x = a + b
现在,每个寄存器都只被赋值一次。

基于单赋值形式的优化 🔄
将代码转换为单赋值形式后,我们可以更方便地应用一些强大的优化。
以下是两种依赖于单赋值形式的重要优化:
公共子表达式消除
如果两个赋值语句的右侧表达式完全相同,并且由于处于单赋值形式,其操作数在两次赋值之间不会改变,那么这两个表达式计算的值必然相同。因此,第二个计算可以被替换为对第一个结果寄存器名的引用。
例如:
x = y + z
...
w = y + z // 可替换为 w = x
复制传播
如果我们看到一个赋值 w = x(将 x 的值复制给 w),那么 w 的所有后续使用都可以替换为直接使用 x。
例如:
b = 5
a = b
c = a + 1 // 复制传播后变为 c = b + 1
复制传播本身并不减少指令,但它常与其他优化(如死代码消除)结合,为后续优化创造条件。

优化组合实例 🧩

单个优化的效果可能有限,但优化之间会相互作用,一个优化可能为另一个优化打开大门。让我们通过一个例子来观察这个过程。
假设我们有以下代码片段:
a = 5
x = 2 * a
y = x + 6
t = x * y
优化过程如下:
- 常数传播:将
a = 5传播到x = 2 * a,得到x = 2 * 5。 - 常量折叠:计算
2 * 5,得到x = 10。 - 常数传播:将
x = 10传播到y = x + 6,得到y = 10 + 6。 - 常量折叠:计算
10 + 6,得到y = 16。 - 常数传播:将
x=10和y=16传播到t = x * y,得到t = 10 * 16。 - 常量折叠:计算
10 * 16,最终得到t = 160。
通过一系列优化的组合,复杂的计算在编译时就被完成了。
死代码消除 ☠️

在进行了各种传播和折叠后,程序中可能会留下一些不再需要的代码。
如果一个寄存器被赋值,但其值在程序的后续部分(包括本基本块和其他地方)从未被使用,那么对该寄存器的赋值语句就是“死代码”,可以直接删除。
考虑以下代码,假设 a 在程序其他地方未被使用:
x = y + z
a = x
- 首先转换为单赋值形式(假设需要):
b = y + z; a = b。 - 进行复制传播:后续使用
a的地方替换为b(此例中无后续使用)。 - 现在,赋值
a = b的结果未被引用,可以删除。 - 最终只留下
b = y + z(或x = y + z)。


综合示例与优化策略 🎯

最后,让我们看一个更复杂的例子,并总结编译器的优化策略。
假设初始代码如下,目标是计算 g:
b = 3
c = b
a = x ** 2
d = x * x
e = 3 << 1
f = a + a
g = e * f
优化步骤:
- 代数简化:
x ** 2替换为x * x;3 << 1是3*2,但先保留。 - 复制/常数传播:
b=3和c=b传播开。 - 常量折叠:
e = 3 << 1可计算为e = 6。 - 公共子表达式消除:
a = x * x和d = x * x相同,故d = a。 - 复制传播:
d=a和e=6传播到后续使用。 - 死代码消除:假设
b, c, d, e后续不再使用,则它们的赋值语句可被删除。 - 最终得到优化后的核心代码:
a = x * x; f = a + a; g = 6 * f。- 进一步的优化:聪明的编译器可能发现
f = a + a即f = 2 * a,从而推导出g = 6 * (2 * a) = 12 * a,进而可能将f也作为死代码消除。
- 进一步的优化:聪明的编译器可能发现
优化编译器的工作方式就像一个拥有许多技巧的工具袋。它反复扫描代码,寻找可应用的优化转换,执行它们,然后再次扫描,直到没有更多的优化可以应用为止。这个过程称为“优化遍”。
总结 📚

本节课中我们一起学习了程序优化中的局部优化技术。我们从局部优化的定义开始,先后介绍了代数简化、常量折叠、消除不可达代码等基础优化。为了更有效地实施优化,我们引入了单赋值形式,并在此基础上讲解了公共子表达式消除和复制传播。最后,我们通过实例看到了死代码消除如何清理冗余代码,并理解了优化编译器通过多“遍”扫描、组合应用各种优化策略的工作方式。掌握这些局部优化是理解更复杂全局优化的基础。

编译器原理 P75:窥孔优化 🕵️
在本节课中,我们将学习一种名为“窥孔优化”的编译器优化技术。这是一种直接作用于汇编代码的局部优化方法,通过分析一小段连续的指令序列,并用更高效的序列替换它,从而改进程序性能。

窥孔优化的基本思想 💡

上一节我们介绍了局部优化的概念,本节中我们来看看一种直接应用于汇编代码的优化变体。
窥孔优化的核心思想是,不通过中间代码,而是直接在生成的汇编代码上进行优化。它得名于其工作方式:想象我们通过一个“窥孔”来观察程序,每次只能看到一小段连续的指令序列。
窥孔如何工作 🔍
窥孔代表程序中的一个滑动窗口,通常包含一小段连续的指令。例如,如果窥孔大小为4,那么优化器每次只能看到4条指令。

以下是窥孔优化的工作流程:
- 优化器将窥孔窗口对准程序的一部分。
- 检查窗口内的指令序列。
- 如果它知道一个更高效的等价指令序列,就用新的序列替换窗口内的旧序列。
- 将窗口滑动到程序的下一个部分,重复此过程,可能对同一段代码应用多次转换。
窥孔优化的规则 📜
窥孔优化通常被编写为一系列的替换规则。规则左边是需要匹配的指令模式,右边是更优的替换序列。
以下是几个典型的窥孔优化示例:
示例1:消除冗余移动
如果我们看到以下指令序列:
MOV a, b
MOV b, a
第二条移动指令是多余的,可以删除。因此,这个两指令序列可以被优化为单指令序列 MOV a, b。此优化需确保没有跳转目标指向第二条指令。

示例2:常数折叠
如果我们看到连续的加法操作:
ADD a, i
ADD a, j
我们可以进行常数折叠,将两次加法合并为一次:
ADD a, (i+j)
这里,(i+j) 表示在编译时计算出的常量值。
与基本块优化的关系 ⛓️
上一节我们讨论了许多基本块优化,其中许多可以转化为窥孔优化。
以下是可转换的优化示例:
- 消除加零操作:指令
ADD a, 0可以优化为MOV a, a(即自身赋值)。 - 消除自身赋值:指令
MOV a, a是冗余的,可以被完全删除,替换为空指令序列。

综合应用这两条规则,ADD a, 0 首先被转换为 MOV a, a,随后 MOV a, a 被删除。这个小例子也说明,与局部优化一样,窥孔优化需要反复应用才能达到最佳效果。
总结与核心观点 🎯
本节课中我们一起学习了窥孔优化技术。我希望这个简单的讨论已经表明,许多优化可以直接应用于汇编代码。
这里需要强调一个重要的观点:对中间代码进行优化并没有特别的“魔力”。对于任何语言编写的程序——无论是高级语言、中间语言还是汇编语言——讨论如何转换该语言的程序以改善其行为都是有意义的。

最后,必须指出“程序优化”这个术语可能有些误导。编译器并不会偶然产生“最优”代码。实际上,编译器所做的是应用一系列已知的“程序改进”转换。它们会尽可能地应用这些转换来改善程序行为。因此,程序优化的本质是程序改进,我们的目标是让程序变得更好。

课程 P76:全局数据流分析入门 🧠
在本节课中,我们将要学习全局程序优化的基础,特别是理解为何需要一种名为数据流分析的技术。我们将从回顾简单的局部优化开始,逐步扩展到整个程序的控制流图,并探讨如何安全地进行全局常量传播。

回顾:基本块优化 🔄
上一节我们介绍了全局优化的概念,本节中我们先回顾一下简单的基本块优化,特别是常量传播和死代码消除。

以下是一小段代码示例:
x = 3;
y = x + 5;
我们注意到变量 x 被赋予了一个常数 3。在局部优化中,这个常量赋值可以向前传播。如果基本块是单赋值形式,这特别容易做到。如果 x 的值在程序中的任何其他地方都未被使用,那么该赋值语句就是死代码,可以被删除。
这是一个在单个基本块内合并常量传播和死代码消除的简单示例。

扩展到控制流图 🌐
上一节我们介绍了基本块内的优化,本节中我们来看看如何将这些优化扩展到整个控制流图。
控制流图是基本块的图,其中节点是基本块,边表示基本块之间的控制转移。例如,第一个基本块可能包含一个测试和 if 语句,根据测试结果跳转到不同的基本块。
在下面的控制流图示例中,我们观察到 x 被赋予常数,随后有对 x 的使用。在某些情况下,像在单个基本块中一样,用常数 3 全局替换 x 的使用是安全的。

全局常量传播的安全性 🛡️
然而,并非所有情况都允许安全地进行常量传播。让我们看一个反例。

考虑以下情况:变量 x 在程序的不同路径上被赋予了不同的常数值。例如,一条路径上 x = 3,另一条路径上 x = 4。在两条路径汇合后使用 x 的地方,我们不能简单地将 x 替换为 3 或 4,因为实际值取决于执行路径。
那么问题来了:我们如何知道何时可以安全地进行全局常量传播?

全局常量传播的条件 ✅
对于常量传播,有一个简单的判断标准:要用常数 k 替换变量 x 在某个位置的使用,我们必须知道以下事实:

在每一条能够到达该 x 使用位置的程序路径上,x 的最后一次赋值都必须是 x = k。
这个条件很直观:要保证 x 在使用点一定是 k,就必须确保所有可能的执行路径在到达该点前,最后都给 x 赋了值 k。
让我们再次审视之前的例子。要安全地将某处的 x 替换为 3,我们需要检查所有能到达该点的路径。如果每条路径上 x 的最后一次赋值都是 3,那么替换就是安全的。反之,如果存在一条路径使得 x 的最后赋值是其他值(如 4),那么替换就不安全。

全局数据流分析简介 🧩
检查“所有路径”的条件并不容易,因为路径可能包含循环和条件分支。实现这类检查的技术统称为全局数据流分析。它之所以被称为“全局”,是因为它需要对整个程序的控制流图进行分析。
暂时退一步看,编译器需要执行的许多全局优化(如常量传播)都依赖于知晓程序在特定点的某些属性(例如,“变量 x 在此处是否一定是常数?”)。然而,证明这样一个局部事实通常需要推理整个程序,这是一个复杂且可能计算量很大的问题。
幸运的是,在优化中我们总是可以采取保守策略。这意味着:
- 如果我们能肯定某个属性成立,我们就应用优化。
- 如果我们不能确定属性是否成立,我们最坏的情况就是放弃这次优化,以保证程序行为的正确性。
因此,数据流分析可以采用近似技术,只要在它声称属性成立时保证正确即可;当它不确定时,就选择不优化。
总之,全局数据流分析是一套用于解决上述问题的标准技术族。全局常量传播就是一个需要数据流分析的典型优化示例。
总结 📝
本节课中我们一起学习了:
- 基本块优化的回顾:包括常量传播和死代码消除。
- 将优化思想扩展到控制流图,并认识到全局优化的复杂性。
- 明确了全局常量传播的安全条件:在所有可达路径上,变量的最后一次赋值必须是目标常量。
- 引入了全局数据流分析的概念,它是一种通过分析整个控制流图来推导程序各点属性的技术,并理解其保守近似的特性。

在接下来的课程中,我们将更详细地探讨全局常量传播及其他数据流分析技术的具体实现。

课程 P77:全局常量传播详解 🧮

在本节课中,我们将深入探讨全局数据流分析中的一个重要应用——全局常量传播。我们将学习其工作原理、核心概念以及如何通过系统化的算法为程序中的每个点计算变量的常量属性。


概述
全局常量传播的目标是,在满足特定条件时,用常量值替换程序中变量的使用。为了实现这一点,我们需要分析程序的控制流,并确定在每一个程序点上,变量是否具有一个已知的常量值。
常量传播的条件
要进行全局常量传播,必须满足一个关键属性:对于变量 x 的每一次使用,在到达该使用的每一条执行路径上,x 的最后一次赋值都必须是 x = k(其中 k 是一个常量)。

公式表示:
对于变量 x 在点 p 的使用,需满足:
∀ path → p, last assignment to x on path is x = k
这意味着,只有当我们能证明在所有可能执行到该点的路径上,x 都被赋予了同一个常量值时,才能安全地进行替换。
程序点的值状态
为了进行计算,我们需要为每个程序点上的变量 x 关联一个“值状态”。这个状态可以是以下三种之一:
- ⊤ (Top):表示我们不知道
x在该点是否为常量。这是最安全(但最不精确)的假设,意味着x可以取任何值。 - c (Constant):表示我们已证明在该程序点上,
x始终是某个特定的常量值c。 - ⊥ (Bottom):表示该程序点可能永远无法执行(语句不可达)。在这种情况下,
x的值无关紧要。
核心概念:
变量 x 在程序点 p 的状态 ∈ { ⊥, c, ⊤ }

手动分析示例

让我们通过一个简单的控制流图来手动理解这个过程。
假设我们有以下代码片段(已转化为控制流图):
x = 3- 条件分支(不涉及
x) - 右分支:
y = ...(不影响x) - 左分支:
y = ...;x = 4 - 汇合点:
a = 2 * x

分析步骤:
- 入口点:程序开始前,我们不知道
x的值,所以x = ⊤。 - 执行
x = 3后:我们确定x = 3。 - 经过条件分支后:分支不改变
x,所以在两个分支的起点,x仍为3。 - 右分支执行
y = ...后:x不变,仍为3。 - 左分支执行
y = ...后:x仍为3;执行x = 4后,x变为4。 - 汇合点 (
a = 2 * x) 之前:来自右分支的x是3,来自左分支的x是4。两条路径的值不一致,因此我们无法确定x是常量。所以在此点,x = ⊤。

这个例子展示了如何在路径汇合时,常量信息可能丢失(变为 ⊤)。

数据流分析算法原理
上一节我们通过例子直观理解了常量传播。本节中我们来看看如何系统化地计算这些属性。
全局数据流分析的核心思想是:复杂的全局程序分析,可以通过组合只关注相邻语句间信息变化的简单局部规则来实现。
我们将定义一组传递函数,它们描述了信息如何从一个语句“流动”到下一个语句,以及如何被语句本身所改变。
语句间的信息传递规则 (前驱 → 当前语句)
对于一个语句 s,它有若干个直接前驱语句 P1, P2, ..., Pn。x 在 s 执行之前的状态,取决于它在所有前驱语句执行之后的状态。

以下是决定 x 在进入 s 之前(in[s])状态的规则:

规则列表:
以下是基于前驱状态的组合规则:
- 规则 1:如果任何一个前驱之后
x = ⊤,那么in[s]中x = ⊤。 - 规则 2:如果两个前驱之后
x是不同的常量(例如c和d,且c ≠ d),那么in[s]中x = ⊤。 - 规则 3:如果所有前驱之后
x都是同一个常量c,或者某些是c而另一些是⊥,那么in[s]中x = c。(因为⊥表示路径不可达,不影响可达路径的一致性)。 - 规则 4:如果所有前驱之后
x = ⊥,那么in[s]中x = ⊥(表示s本身不可达)。
语句内部的信息传递规则 (输入 → 输出)
上一组规则连接了不同语句。我们还需要规则来描述单个语句如何改变信息,即从语句 s 的输入状态 (in[s]) 产生输出状态 (out[s])。
规则列表:
以下是语句 s 对 x 状态的影响规则:
- 规则 5:如果
in[s]中x = ⊥,那么out[s]中x = ⊥。(语句不可达,则其输出点也不可达)。 - 规则 6:如果语句
s是x = c(常量赋值),那么无论in[s]中x是什么(⊤或某个常量d),out[s]中x = c。(注意:规则5优先级更高,若in[s]为⊥则应用规则5)。 - 规则 7:如果语句
s是x = f(...)(非常量复杂表达式赋值),且in[s]中x ≠ ⊥,那么out[s]中x = ⊤。(我们无法计算复杂表达式的结果)。 - 规则 8:如果语句
s是对其他变量(不是x)的赋值或无关操作,那么out[s]中x的状态与in[s]中相同。

算法执行过程
现在我们可以将这些规则整合成一个具体的算法。
算法步骤:
- 初始化:
- 在程序入口点,将
x的状态设为⊤(因为最初值未知)。 - 在所有其他程序点,将
x的状态保守地初始化为⊥(假设它们都不可达)。
- 在程序入口点,将
- 迭代求解:
- 反复扫描程序中的所有语句和程序点。
- 检查当前的状态分配是否违反了上述8条规则中的任何一条。
- 如果发现不一致,就根据相应的规则更新该点的状态。
- 终止:
- 当一次完整的扫描不再引起任何状态更新时,算法终止。此时得到的状态分配满足所有数据流规则,就是最终的分析结果。

算法特点:
这是一个约束满足或不动点迭代算法。它从最保守的假设(⊥)开始,逐步沿着控制流传播已知信息(常量 c),直到所有点的状态不再变化。
总结
本节课中我们一起学习了全局常量传播的完整流程。
我们首先明确了进行常量替换必须满足的路径条件。接着,我们引入了 ⊤、c、⊥ 这三个关键概念来描述程序点的值状态。通过一个手动示例,我们直观看到了信息在控制流图中的传播与合并。
然后,我们深入探讨了系统化的数据流分析算法,将其分解为两组核心规则:一组管理语句间的信息传递,另一组管理语句内部的信息转换。最后,我们描述了如何通过初始化和迭代求解来执行这个算法,从而为程序中所有点计算出变量的常量属性。

掌握这个算法是理解许多编译器优化(如常量折叠、常量传播)的基础。

课程 P78:循环分析 🔄
在本节课中,我们将学习控制流图分析中最有趣的部分——循环分析。我们将通过一个具体的例子,理解当分析遇到循环时如何打破递归,并最终计算出程序中变量的常量值。


概述
我们将分析一个包含循环的控制流图。分析循环时,会遇到信息相互依赖的递归问题。为了解决这个问题,我们需要引入一个特殊的初始值(bottom),并通过迭代更新的方式,逐步推导出最终的正确信息。
循环分析示例
这是一个包含循环的控制流图示例。分析中需要特殊元素 bottom 的需求,这与循环分析紧密相关。
让我们思考如何用这个特定的控制流图进行常量传播分析。关于变量 x,我们最初一无所知。
在进入控制流图之前,x 的值为 top(表示未知)。在赋值为 3 后,我们知道 x 的值为 3。这里的条件分支,其谓词不会影响 x 的值,因此两条分支上 x 的值都是 3。对 y 的赋值不会影响 x,因此这里 x 的值也是 3。
现在,我们关注循环中的这个语句。分析 x 在 y 等于 0 时的规则是:x 在赋值给 y 之前的值,是其所有前驱节点值的函数。
我们还没有这里 x 的值。所以问题是:这条边上 x 的值是什么?为了弄清楚这一点,我们需要看它的前驱节点。
它的前驱节点包括:谓词之后的一个点、两个语句之间的一个点、以及执行 y 赋值之后的一个点。我们正在沿着边向后追溯。为了知道 x 在这里的信息,我们需要知道它在前驱节点的信息。因为这个边意味着,我们再次需要在 y 等于 0 的两个前驱节点上知道 x 的信息。

现在我们处于循环中。这并不令人惊讶。如果关于 x 的信息依赖于其语句的前驱,并且你确实遵循递归追溯,那么最终你会陷入这样的循环。目前没有立即明显的方法来解决这个问题。
我们如何获取关于前驱的信息,当 y 的前驱又依赖于自身时?更精确地说,再次查看那个特定语句:为了计算在语句 y 等于 0 之前,x 是否为常数,我们需要知道 x 在其两个前驱处是否为常数。而该信息又依赖于其前驱,其中包括 y 等于 0 这个点本身。这就是难题:我们如何解决这个递归问题?

解决方案:打破循环
有一个标准解决方案,实际上在许多数学领域都使用,不仅仅是循环分析。当你有这些类型的递归关系或递归方程时,标准解决方案是打破循环。
具体做法是:从一些初始猜测开始。所以你有一个初始近似值,它可能甚至不是最终结果,但允许你开始计算。
由于循环的存在,所有程序点在所有时间都必须有一个值。因此,我们将为所有点分配一个初始值。这就是 bottom 的作用。bottom 意味着“到目前为止,我们知道控制从未到达这一点”。记住这一点,我们几段视频之前说过。这将使我们能够取得进展。

逐步分析过程
让我们继续分析这个控制流图。我们假设在所有点,最初 x 有一个 bottom 值,除了入口点。
入口点是特殊的,我们假设我们不知道关于 x 的任何信息(即 top),因为我们知道控制达到了初始点。但最初,我们将其他地方 x 的值都设为 bottom。
现在,我们有初始设置。记住程序是什么,我们去看信息不一致的地方,然后更新它。
信息不一致的地方在哪里?显然,在 x = 3 赋值之后,x 的值是 bottom 是不正确的。因为如果控制到达 x = 3 之前,那么赋值后 x 将等于 3。同样,谓词不会改变 x 的值,所以我们必须更新两个分支在谓词之后、以及在这个不影响 x 的赋值之后的结果,使信息一致。
我们现在回到有趣的情况。我们知道 x 等于 3,进入 y = 0 的这条分支。就我们所知,控制从未到达另一个前驱(其值仍是 bottom),所以我们将开始假设那条路径从未被采取。
如果那条路径从未被采取,那么它不会贡献任何信息。所以在程序的这个点,我们将知道 x 等于 3。假设所有这些信息都是正确的,我们将能够得出结论:x 等于 3。
注意我们如何打破循环并开始:我们就假设循环中的最后一条边从不执行。如果不是这样,我们稍后会发现的,这个下面的值将不再是 bottom,然后我们会再次更新赋值。
让我们继续。在 y 被赋值为 0 之前,x 等于 3。对 y 的赋值不会影响 x 的值,所以使之后的信息一致,我们更新该点 x 等于 3。
现在有两个路径的合并。在执行这个赋值之前,我们也知道 x 等于 3。赋值 a 不会影响 x,我们会更新那一点。谓词不会影响 x 的值,所以我们会知道 x 在这条回边上等于 3。
现在信息已经改变。我们知道控制可以到达这条边,因为我们遵循了一条控制路径,一路到这里我们有了关于 x 的新信息。所以现在我们必须再次检查一切是否仍然正常。
这里我们有 x 等于 3 在这条边上,x 等于 3 在这条边上。我们之前的结论是 x 在进入语句 y = 0 时等于 3,那仍然是一致的。控制流图中没有不一致的地方,所以所有信息都与所有规则一致。我们完成了。
这是最终分析。我们能够得出结论:在所有这些点(除了入口点),x 实际上是常数 3。
总结

本节课中,我们一起学习了循环分析。我们看到了分析循环时遇到的递归依赖问题,并学会了通过引入 bottom 作为初始值来打破循环。通过迭代更新信息,我们最终能够推导出程序中变量的常量值。这个方法是在存在循环的控制流图中进行静态分析的基础。

课程 P79:抽象值与偏序关系 🧮
在本节课中,我们将学习程序分析中抽象值的概念,并引入一个关键的工具——偏序关系。我们将看到如何用偏序来组织抽象值,以及它如何帮助我们理解分析算法的行为,特别是其终止性。

抽象值与具体值 📊

上一节我们介绍了程序分析的基本概念。本节中,我们来看看分析中使用的“值”有何不同。
在程序分析中,我们计算的值(如“底部”、“常数”和“顶部”)被称为抽象值。这是为了与具体值相区分。
- 具体值:程序在运行时实际计算的值,例如具体的数字或对象。
- 抽象值:程序分析所使用的、更为抽象的值。一个特定的抽象值可以代表一组可能的具体值。
在常量传播分析所使用的抽象值集合中,有一个非常抽象的值——顶部。它代表任何可能的运行时值,即所有运行时值的集合。
抽象值的偏序关系 ⬆️⬇️
理解了抽象值的概念后,我们如何组织它们呢?本节我们将引入一个核心概念:偏序关系。

我们可以通过定义一种顺序来简化我们一直在讨论的分析。我们规定:
- 底部 小于所有常数。
- 所有常数小于 顶部。
如果我们用一张图来表示,将较低的值画在下方,较高的值画在上方,就能得到以下关系图:
- 底部 在最下方,低于所有其他值。
- 所有常数位于中间层。
- 常数之间是不可比的。例如,0 不大于 1,1 也不大于 0,它们彼此没有大小关系。
- 顶部 位于最上方,大于其他一切值。
这个关系可以用一个简单的公式来描述抽象值 a 和 b 之间的“小于或等于”关系 ⊑:
⊥ ⊑ c(对于任何常数c)c ⊑ T(对于任何常数c)⊥ ⊑ T
最小上界(LUB)运算 🔗
定义了顺序之后,我们就可以定义在值集合上的一个关键操作:最小上界。
最小上界是指,在排序中,大于或等于集合中所有元素的最小的那个元素。
以下是几个例子:
{⊥, 1}的最小上界是1。{⊥, T}的最小上界是T。{1, 2}的最小上界是T。因为1和2不可比,大于它们两者的最小元素就是T。
这个概念非常重要,因为它精确地描述了之前课程中规则1到4所做的事情。语句 S 的输入信息 in[S],就等于其所有前驱语句输出信息 out[P] 的最小上界。

用代码逻辑可以表示为:
in[S] = LUB({out[P] for P in predecessors_of(S)})
其中 LUB 函数计算给定集合的最小上界。
偏序如何保证算法终止?🔄
我们一直说分析算法会不断应用规则直到没有变化,但为什么这个过程一定会停止,而不是永远循环下去呢?本节将揭示偏序关系在此起到的作用。
算法保证终止,原因在于抽象值的偏序结构以及规则的特性。
- 初始状态:除了程序入口点,所有信息在开始时都被初始化为底部(即排序中的最低点)。
- 规则的单向性:仔细观察所有转移规则,它们只会使一个程序点上的值增加(在偏序的意义上向“上”移动)。
- 一个值可以从
⊥提升到某个常数c。 - 之后可以从常数
c提升到T。
- 一个值可以从
- 有限的上升空间:一旦值达到
T,就没有比它更大的元素了,因此无法再被更新。

这意味着,对于每个程序点上的每个变量(无论是 in 还是 out),其值最多只能改变两次(⊥ → c → T)。因此,我们描述的常量传播算法,其时间复杂度实际上是程序大小的线性级别。
算法可能执行的总步数被常数(每个值最多改变2次)和程序点的数量所限制。粗略估计,总步数不会超过 (程序语句数量) * 4。
总结 📝
本节课中我们一起学习了:
- 抽象值与具体值的区别,理解了
⊥(底部)、常数和T(顶部)的含义。 - 如何用偏序关系(
⊑)来组织这些抽象值,构建出⊥ < 常数 < T且常数间不可比的层次结构。 - 定义了最小上界运算,并认识到分析规则中的合并操作本质上就是在计算前驱值的最小上界。
- 最关键的是,我们明白了偏序关系如何保证了分析算法的必然终止:因为值只能从低向高有限地变化,最终必然会达到一个不再变化的稳定状态。

通过引入偏序这一数学工具,我们不仅更清晰地描述了分析过程,也严谨地论证了算法的可行性。这是将直观分析思想形式化的重要一步。

编译原理课程 P8:词法分析中的前瞻问题 🔍

在本节课中,我们将学习词法分析中的一个核心挑战:前瞻。我们将通过分析历史上几种编程语言(如Fortran和PL/1)中出现的具体例子,来理解为什么在从左到右扫描源代码时,有时需要“向前看”几个字符才能正确识别一个词素(Token),以及为什么现代语言设计会尽量避免这种情况。
Fortran中的空格规则与前瞻问题 📜
上一节我们介绍了词法分析的基本概念,本节中我们来看看一个经典的例子:Fortran语言。Fortran有一个有趣的词法规则:空格不重要。这意味着源代码中的空格可以被忽略,不会改变程序的含义。
例如,变量名 var one 和 varone 在Fortran中被视为完全相同的标识符。这个规则源于早期使用穿孔卡输入程序的年代,旨在减少因意外添加空格而导致的错误。
然而,这个规则给词法分析器带来了挑战。请看以下两个Fortran代码片段:
DO 5 I = 1, 25
DO 5 I = 1. 25
这两个片段几乎完全相同,唯一的区别是第一个使用逗号 ,,第二个使用句号 .。但这个微小的差异导致了完全不同的解释:
- 第一个片段是一个
DO循环(类似于现代语言的for循环)。DO是关键字,5是循环结束的标签,I是循环变量,1, 25表示循环范围。 - 第二个片段是一个赋值语句。
DO5I是一个变量名(因为空格被忽略),=是赋值符,1.25是一个浮点数。
当词法分析器从左到右扫描,读到字符序列 D-O-空格-5 时,它无法立即判断 DO 是一个独立的关键字,还是变量名 DO5I 的一部分。唯一能区分它们的方法是向前查看下一个字符是逗号还是句号。这就是一个典型的需要前瞻才能做出词法判断的例子。
词法分析的目标与前瞻的必要性 🎯
词法分析的核心目标是:将输入的字符流分割成一系列有意义的词素,并为每个词素赋予一个标记(Token)。
这个过程通过从左到右扫描输入流来完成。由于这种扫描方式,分析器经常需要查看当前字符之后的字符(即“前瞻”),以确定:
- 当前词素在哪里结束。
- 下一个词素从哪里开始。

以下是几个需要前瞻的常见情况:

- 区分关键字与标识符:例如,在C语言中,扫描到字符
e时,需要前瞻以判断它是标识符e的开头,还是关键字else的开头。 - 区分单符号与双符号操作符:例如,扫描到
=时,需要前瞻以判断它是一个单独的赋值符=,还是等于比较符==的第一部分。

前瞻总是需要的,但语言设计的一个关键目标是将其限制在最小、固定的范围内,这能极大地简化词法分析器的实现。
PL/1:未保留关键字带来的挑战 🤯
为了进一步理解前瞻的复杂性,我们看看PL/1语言。PL/1的一个设计特点是:关键字不是保留字。这意味着关键字(如 IF, THEN, ELSE)也可以被用作变量名或函数名。
这导致了非常令人困惑的代码,例如:
IF THEN THEN ELSE = ELSE; ELSE ELSE = THEN;
在这行代码中,IF、THEN、ELSE 既可能作为关键字,也可能作为变量名。词法分析器在扫描时,无法仅根据局部字符序列来判断它们的角色,必须依赖对整个语句语法的理解,这可能需要大量的、甚至无限的前瞻。
另一个PL/1的例子是:
DECLARE (ARG1, ARG2, ..., ARGN)
仅看这个片段,DECLARE 可能是一个关键字(声明语句),也可能是一个数组名(数组引用)。要确定它的真实角色,词法分析器可能需要扫描完整个很长的参数列表 (ARG1, ARG2, ..., ARGN),并查看后面是否跟着等号 = 或其他符号。这理论上可能需要无限的前瞻。

Fortran和PL/1的这些历史经验告诉我们,糟糕的词法设计会让分析变得异常困难。
现代语言中的类似问题:C++模板 🧩
前瞻问题在现代语言中并未完全消失。一个著名的例子是C++中的嵌套模板与流操作符的冲突。
考虑以下C++代码:
std::vector<std::list<int>> myList;

设计者的本意是声明一个“int列表的向量”。然而,>> 在C++中也是右移操作符和流输入操作符。早期的许多C++词法分析器会简单地将 >> 识别为一个独立的操作符标记,从而导致语法错误。
这个问题的解决方案是:词法分析器需要在特定上下文(模板参数列表内)中,将连续的 > 解释为两个独立的闭括号标记。对于程序员来说,在C++11标准之前,通常的变通方法是在>>之间插入一个空格:std::vector<std::list<int> > myList;。
这个例子说明,即使在新语言中,词法分析和语法分析的界限有时也会变得模糊,需要协同工作来解决歧义。
总结 📝

本节课中我们一起学习了词法分析中的“前瞻”问题。
- 核心任务:词法分析器负责将字符流分割为词素并分配标记,主要通过从左到右扫描实现。
- 核心挑战:由于扫描是顺序的,分析器经常需要前瞻后续字符,才能确定当前词素的边界和类型。
- 设计目标:优秀的编程语言设计应力求最小化所需的预读量,最好将其限制为一个很小的常数,这能极大简化词法分析器的构建。
- 历史教训:像Fortran(忽略空格)和PL/1(未保留关键字)这样的设计,会导致严重的、有时是无限的前瞻需求,应引以为戒。
- 现代体现:即使在C++这样的现代语言中,类似嵌套模板
>>的歧义问题仍然存在,需要通过更精巧的词法-语法分析协作来解决。

理解前瞻问题,能帮助我们更好地领会编译器前端的设计思路,以及编程语言本身的设计哲学。

课程 P80:活跃度分析 🔍

在本节课中,我们将要学习一种名为“活跃度分析”的全局数据流分析技术。我们将了解其核心概念、分析规则,并通过一个简单例子演示其工作过程。活跃度分析是编译器优化中的一项关键技术,用于识别程序中哪些变量的值在特定点之后还会被使用。

概述 📋

本视频探讨了另一种全局分析:活跃度分析。在过去的几个视频中,我们探讨了在控制流图中全局传播常量的过程。回顾我们讨论过的算法,足以证明在某些情况下,我们可以将变量替换为常数。一旦完成,该变量可能不再有用,因此可能从程序中删除该语句。这是一个重要的优化,但前提是该变量在程序其他地方确实不被使用。
上一节我们介绍了常量传播,本节中我们来看看活跃度分析,它关注的是变量值在未来的“使用可能性”。
活跃度的定义 📖

让我们更精确地定义“变量未被使用”的含义。在语句中对变量的引用,我们称之为“使用”。如果一个变量在某个程序点之后,其值可能在未来被使用,我们就说该变量在该点是活跃的。
活跃 = 值可能在未来被使用。
例如,在某行代码中写入变量x的值,如果该值可能被后续指令使用,那么x在该写入点就是活跃的。在某些情况下,该值甚至是保证会被使用的。相反,如果变量x被赋值后,其值在后续任何使用之前就被新的赋值覆盖,那么这个赋值就是死的,因为写入的值永远不会被程序的任何部分使用。

活跃度的形式化定义 🔬

总结来说,变量x在语句s处活跃,当且仅当存在一个使用x的语句s',并且从s到s'存在一条路径,且该路径上没有对x的其他赋值。这意味着s处写入的x值,最终会被s'读取。
如果变量不是活的,那么它就是死的。如果一个对x的赋值语句之后,x立即死亡,那么这个赋值是无用的,整个语句可以从程序中删除。为了做到这一点,我们需要活跃度信息。

活跃度分析框架 🏗️
与常量传播类似,我们希望将“变量未来是否会被使用”这一全局信息,关联到程序的特定点上,以便做出局部优化决策。我们将定义并执行活跃度分析的算法,它遵循相同的框架:用相邻语句之间传递的信息来表达活跃度。
活跃度分析实际上比常量传播更简单,因为它只是一个布尔属性(真或假)。

活跃度传播规则 📜
以下是决定变量x活跃度的四条核心规则。这些规则定义了信息如何在相邻程序点之间流动。
-
后继点规则(控制流合并):在程序点
p之后,x的活跃度是p所有后继程序点x活跃度的逻辑或(OR)。公式表示为:live_out(p) = OR(live_in(s)) for all successors s of p。直觉是,只要x在p之后的某条路径上会被使用,它在p点就是活跃的。 -
读操作规则:如果语句
s读取了x的值(例如y = x + 1),那么在语句s之前,x是活跃的。因为x的值即将被使用。规则为:live_in(s) = true(如果s读取x)。 -
写操作规则:如果语句
s写入了x的值,且其右侧表达式e中没有引用x(例如x = 5),那么在语句s之前,x是死的。因为s将覆盖x的旧值,而旧值不会被s本身使用。规则为:live_in(s) = false(如果s写入x且e不含x)。

- 无关操作规则:如果语句
s既不读取也不写入x,那么x在s之前的活跃度与在s之后相同。规则为:live_in(s) = live_out(s)。

活跃度分析算法 ⚙️
基于以上规则,我们可以给出算法:
- 初始化:假设变量
x在所有程序点的活跃度信息都为false(死亡)。 - 重复以下步骤,直到所有语句的信息都满足上述四条规则:
- 选择一个信息不一致的语句。
- 根据适当的规则更新该语句的活跃度信息。
这个算法与我们用于常量传播的算法结构相同。
实例分析 🔎
让我们分析一个简单的带循环的程序:
x = 0;
while (x != 10) {
x = x + 1;
}
// 假设循环结束后不再使用 x
以下是分析步骤:
- 初始假设:假设
x在程序出口(循环后)是死的(false)。所有其他点初始也为false。 - 应用规则:
- 在语句
x = x + 1处,它读取了x。根据规则2,在该语句之前,x必须为活跃(true)。 - 这个活跃信息会沿着控制流反向传播(规则1和规则4)。因此,在循环条件
x != 10判断之前,x也是活跃的。 - 信息继续反向传播到循环入口和初始化语句
x = 0之后。 - 对于初始化语句
x = 0,它写入x且右侧没有读取x。根据规则3,在该语句之前,x是死的(false)。这与我们从循环体反向传播来的true信息在入口点汇合,但根据规则1(OR运算),true OR false = true,所以入口点x为活跃。然而,由于程序入口前没有其他路径,且x是局部变量,其初始值无意义,通常我们更关心x = 0这个赋值本身是否有用。分析显示,在x = 0之后,x是活跃的(因为马上要进入循环判断),所以这个赋值是必要的。
- 在语句
通过这个过程,我们得到了每个程序点上x的正确活跃信息。

算法性质与终止性 ✅
从我们的例子可以看出,活跃度信息只能从false变为true,而不会从true变回false。在数学上,我们有两个值:false(低)和true(高),构成一个偏序集。算法从最低值(false)开始,信息只能向更高的值(true)移动。

由于每个程序点的值最多只能改变一次(从false到true),因此算法保证会在有限步内终止,最终为控制流图提供一致的活跃度信息。
总结:前向分析与后向分析 🔄
总结我们关于控制流图全局分析的讨论,我们介绍了两种主要类型的分析:

- 常量传播是前向分析:信息沿着程序执行的方向(从前向后)流动。例如,一个常数值从定义点传播到使用点。
- 活跃度分析是后向分析:信息逆着程序执行的方向(从后向前)流动。例如,一个变量的使用点会将其活跃性反向传播到其定义点。
文献中有许多其他类型的全局数据流分析,常量传播和活跃度分析是最重要的两种。它们都可以归类为前向或后向分析。几乎所有这类分析都遵循相同的方法论:定义局部规则来描述相邻程序点之间的信息如何传递,从而将分析整个复杂控制流图的问题,分解为一系列简单的、局部的推理步骤。

本节课中我们一起学习了活跃度分析的核心概念、四条传播规则、迭代算法及其工作实例,并理解了它作为一种后向分析与前向分析(如常量传播)的区别。掌握这些基础是理解更复杂编译器优化技术的关键。

课程P81:寄存器分配基础 🧠

在本节课中,我们将要学习编译器后端的一个核心环节——寄存器分配。我们将了解为何需要寄存器分配,以及如何利用全局活跃变量分析来构建寄存器干扰图,从而将中间代码中的大量临时变量映射到有限的物理寄存器上。

为何需要寄存器分配?🤔
上一节我们介绍了中间代码生成。回忆一下,中间代码可以使用无限多的临时变量。这简化了优化过程,因为我们无需担心寄存器的数量限制。
但这也带来了问题。最终生成的汇编代码必须运行在真实的硬件上,而硬件寄存器的数量是有限的。如果中间代码使用了过多的临时变量,我们就无法将它们全部放入寄存器中。

因此,我们需要一个步骤:重写中间代码,使其使用的临时变量数量不超过机器的物理寄存器数量。我们的目标是通过算法,将多个临时变量分配到同一个寄存器中,形成一个从临时变量到寄存器的“一对多”映射。
显然,如果临时变量太多,我们无法全部放入寄存器。这时我们需要一些“技巧”,甚至备用方案。但默认的计划是尽可能多地将临时变量放入同一个寄存器,同时不改变程序的行为。
核心思想:共享寄存器的条件 🔄
我们如何实现这个“魔法”,让一个寄存器保存多个值呢?关键在于:一个寄存器可以保存多个值,但在同一时间只能保存一个值。

让我们考虑一个简单的三语句程序:
a = b + c
e = a + d
f = e - 1
注意:
a在第一句被写入,在第二句被读取。e在第二句被写入,在第三句被读取。f仅在第三句被写入。
假设 a 和 f 在其他地方未被使用,那么 a、e、f 这三个变量的生命周期(活跃期)并不重叠。因此,它们可以共存于同一个寄存器(例如 R1)中。

分配后的代码可能如下:
R1 = R2 + R3 // a = b + c
R1 = R1 + R4 // e = a + d
R1 = R1 - 1 // f = e - 1
这就实现了一个寄存器(R1)对应多个临时变量(a, e, f)的映射。

历史与现代方法 📜

寄存器分配是一个古老的问题,早在1950年代的FORTRAN项目中就被识别出来。早期的算法比较粗糙,人们很快发现,寄存器分配的能力是制约代码生成质量的瓶颈。
约30年后,在1980年,IBM的研究人员取得了突破。他们提出了一种基于图着色的寄存器分配方案。该方案的优点是简单、易于解释,并且是全局性的(利用整个控制流图的信息),同时在实践中效果良好。

基本原则:活跃变量分析 📊
现代寄存器分配算法的基本原则是:两个临时变量可以共享同一个寄存器,当且仅当它们不在同一时间活跃。
换句话说,如果在程序的任何一点,T1 和 T2 中最多只有一个变量是活跃的,它们就可以共享寄存器。更简洁的否定表述是:如果 T1 和 T2 同时活跃,那么它们就不能共用同一个寄存器。因为如果需要同时使用两个值,就必须有两个不同的存储位置。
为了应用这个原则,我们需要知道每个程序点上哪些变量是活跃的。这就需要用到上一节课介绍的活跃变量分析。这是一种数据流分析,通过逆向遍历控制流图,计算出在每个程序点,哪些变量在未来会被使用(即“活跃”)。
构建寄存器干扰图 (RIG) 🕸️

本节中我们来看看如何利用活跃信息进行寄存器分配。核心是构建一个寄存器干扰图。

以下是构建步骤:
- 为每个临时变量创建一个图节点。
- 如果两个临时变量在程序的任何一点同时活跃,就在它们对应的节点之间添加一条无向边。
这条边表示一个约束:这两个变量不能分配到同一个寄存器。

让我们看一个例子。假设在某个程序点,分析显示变量 c 和 e 都处于活跃集中。那么,在寄存器干扰图中,节点 c 和节点 e 之间就会有一条边。

这个图称为寄存器干扰图。其核心思想是:两个临时变量可以被分配到同一个寄存器,当且仅当在寄存器干扰图中,它们之间没有边直接相连。
RIG的优势与寄存器分配 🎯

寄存器干扰图有三大优势:
- 精确描述问题:它提取了描述合法寄存器分配所需的全部约束信息。
- 全局视图:它基于整个控制流图的分析结果,帮助我们做出全局性的寄存器重要性决策。
- 与架构无关:算法本身不依赖于具体的机器指令集,只依赖于一个关键属性——机器的物理寄存器数量。这正是我们使用该算法进行分配时所需要知道的。
从RIG到具体的寄存器分配,可以类比为图着色问题:我们有 K 种颜色(代表 K 个物理寄存器),需要为图中每个节点(临时变量)着色,并保证任何一条边连接的两个节点颜色不同。找到一种 K 着色方案,就找到了一个合法的寄存器分配。
总结 📝

本节课中我们一起学习了寄存器分配的基础知识。我们了解到,由于硬件寄存器有限,必须将中间代码的众多临时变量映射到有限的物理寄存器上。关键在于利用活跃变量分析来识别变量生命周期的重叠情况,并据此构建寄存器干扰图。RIG以图的形式精确刻画了“哪些变量不能共享寄存器”的约束,将寄存器分配问题转化为了图着色问题,为后续设计高效、全局的分配算法奠定了坚实的基础。

课程 P82:寄存器分配与图着色 🎨

在本节课中,我们将学习如何利用图着色技术为程序中的变量分配寄存器。我们将从寄存器干扰图出发,理解图着色的基本概念,并学习一种在实践中广泛使用的近似着色算法。
图着色基础 📚
上一节我们介绍了寄存器干扰图,本节中我们来看看图着色的核心定义。

图着色是指为图中的节点分配颜色,使得任何由边连接的两个节点具有不同的颜色。

如果一张图可以使用 k 种或更少的颜色完成着色,则称该图是 k可着色 的。
在我们的寄存器分配问题中,颜色对应着物理寄存器。我们设 k 为可用物理寄存器的最大数量。如果寄存器干扰图是 k可着色 的,那么就存在一种寄存器分配方案,使得程序使用的寄存器数量不超过 k 个。
图着色示例 🔍

让我们看一个寄存器干扰图的着色示例。

对于这个特定的图,我们使用不超过4种颜色完成了着色。图中用彩色标签和寄存器名称标明了每个节点的分配结果。
请注意,尽管图中节点(临时变量)数量超过4个,我们仍然只用了4种颜色。这意味着多个节点可以共享同一个寄存器,例如节点 D 和 b 共享颜色,节点 e 和 a 共享颜色。

着色算法的挑战 ⚠️
我们讨论了寄存器干扰图是什么,也定义了图着色,但尚未讨论如何计算图着色。这并不容易。
图着色是一个 NP难 问题。这意味着没有已知的、能在所有情况下都快速求解的完美算法。因此,编译器实际使用的都是近似技术。

此外,我们还会遇到第二个问题:给定数量的寄存器可能不足以对图进行着色。例如,我们只有8个寄存器,但图可能需要9或10种颜色才能着色。这个问题我们稍后会处理。
图着色启发式算法 🛠️
现在,我将介绍一种最流行的为寄存器干扰图着色的启发式算法。其基本思想是分而治之。

算法的核心是选择一个邻居数量少于 k 的节点 t,将其从图中移除。如果移除 t 后得到的子图是 k可着色 的,那么原始图也是 k可着色 的。
其原理是:节点 t 的邻居少于 k 个。在为子图着色后,t 的邻居最多使用了 k-1 种颜色,因此至少有一种颜色剩余,可以分配给 t。
算法步骤详解 📝

该算法分为两个阶段:简化 和 着色。
第一阶段:简化
以下是简化阶段的步骤:
- 在寄存器干扰图中,选择一个邻居数量少于
k的节点t。 - 将节点
t压入栈中,并将其从图中删除(包括与其相连的所有边)。 - 重复步骤1和2,直到图为空。
这个阶段的目标是为图中的节点生成一个处理顺序。
第二阶段:着色
以下是着色阶段的步骤:
- 从栈中弹出栈顶节点(即最后被移除的节点)。
- 将该节点加回图中,并恢复其原有的边。
- 为该节点分配一个颜色(寄存器),该颜色必须与其所有已着色邻居的颜色不同。通常选择编号最小的可用寄存器。
- 重复步骤1到3,直到栈为空,所有节点都完成着色。
算法实例演示 🧮
让我们通过一个具体例子来理解算法。假设我们有下图,且 k = 4(即有4个可用寄存器)。

第一阶段:简化
- 选择节点
a(2个邻居),移除并压栈。栈:[a]。 - 选择节点
d(3个邻居),移除并压栈。栈:[d, a]。 - 选择节点
c(此时邻居少于4),移除并压栈。栈:[c, d, a]。 - 选择节点
b,移除并压栈。栈:[b, c, d, a]。 - 选择节点
e,移除并压栈。栈:[e, b, c, d, a]。 - 选择节点
f,移除并压栈。栈:[f, e, b, c, d, a]。图空。
第二阶段:着色
我们按出栈顺序(f, e, b, c, d, a)为节点分配寄存器(r1, r2, r3, r4)。
f入图,无邻居,分配r1。e入图,邻居f用r1,分配r2。b入图,邻居f(r1)、e(r2),分配r3。c入图,邻居f(r1)、e(r2)、b(r3),分配r4。d入图,邻居f(r1)、e(r2)、c(r4),分配r3。a入图,邻居b(r3)、c(r4),分配r2。
最终,我们使用4个寄存器完成了对6个节点的着色分配。
总结 ✨
本节课中,我们一起学习了寄存器分配中的图着色技术。我们首先明确了图着色与寄存器分配的对应关系,即颜色代表寄存器。然后,我们认识到精确的图着色是NP难问题,因此编译器采用启发式算法。

我们重点学习了一种基于“简化-着色”两阶段的经典启发式算法。该算法通过不断移除低度数的节点来简化图,然后逆序为节点分配颜色,从而高效地找到近似最优的寄存器分配方案。通过实例演示,我们看到了该算法如何在实际中运作。


课程 P83:寄存器分配中的溢出处理 🎨

在本节课中,我们将要学习当寄存器分配中的图着色算法失败时,如何通过“溢出”操作将临时变量存储到内存中,并修改程序代码以完成分配。
上一节我们介绍了图着色寄存器分配的基本启发式方法。本节中我们来看看当图无法成功着色时,我们该如何处理。


溢出操作概述 💡

图着色启发式算法并不总能成功为任意图着色。当算法陷入困境,无法找到着色方案时,意味着需要分配的临时变量数量超过了可用寄存器的容量。此时,我们必须将一些临时变量“溢出”到内存中。内存是我们除寄存器外唯一的其他存储位置。
图着色失败的情形 🔍

图着色启发式算法唯一无法进展的情况是:图中所有节点的邻居数量都大于或等于可用寄存器数量 k。

让我们通过一个熟悉的寄存器冲突图示例来说明。假设目标机器只有三个寄存器(k=3),我们需要为该图寻找三色着色方案。


应用启发式算法,我们尝试移除节点 a。然而,移除 a 及其边后,图中剩余的所有节点都至少有3个邻居。这意味着没有节点可以安全移除,以确保后续能找到着色方案,算法因此卡住。
选择并移除溢出候选节点 🎯
在这种情况下,我们需要选择一个节点作为“溢出”候选。这意味着该临时变量可能被分配到内存而非寄存器。有多种方法选择特定的溢出节点,为示例说明,我们假设选择节点 f。

我们将从图中移除节点 f,然后继续简化图。移除 f 后,图中出现邻居数少于3的节点(例如 b 和 d),简化过程得以继续,并最终成功找到着色顺序。
乐观着色尝试 ✨

在决定溢出 f 并成功为子图着色后,我们必须尝试为 f 本身分配颜色。有时我们可能很幸运:即使 f 有 k 个或更多邻居,但在子图着色后,这些邻居可能并未占用所有寄存器,从而有剩余寄存器可分配给 f。这称为“乐观着色”。

让我们将 f 加回图中,检查其邻居的着色情况。假设其邻居分别占用了寄存器 R1、R2、R3,即所有三个可用寄存器。在这种情况下,乐观着色失败,f 确实没有可用的寄存器。
实际溢出与代码修改 🔄
当乐观着色失败时,我们必须实际执行溢出操作。我们将为 f 在内存中(通常是当前堆栈帧)分配一个地址,记作 A。接着,我们需要修改控制流图(即正在编译的代码):
- 在每次读取
f的操作之前,插入一条加载指令,将f的当前值从地址A加载到一个新的临时变量中。 - 在每次写入
f的操作之后,插入一条存储指令,将f的当前值保存回地址A。
以下是修改代码的示例。原始代码中对 f 有多次引用:


修改后的代码为每次 f 的使用创建了新的临时名称(f1, f2, f3),并插入了相应的加载和存储指令:

重建活跃信息与干扰图 📊
修改代码后,程序不再使用原始变量 f,而是使用了新的临时变量 f1, f2, f3。因此,我们必须:
- 删除所有关于
f的旧活跃信息。 - 为
f1,f2,f3计算新的活跃信息。 - 基于新的活跃信息,重新构建寄存器干扰图。

这种修改带来了关键好处:
- 生命周期缩短:每个新的
fi只在其加载指令和下一次使用之间(或计算指令和存储指令之间)存活,生命周期大大减少。 - 干扰减少:由于生命周期缩短,每个
fi在图中冲突的邻居数量比原来的f要少。 - 解耦使用:将
f拆分为多个独立变量,避免了不同使用点之间不必要的干扰。
在新的干扰图中,f1, f2, f3 可能只与少数变量(如 a 和 c)冲突,从而使得新图能够用三种颜色成功着色。

如何选择溢出变量? 🤔
一次溢出可能不足以解决问题,我们可能需要溢出多个临时变量。选择溢出哪个变量是一个性能优化问题,任何选择都能产生正确代码。以下是常用的启发式方法:
以下是选择溢出候选变量的常见启发式方法:
- 溢出冲突最多的变量:移除该变量能最大程度地减少图中的边数,可能使图变得可着色。
- 溢出定义和使用较少的变量:这样的变量插入的加载/存储指令较少,性能开销相对较小。
- 避免溢出最内层循环使用的变量:向内层循环添加额外指令的代价很高,应优先选择溢出在循环外使用的变量。
总结与拓展 🏁

本节课中我们一起学习了寄存器分配中溢出处理的全过程。当图着色失败时,我们通过选择候选变量、尝试乐观着色、实际溢出并修改代码、最后重新计算活跃信息和干扰图来解决问题。溢出操作通过缩短变量生命周期和分离不同使用点来降低图的复杂度,使其最终可着色。
寄存器分配是现代编译器后端至关重要的一步,它使得中间代码可以自由使用大量临时变量,同时高效利用有限的物理寄存器资源,从而生成高性能的目标代码。
核心公式与概念总结:
- 溢出条件:当图中所有节点度数 >=
k(寄存器数量)时。 - 代码修改:对溢出变量
v,在每次使用前插入v_new = load [A],在每次定义后插入store v_new, [A]。 - 关键效果:溢出操作将长生命周期的变量
v拆分为多个短生命周期的v_i,从而减少寄存器干扰。

注:本节描述的算法主要针对RISC(精简指令集)架构。对于CISC(复杂指令集)架构,由于寄存器使用存在更多限制(如专用寄存器、不同尺寸等),需要在此基础上进行适配,但图着色的核心思想保持不变。

课程 P84:缓存管理 🧠

概述
在本节课中,我们将要学习计算机系统中一个至关重要的资源——缓存。我们将探讨现代计算机的内存层次结构,理解缓存未命中的高昂代价,并分析编译器在管理缓存方面的能力与局限。最后,我们将通过一个具体的代码示例,学习如何通过改变代码结构(如循环交换)来显著提升程序的缓存性能。
内存层次结构 🗂️
上一节我们提到了管理寄存器,本节中我们来看看另一项重要资源——缓存,以及编译器能做什么和不能做什么。
现代计算机系统拥有复杂的内存层次结构。若从离处理器最近的一层开始,会发现芯片上有若干寄存器。这些寄存器的访问速度极快,通常可以在单个时钟周期内完成访问。但问题是,建造如此高性能的内存非常昂贵,因此我们无法拥有很多寄存器。

通常,处理器上总共可用的寄存器容量可能在256字节到8千字节之间。现代处理器芯片的很大一部分面积将用于缓存。缓存性能也非常高,但不如寄存器。平均可能需要3个周期从缓存中获取数据,但你可以得到更多的缓存容量。
现代处理器最多可拥有1兆字节的缓存。离处理器更远的是主内存(DRAM),访问它需要更多时间,代价更高,典型值是20到100个周期。虽然访问慢,但你能得到很大的容量,例如32GB甚至更多。离处理器最远的是典型的硬盘,访问需要极长的时间,达数十万或数百万个周期,但可拥有海量存储,从GB到TB级别。
缓存的重要性与挑战 ⚡

正如前文所说,寄存器和缓存的大小与速度都有限制,这些限制受功耗等因素制约。因此,人们希望拥有尽可能多、尽可能快的寄存器和缓存,但在做大做快方面存在实际限制。
不幸的是,缓存未命中的代价非常高。如果数据能在几个周期内从缓存中获取,而它不在缓存中,则可能需要几个数量级更长的时间从主内存中取出。因此,人们尝试在处理器和主内存之间构建缓存,以隐藏主内存的延迟,使得大部分数据访问发生在缓存中。
如今,通常需要多级缓存才能很好地匹配快速处理器与庞大主内存之间的速度差异。现代处理器中通常有2级缓存,一些甚至拥有3级缓存。关键在于,为了获得高性能,正确管理这些资源(特别是寄存器和缓存)至关重要。

编译器在资源管理中的角色 🔧
若要程序性能好,编译器已变得非常擅长管理寄存器。实际上,今天大多数人会同意,对于几乎所有程序,编译器比程序员更擅长管理寄存器。因此,将分配寄存器的任务留给编译器是非常值得的。
然而,编译器并不擅长管理缓存。虽然编译器能做一点优化,但大部分情况下,如果程序员想要获得良好的缓存性能,他们需要了解机器上缓存的运行行为、了解程序正在做什么、了解编译器能做什么,然后仍需编写有利于缓存友好的程序。这仍是一个开放的研究问题,即编译器能在多大程度上提高缓存性能,尽管我们发现编译器确实能做几件事。
案例分析:糟糕的缓存性能 ❌
为了具体了解编译器能做到什么,让我们看看以下示例循环。这个循环的缓存性能极差。
for (j = 0; j < 10; j++) {
for (i = 0; i < N; i++) {
a[i] = a[i] + b[i];
}
}
在这个循环中,外层循环是 j,内层循环是 i。每次内层循环都会读取 b[i],进行计算,然后将结果存入 a[i]。
这个程序缓存性能会很差。假设缓存按内存块工作,在第一次迭代中,我们会加载 b[1] 并存储结果到 a[1],那么 a[1] 和 b[1] 会被加载到缓存中。在第二次迭代中,我们会加载 b[2] 并写入 a[2],那么 a[2] 和 b[2] 又会被加载到缓存中,以此类推。
重要的是,在循环的每次迭代中,我们引用的都是全新的数组元素。因此,几乎每一次对 a 和 b 的引用都会导致缓存缺失(假设数据项足够大,占满整个缓存行)。如果循环边界 N 非常大(远大于缓存容量),当我们执行到循环末尾时,整个缓存将被来自 a 和 b 后半部分的值填满。然后,当我们开始外层循环 j 的下一次迭代,并再次从 i=0 开始时,需要引用的 a[1]、b[1] 等数据已经不在缓存中了,又会导致缓存缺失。
这个循环的基本问题是,如果数据足够大,几乎每一个内存引用都是缓存缺失,程序将以主内存的访问速度运行,而不是缓存的速度。
优化方案:循环交换 ✅
现在,让我们考虑相同程序的另一种结构。我们交换内层和外层循环的顺序。
for (i = 0; i < N; i++) {
for (j = 0; j < 10; j++) {
a[i] = a[i] + b[i];
}
}

在这里,我们将 i 循环放在外层作为外循环,将 j 循环放在内层作为内循环。我们所做的是:加载 b[i] 并写入 a[i],然后在相同的 i 值上将这个计算重复十次。
这种结构将获得出色的缓存性能。对 a[i] 和 b[i] 的第一次引用将是缓存缺失,但随后的九次引用,数据已经位于缓存中。完成内层循环后,我们再继续处理下一个 i 值(即 a[i+1] 和 b[i+1])。
这种结构的优点是:它将特定数据项(a[i] 和 b[i])带入缓存,然后尽可能多地重复使用该数据,然后再处理下一个数据项。而不是像之前那样,对所有数据项都做一点操作,然后必须重新遍历所有数据项。
这种特定的优化称为循环交换。它计算完全相同的结果,但可能拥有超过10倍更好的缓存性能,从而运行得更快。编译器可以进行这种简单的循环交换优化,但通常很难自动判断在何种情况下交换循环顺序是合法的(即不改变程序语义)。因此,通常需要程序员自己识别并实施这种优化以提高性能。
总结
本节课中,我们一起学习了:
- 计算机系统的内存层次结构,以及缓存位于处理器和主内存之间,用于隐藏内存延迟。
- 缓存未命中的代价高昂,因此管理缓存对性能至关重要。
- 编译器擅长管理寄存器,但在管理缓存方面能力有限。
- 通过一个具体的双循环例子,我们分析了导致缓存性能低下的代码模式:在循环中频繁跳转访问不同的大数据项。
- 我们学习了循环交换这种优化技术,通过改变循环嵌套顺序,使程序访问模式更符合时间局部性原理(重复使用已加载到缓存的数据),从而大幅提升缓存命中率和程序运行速度。

理解缓存的工作原理并编写缓存友好的代码,是进行高性能编程的关键技能之一。

课程 P85:自动内存管理概述 🧠


在本节课中,我们将要学习自动内存管理,特别是垃圾回收的基本概念。我们将探讨手动管理内存带来的问题,理解自动内存管理的必要性,并学习如何定义和识别程序中的“可达”与“不可达”对象。
手动内存管理的问题
上一节我们介绍了本课程的主题。本节中,我们来看看手动管理内存会带来哪些具体问题。
手动管理内存意味着程序员需要负责所有内存的分配和释放。这在C和C++等语言中很常见。这种做法编程困难,容易导致难以消除的程序错误。
以下是手动管理内存时可能出现的几种主要存储错误:

- 内存泄漏:忘记释放不再使用的内存。
- 悬垂指针:释放内存后,仍有指针指向该无效内存区域。
- 数据覆盖:无意中覆盖了数据结构的一部分。
这些错误通常很难发现,因为它们的影响可能在时间和空间上都远离错误源头,并且经常在代码投入生产后很长时间才暴露出来。

自动内存管理的核心思想
了解了手动管理的问题后,我们来看看自动内存管理如何解决它们。自动内存管理的基本策略相当简单。
当一个对象被创建时(例如使用 new 关键字),运行时系统会自动找到未使用的内存空间并分配给该对象。

// 系统自动分配内存
Object* obj = new Object();
重复执行此操作,最终会耗尽可用空间。此时,系统需要回收部分空间以分配新对象。垃圾回收系统基于一个关键观察:部分被占用的空间可能存放着程序不再使用的对象。如果我们能找出这些对象,就可以回收并重用它们的内存。
如何判断对象不再被使用?
那么,核心问题来了:我们如何知道一个对象将不再被使用?目前大多数垃圾收集技术都基于以下观察:程序只能使用它能“找到”的对象。
让我们通过一段伪代码来理解:

let x = new A(); // 分配对象a,x指向它
x = y; // 将x指向y所指向的对象
执行这段代码后,最初创建的对象 a 变得不可达,因为程序中没有任何变量或数据结构指向它。既然程序无法找到它,未来也绝不可能再使用它,其占用的空间就可以被回收。
可达性的正式定义
实际上,我们需要一个比上例更广义的对象“可达性”定义。

对象x是可达的,当且仅当满足以下条件之一:
- 某个寄存器(包含局部变量等程序可直接访问的值)持有指向x的指针。
- 存在另一个可达对象y,且y包含指向x的指针。
这意味着,可达对象的集合是从所有寄存器(称为“根”)开始,递归地跟随所有指针所能触及的所有对象。这个集合的补集,即那些无法通过此过程触及的对象,就是“不可达”对象,也就是垃圾。
可达性是一种近似
值得注意的是,可达性是对“未来不再使用的对象”的一种近似判断。
如果一个对象不可达,它肯定不会再被使用。然而,仅仅因为一个对象是可达的,并不意味着它将来一定会被再次使用。有些可达对象可能在程序的后续执行中永远不会被访问,但由于编译器或运行时无法精确预知程序的所有执行路径,它们仍会被视为“存活”而无法被回收。
在Cool语言中的垃圾收集
现在让我们谈谈在Cool语言中如何进行垃圾收集。Cool语言的结构相对简单。
垃圾收集器需要从一组“根”开始跟踪所有可达对象。在Cool中,“根”包括:
- 累加器:通常指向当前操作的对象。
- 栈:每个栈帧中可能包含指向对象的指针(如方法参数)。

编译器必须为每种方法的激活记录(栈帧)布局保留信息,以便垃圾收集器在运行时能识别帧中的哪些位置存储的是对象指针,哪些是非指针数据(如返回地址)。
垃圾收集过程示例
在Cool中,垃圾收集的过程可以概括如下:
我们从累加器和栈指针这些“根”开始。遍历从这些根出发所有能通过指针链访问到的对象,并将它们标记为“可达”。

未被标记的对象,即使它们内部可能互相指向(例如,一个不可达对象指向另一个不可达对象),也属于“不可达”集合。这些对象的内存可以被安全地回收和再利用。
垃圾收集的基本步骤
以下是垃圾收集方案的基本步骤:
- 按需分配:为新对象分配空间。
- 持续分配:只要还有空闲空间,就继续分配。
- 触发回收:当空闲空间耗尽或根据特定策略需要时,触发垃圾回收。
- 标记可达:通过跟踪从根寄存器集合出发的所有指针,找出所有可达对象。
- 释放内存:释放那些不在可达集合中的对象(即不可达对象)所占用的内存。
有些策略会在空间完全耗尽之前就主动进行垃圾回收,我们将在后续视频中探讨其中之一。


总结
本节课中我们一起学习了自动内存管理的基础知识。我们了解了手动内存管理的弊端,认识了自动内存管理和垃圾回收的核心思想——通过判断对象的“可达性”来识别垃圾。我们学习了可达性的正式定义,并认识到它是对程序未来行为的一种安全近似。最后,我们概述了在Cool语言中实施垃圾收集的基本框架和步骤。在接下来的课程中,我们将深入探讨具体的垃圾回收算法。

课程 P86:垃圾收集技术(全)——标记清除算法 🧹

在本节课中,我们将要学习三种垃圾收集技术中的第一种:标记清除算法。我们将详细查看其工作原理、执行阶段以及实现时需要注意的细节。

概述 📋
标记清除算法是一种经典的自动内存管理技术,用于回收程序中不再使用的内存(即“垃圾”)。它主要分为两个阶段:标记阶段和清除阶段。该算法通过追踪所有可达对象来识别垃圾,然后回收这些垃圾对象占用的内存。
标记清除的两个阶段

上一节我们介绍了算法的整体概念,本节中我们来看看它的两个核心阶段是如何工作的。
1. 标记阶段 🎯

标记阶段的目标是找出堆中所有可达对象。为了支持这一点,每个对象都带有一个额外的标记位,该位专为垃圾收集器保留,程序本身不会使用。
在开始垃圾收集前,所有对象的标记位被初始化为 0。标记阶段会遍历所有从“根”(如寄存器中的指针)开始的可达对象,并将其标记位设置为 1。
以下是标记阶段基于工作列表的算法核心步骤:
- 初始化一个工作列表,其中包含所有根指针。
- 只要工作列表不为空,就重复以下步骤:
- 从列表中取出一个对象
v。 - 如果
v的标记位为0(未标记):- 将其标记位设置为
1。 - 找出
v内部的所有指针,并将这些指针指向的对象加入工作列表。
- 将其标记位设置为
- 如果
v已被标记(标记位为1),则直接丢弃,不做任何操作。
- 从列表中取出一个对象

当此阶段结束时,所有可达对象的标记位都将是 1,而不可达对象(垃圾)的标记位仍为 0。
2. 清除阶段 🧽
标记阶段完成后,我们知道了哪些对象是垃圾。清除阶段的任务就是回收这些垃圾对象占用的内存。

清除阶段会扫描整个堆,检查每个对象的标记位:
- 如果标记位为
1(可达对象),则将其标记位重置为0,为下一次垃圾收集做准备。 - 如果标记位为
0(垃圾对象),则将该对象占用的内存块添加到空闲列表中,以供未来分配新对象时使用。
以下是清除阶段的伪代码描述。其中 size_of(p) 函数用于获取指针 p 所指向对象的大小(这通常存储在对象的头部信息中)。
p = bottom_of_heap
while (p < top_of_heap)
if (mark_bit(p) == 1)
mark_bit(p) = 0 // 重置可达对象的标记位
else
add_block_to_freelist(p, size_of(p)) // 将垃圾块加入空闲列表
p = p + size_of(p) // 移动到堆中的下一个对象

算法示例与图解

为了更好地理解,我们来看一个简单的例子。假设堆中有对象 A、B、C、D、E,并且只有一个根指针指向对象 A。
- 初始状态:所有对象的标记位为
0。 - 标记阶段后:从根(A)开始遍历,可达对象 A、C、E 的标记位被设置为
1。对象 B 和 D 不可达,标记位仍为0。 - 清除阶段后:遍历堆,将标记位为
1的对象(A、C、E)重置为0。将标记位为0的对象(B、D)所占用的内存块加入空闲列表。
最终,空闲列表由这些回收的内存块组成,可以用于后续的内存分配。
实现细节与挑战
虽然标记清除算法概念清晰,但在实现时需要考虑一些关键问题。
标记阶段的存储问题
标记阶段需要使用一个工作列表来跟踪待处理的对象。然而,垃圾收集通常在内存已耗尽时触发,此时可能没有额外空间来分配这个列表。
解决方案:指针反转
一种巧妙的技巧是使用指针反转,在遍历对象图时,利用对象本身的空间来记录回溯路径,从而模拟深度优先搜索所需的栈,而无需分配额外内存。其核心思想是:当沿着指针访问一个对象时,临时修改该指针,使其指向上一个访问过的对象(父节点),从而在需要回溯时能找到回去的路。
内存碎片化与合并
标记清除算法的一个缺点是可能导致内存碎片化。因为从空闲列表分配内存时,我们选择一块足够大的内存,可能只使用其中一部分,剩余的小块会放回空闲列表。长此以往,空闲列表中会充满许多小碎片,可能无法满足较大对象的分配请求。

解决方案:合并空闲块
在清除阶段或维护空闲列表时,需要检查相邻的内存块是否都是空闲的。如果是,则将它们合并成一个更大的连续空闲块,以减少碎片。
标记清除算法的优缺点
优点 👍
- 对象不移动:在垃圾收集过程中,对象在内存中的位置保持不变。这意味着不需要更新指向这些对象的指针。
- 适用于暴露指针的语言:正因为对象不移动,该算法可以适配像 C 和 C++ 这类将指针地址语义暴露给程序员的语言,已有一些为这些语言实现的标记清除垃圾收集器变种。

缺点 👎
- 内存碎片化:如上所述,容易产生内存碎片,可能影响大对象的分配效率。
- 暂停时间:标记和清除需要遍历整个堆或大部分堆,在堆较大时可能导致程序出现明显的停顿。
- 标记阶段开销:需要遍历所有可达对象图,如果对象图非常复杂,开销较大。
总结 🎓
本节课中我们一起学习了标记清除垃圾收集算法。我们了解到该算法通过标记阶段识别所有存活对象,并在清除阶段回收垃圾对象的内存。我们探讨了其实现中关于工作列表存储(可通过指针反转解决)和内存碎片化(可通过合并空闲块缓解)的挑战。最后,我们分析了该算法对象不移动的主要优点及其带来的内存碎片化缺点。理解标记清除是学习更复杂垃圾收集算法(如复制收集、分代收集)的重要基础。

课程 P87:停止与复制垃圾回收技术 🗑️➡️🗂️

概述
在本节课中,我们将要学习第二种垃圾回收技术:停止与复制。我们将了解其工作原理、核心算法、优势与劣势,并通过一个具体的例子来理解其执行过程。
内存布局与分配策略

上一节我们介绍了标记-清除算法,本节中我们来看看停止与复制算法是如何组织内存的。
在停止与复制垃圾收集中,内存被划分为两个区域:
- 有一个旧空间用于分配。程序当前使用的所有数据都存放在称为旧空间的区域。
- 有一个新空间,为垃圾收集器保留。程序不使用这个空间,这是为GC保留的。

停止与复制垃圾收集中的第一个决定是:程序只能使用一半的空间。有一些更高级的停止垃圾收集技术允许程序使用超过一半的空间,但本质上,空间的一大部分必须为垃圾收集器保留。
分配方式是在旧空间中有一个堆指针。堆指针左侧的所有内容目前都在使用,这是所有已分配对象所在的区域。当需要分配新对象时,我们简单地将其分配在堆指针处。
因此堆指针将简单地向上移动,并为下一个要执行的对象分配一些块空间。它将不断穿过旧空间进行分配。分配只是推进堆指针。停止与复制的一个实际优势是简单快速的分配策略。
核心概念:
- 旧空间(From Space):
heap_pointer左侧为已使用区域。 - 新空间(To Space):保留给GC的空闲区域。
- 分配操作:
heap_pointer += object_size

垃圾回收过程
最终,如果我们一遍又一遍地分配,我们将填满旧空间,垃圾收集将开始。
GC将在旧空间满时开始。它将复制所有可达对象从旧空间到新空间。这个想法的美妙之处在于:当你复制可达对象时,垃圾被留下。所以你只需捡起你正在使用的所有数据,将其移动到新空间,而你不再需要的所有垃圾都留在旧空间。
在您将东西复制到新空间后,由于你留下了垃圾,收集后使用的空间比以前少了,新空间现在有空余。然后交换旧空间和新空间的角色(旧新空间反转,旧的变新,新的变旧),程序继续。

工作示例
看个快速例子,了解如何工作。假设这是旧空间,有一个根,是对象 a。
我们要做的是:复制从 a 可达的所有对象,并移到新空间。我们追踪一下:
- 从
a开始,跟随指针。 - 从
main看到指向c的指针,c可达。 - 然后指向
f的指针。 - 然后
f指向a。
所有可达对象被复制。复制时,也复制指针并调整它们指向新副本。我们不仅移动对象,也移动指针,并调整它们,所以真的将整个对象图复制到新空间。现在使用较少空间,这里有些空闲。这将成为新的旧空间,用于下次垃圾收集。

核心挑战:指针更新与遍历

停止复制的一个基本问题是确保找到所有可达对象。真正区分停止和复制的是我们将复制这些对象。因此,当我们找到一个可达对象时,我们将其复制到新空间。这意味着我们必须找到并修复所有指向该对象的指针。
正确地做到这一点并不明显,因为当你找到一个对象时,你看不到所有指向该对象的指针。我们如何做到这一点?当我们复制对象时,将存储旧版本,它被称为指向新复制的转发指针。
以下是具体步骤:
- 在旧空间中发现可访问对象
a。 - 在新空间制作它的副本。
- 我们将重用其旧空间,并将存储称为转发指针的东西。
- 首先我们将标记它以某种方式表明已被复制。
- 然后在对象的一个显眼位置,我们将存储转发指针(可以将其视为转发地址)。
若稍后有指针指向此对象,我们可能跟随此指针,找出此对象的点,意识到此对象已移动(因为我们已标记它),然后可用前向指针找出新对象位置,然后更新此指针,使其指向新对象。

算法实现:三区域划分与工作列表
与标记和清除类似,我们仍需解决如何实现对象图遍历,而无需使用任何额外空间。垃圾收集器需要在恒定空间工作。
以下是停止复制算法解决问题的核心想法:我们将新空间划分为三个连续区域。
- 空白区域:最右边,用于分配新复制的对象。有一个分配指针指向该区域的开始。
- 已复制未扫描区域:空白区域左侧。对象已被复制到新空间,但我们尚未查看其内部的指针。
- 已复制已扫描区域:最左边。这些是已被复制的对象,并且已处理所有对象内指针。
扫描指针与分配指针之间的区域(即“已复制未扫描区域”)就是我们的工作列表,包含了仍需处理其内部指针的对象。

核心概念:
- 新空间划分:
[已扫描 | 未扫描 | 空白] - 指针:
scan_ptr指向下一个待扫描对象,alloc_ptr指向空白区域起始处。 - 循环条件:当
scan_ptr < alloc_ptr时,工作列表非空。

逐步执行示例
现在我们将逐步讲解停止和复制垃圾收集器如何收集一个特定的堆。请注意,我们只有一个根对象 a,它指向对象 c。

以下是算法步骤:

第一步:复制根对象
我们将 a 对象复制到新空间。这是一个位对位复制。分配指针移动到 a 副本之后。a 副本中的指针仍指向旧空间中的对象 c。我们在旧 a 中留下一个转发指针,指向新 a 副本,并标记旧 a 为已移动。现在,“已复制未扫描”区域有一个对象 a。
第二步:扫描对象 a
我们处理对象 a,遍历其内部指针。发现它指向旧空间的 c。c 未被移动,因此我们复制 c 到新空间,更新 a 的指针指向新 c 副本。扫描指针移过 a,分配指针因分配 c 而移动。在旧 c 中设置转发指针。现在,“已复制未扫描”区域有对象 c。
第三步:扫描对象 c
扫描 c,发现其指向 f。f 尚未移动,因此将 f 复制到新空间,更新 c 的指针指向新 f 副本。扫描指针移过 c,分配指针移动。在旧 f 中设置转发指针。现在,“已复制未扫描”区域有对象 f。

第四步:扫描对象 f
扫描 f,发现其指向 a。a 已经被标记为已移动并有转发指针。因此,我们不复制新对象,仅更新 f 中的指针,使其指向 a 的新副本(通过转发指针找到)。扫描指针移过 f,分配指针未动。
此时,扫描指针与分配指针相等,工作列表为空。所有从根可达的对象图已被完整复制到新空间。

最后:交换新空间和旧空间的角色,程序恢复运行。
算法伪代码
以下是停止复制垃圾回收算法的伪代码概述:
while (scan_ptr < alloc_ptr) {
obj o = object_at(scan_ptr); // 获取当前待扫描对象
for (each pointer p in o) { // 遍历对象内的每个指针
obj o_prime = *p; // 获取指针指向的对象
if (!forwarding_pointer_exists(o_prime)) {
// 情况1:对象未被复制
new_copy = allocate_space_in_new_space(o_prime.size);
copy_bits(o_prime, new_copy);
set_forwarding_pointer(o_prime, new_copy); // 在旧对象设置转发指针
*p = new_copy; // 更新当前指针指向新副本
} else {
// 情况2:对象已被复制
*p = get_forwarding_pointer(o_prime); // 通过转发指针更新
}
}
scan_ptr += size_of(o); // 移动扫描指针到下一个对象
}
算法一直运行,直到扫描指针赶上分配指针。此外,我们还需要扫描和复制栈中指向的对象,并更新栈中的指针。
总结与比较
本节课中我们一起学习了停止与复制垃圾回收技术。
总结其特点:
- 优点:
- 分配速度快:只需移动堆指针:
heap_pointer += size。 - 收集效率高:成本只与存活对象的大小成正比,不接触垃圾。当垃圾较多时,效率远高于标记-清除。
- 避免碎片:通过紧凑复制,自然消除了内存碎片。
- 分配速度快:只需移动堆指针:
- 缺点:
- 内存利用率低:任何时候都只有一半内存可用于程序。
- 移动对象:在C/C++等语言中,对象地址是语义的一部分,因此无法使用此算法。
- 需要扫描栈:每次回收都必须扫描并更新栈上的指针,可能带来开销。

停止与复制及其变种被普遍认为是效率最高的垃圾回收技术之一,尤其适用于存活对象比例较小的场景。它通过空间换时间,以及专注于存活数据的策略,实现了高效的自动内存管理。
课程 P88:保守式垃圾收集 🧹

在本节课中,我们将要学习一种名为“保守式垃圾收集”的技术。这种技术使得在像C和C++这类缺乏精确类型信息的语言中,也能实现自动内存管理。我们将探讨其核心思想、工作原理以及局限性。

概述
自动内存管理(垃圾收集)依赖于两个关键能力:找到所有“可达”的对象,以及识别对象内部的所有指针。然而,在C和C++这类语言中,由于类型系统较弱,我们无法百分之百可靠地确定内存中哪些数据是指针。本节课将介绍如何通过“保守”的策略来解决这一难题。
核心挑战:指针识别难题
上一节我们介绍了垃圾收集的基本依赖条件,本节中我们来看看在C/C++中实现的具体挑战。
在C或C++中,当我们查看内存中的一块数据(例如两个连续的机器字)时,我们无法确切知道它们的含义。它可能是一个链表节点,其中一个字是数据(如整数),另一个字是指向下一个节点的指针。它也可能是一个二叉树节点,两个字都是指针。由于语言本身不存储运行时类型信息,垃圾收集器无法区分这些情况。
保守式收集的核心思想
既然无法精确识别指针,我们可以采用一个“保守”的策略。基本洞察是:如果我们不确定某个内存字是否是指针,我们就把它当作指针来处理。这基于一个原则:在垃圾收集中,多保留一些对象(即“保守”)比错误地释放一个未来可能被使用的对象要安全得多。

图可达性分析本身已经是一种保守近似(它保留所有可达对象,其中一些可能最终不会被使用)。保守式收集将这种“保守”态度进一步延伸到了指针识别阶段。
以下是保守式收集的基本工作逻辑:
- 扫描根集合(如寄存器、栈)和堆中的已标记对象。
- 对于扫描到的每一个机器字,判断它“是否可能是一个指针”。
- 如果它可能是一个指针,则将其当作真正的指针,并标记它所指向的内存块为“可达”。
- 重复此过程,直到没有新的内存块被标记。
- 最后,清扫所有未被标记的内存块并归还给系统。
如何判断“是否可能是指针”
那么,如何判断内存中的一个值是否“可能是指针”呢?通常依据以下条件:
- 对齐检查:指针通常指向特定对齐的地址(例如,在32位系统上指向4字节边界,64位系统上指向8字节边界)。因此,候选值必须是对齐的地址。
- 有效地址检查:将候选值解释为一个内存地址时,这个地址必须落在程序当前“有效”的内存段内(例如,堆、栈、全局数据区)。一个随机的整数(如
12345)很可能不满足这个条件。
通过这两个过滤器,大部分非指针数据(如小整数、字符等)会被排除。只有真正的指针和少数巧合满足条件的非指针数据会被收集器视为指针。
保守式收集的局限
上一节我们介绍了如何保守地识别指针,本节中我们来看看这种策略带来的主要限制。
由于收集器无法确定哪些是真正的指针,它也就不能移动内存中的对象。因为移动对象后,需要更新所有指向该对象的指针。如果收集器错误地将一个整数当作指针并修改了它,程序的行为将被彻底破坏。
因此,保守式垃圾收集通常只适用于 “标记-清除” 这类非移动式垃圾收集算法,而无法用于更高效的 “复制” 或 “标记-整理” 算法。
其核心行为可以用以下伪代码描述:
// 保守地标记从某个值开始的可达对象
void conservative_mark(word potential_ptr) {
if (looks_like_pointer(potential_ptr)) { // 检查对齐和有效地址
object* obj = (object*)potential_ptr;
if (is_in_heap(obj) && !is_marked(obj)) {
mark(obj);
// 递归扫描该对象内部的每一个字
for (each word w in obj) {
conservative_mark(w);
}
}
}
}
// 主收集过程
void conservative_gc() {
// 1. 从根(寄存器、栈等)开始保守标记
for (each word w in roots) {
conservative_mark(w);
}
// 2. 清扫未标记的对象
sweep();
}
总结

本节课中我们一起学习了保守式垃圾收集技术。我们了解到,为了在C/C++等语言中实现垃圾收集,可以通过“保守”策略将任何看起来像指针的内存字都视为指针,从而安全地高估可达对象集。这种方法的代价是可能保留一些已死亡对象(内存碎片化),并且无法使用需要移动对象的压缩算法。尽管如此,它仍是使手动内存管理语言获得自动内存管理能力的一种实用方案。


课程 P89:引用计数垃圾回收 🧮

在本节课中,我们将结束关于自动内存管理的讨论,学习第三种也是最后一种垃圾回收技术——引用计数。
概述 📋

引用计数是一种自动内存管理技术,其核心思想是跟踪每个对象被引用的次数。一旦指向某个对象的指针数量降为零,该对象就可以被立即回收。这种方法试图在内存耗尽前就进行垃圾回收,而不是等待系统内存不足。
引用计数的基本思想 💡
上一节我们介绍了标记-清除和停止-复制两种垃圾回收技术,它们都需要在特定时刻暂停程序进行全局扫描。本节中我们来看看引用计数,它采用了一种完全不同的增量式策略。

引用计数的基本思想是:我们不去等待内存完全耗尽,而是尝试在对象变得不可达时立即收集它。具体来说,一旦我们丢弃了最后一个指向该对象的指针,该对象就变得不可达,我们将在那时尝试回收它。
我们如何做到这一点呢?正如其名称所示,我们将计算每个对象的被引用次数。在每个对象中,我们会存储一个专门的字段,用于记录指向该对象的指针数量。这意味着每次赋值操作都必须更新引用计数,以保持指向对象指针数量的准确计数。
引用计数的操作机制 ⚙️
以下是引用计数在程序执行过程中的具体操作步骤:
-
创建对象:当使用
new分配一个新对象时,该对象的引用计数初始化为1。返回的指针是该对象的唯一引用。- 公式/代码表示:
ref_count(new Object()) = 1
- 公式/代码表示:
-
赋值操作:当执行赋值语句
x = y时,需要更新相关对象的引用计数。- 步骤:
a. 增加y所指向对象(记为P)的引用计数。
b. 减少x原来所指向对象(记为O)的引用计数。
c. 检查O的引用计数是否降为0。如果是,则释放O的内存。
d. 最后,执行实际的指针赋值,使x指向P。
- 步骤:
因此,程序中的每一个赋值操作,现在都转化为需要执行的四个操作,以维护引用计数的正确性。
引用计数的优缺点 ⚖️
优点
引用计数有其独特的优势,主要体现在以下方面:

- 增量式垃圾回收:引用计数在执行时没有长时间的全局暂停。它总是以小增量的方式回收垃圾,这对于实时应用或交互式应用非常有利,因为它最小化了程序的最长暂停时间。
- 实现相对简单:基本的引用计数实现相当容易。可以想象一个代码生成器,它会为支持引用计数的语言生成包含引用计数更新操作的代码。对编译器所需的修改并不十分复杂。
缺点
然而,引用计数也存在一些明显的缺点:
- 性能开销大:每次赋值都需要更新两个引用计数、检查引用计数是否为零,然后才执行赋值本身。这会将每个赋值操作的成本膨胀数倍,对程序性能产生显著影响。
- 优化可能:智能的编译器可以尝试合并对同一对象的多次引用计数更新,但这实现起来非常棘手。一个高度优化的实现会比简单实现快,但仍有明显性能影响,且难以正确实现。
- 无法回收循环引用:这是引用计数最著名的缺陷。它无法直接回收形成循环引用但整体不可达的对象组。
- 示例:对象A引用B,B引用A,且外部再无引用指向它们。此时A和B的引用计数均为1,但实际都已不可达。引用计数器无法发现并回收它们。
- 解决方案:
- 程序员手动打破循环(在丢弃外部引用前,将循环内的某个引用置为
null)。 - 结合其他垃圾回收技术(如定期运行标记-清除收集器)来清理循环结构。
- 程序员手动打破循环(在丢弃外部引用前,将循环内的某个引用置为
关于自动内存管理的高级观点 🧠
我们现在准备结束关于自动内存管理的讨论。这里有一些高级别的观点:
- 自动内存管理是好事:它防止了严重的存储错误(如内存泄漏、悬垂指针),是编程语言的一大进步。如果你的应用适合,使用支持垃圾回收的语言是高效的选择。
- 代价是控制权的减少:程序员不再能精确控制内存布局、释放时机和数据位置。对内存使用量的控制也变得有限。
- 适用场景:
- 适合:非极度数据密集型、对内存布局不敏感的应用。
- 不适合:需要高效使用内存的高端数据处理和科学计算应用;有严格实时性要求、不能容忍任意长度暂停的嵌入式或实时系统。
- 垃圾回收语言中的“内存泄漏”:自动内存管理防止了内存损坏,但无法防止逻辑上的内存泄漏——即保留了不再需要但仍有指针指向的数据。在生产程序中,忘记将指向大型数据结构的变量置为
null是常见的内存泄漏原因。

总结与展望 🚀
本节课中我们一起学习了引用计数垃圾回收技术。我们了解了它通过跟踪每个对象的引用数来增量回收垃圾的基本原理,分析了其实现简单、无长暂停的优点,也探讨了性能开销大和无法处理循环引用的核心缺点。
如前几课所述,垃圾回收非常重要,每个程序员都应了解其优缺点。它也是编程语言实现中一个有趣且不断发展的领域。除了我们讨论的这三种基本算法,还有更先进的垃圾回收技术,例如:
- 并发垃圾回收:允许程序在垃圾收集时继续运行。
- 分代垃圾回收:基于“大多数对象生命周期很短”的观察,将对象按年龄分代,更频繁地收集年轻代对象。
- 实时垃圾回收:尝试限制垃圾回收导致的最长暂停时间。
- 并行垃圾回收:使用多个垃圾收集器线程同时工作。

理解这些基础知识,将帮助你更好地选择和使用编程语言,并理解其运行时行为。

课程 P9:正规语言与正则表达式入门 🧩


在本节课中,我们将要学习正规语言,它是用于定义编程语言词法结构(即各种“标记”或“Token”类别)的数学工具。我们将重点介绍如何使用正则表达式来描述正规语言,并通过简单的例子来理解其核心概念。

概述:什么是正规语言? 📖
编程语言的词法结构由一组标记类组成,每个标记类都包含一系列特定的字符串。为了精确地指定每个标记类包含哪些字符串,我们通常使用正规语言。本课程将介绍正规语言的基本概念及其表示方法——正则表达式。

正则表达式的基本构成 🧱
正则表达式是一种用于描述字符串集合(即语言)的语法。每个正则表达式都对应一个语言(即一组字符串)。其定义基于一个给定的字母表(例如,包含字符 0 和 1 的集合)。
正则表达式由两种基本形式和三种复合形式构成。
两种基本正则表达式
以下是定义正则表达式的两种最基本形式:

-
单个字符
c:表示仅包含一个字符串的语言,该字符串就是字符c本身。- 公式:
L(c) = { “c” }
- 公式:
-
空字符串
ε:表示仅包含一个字符串的语言,该字符串是空字符串(即长度为0的字符串)。请注意,ε表示包含空字符串的语言,而非空语言(即不包含任何字符串的集合)。- 公式:
L(ε) = { “” }
- 公式:
三种复合正则表达式
我们可以通过以下三种操作,从已有的正则表达式构建出新的、更复杂的正则表达式。
- 并集
A + B:表示语言A和语言B中所有字符串的合集。- 公式:
L(A + B) = L(A) ∪ L(B) = { s | s ∈ L(A) 或 s ∈ L(B) }
- 公式:

-
连接
A B:表示从语言A中任取一个字符串,从语言B中任取一个字符串,将它们前后拼接起来所形成的所有可能字符串的集合。- 公式:
L(A B) = { a b | a ∈ L(A) 且 b ∈ L(B) }
- 公式:
-
克林闭包
A*:表示语言A中的字符串可以重复连接任意次(包括零次)所生成的所有字符串的集合。零次连接得到空字符串ε。- 公式:
L(A*) = ∪_{i≥0} L(A)^i,其中L(A)^0 = {ε},L(A)^i表示L(A)连接自身i次。
- 公式:
正则表达式的形式化定义 📝
综合以上内容,我们可以给出正则表达式在给定字母表 Σ 上的形式化定义(语法):
- 正则表达式
R是以下五种情况之一:ε(空字符串)c,其中c ∈ Σ(单个字符)(R1 + R2),其中R1和R2是正则表达式 (并集)(R1 R2),其中R1和R2是正则表达式 (连接)(R*),其中R是正则表达式 (克林闭包)
这个定义清晰地描述了所有合法正则表达式的构成规则。
实例解析:构建与理解正规语言 🔍

上一节我们介绍了正则表达式的构成规则,本节中我们来看看如何应用这些规则来构建具体的正规语言。我们以字母表 Σ = {0, 1} 为例。
以下是几个构建正规语言的例子及其含义解析:
例1:1*
这个表达式表示所有由字符 1 组成的字符串(包括空字符串)。
- 解析:根据克林闭包的定义,
1* = ε + 1 + 11 + 111 + ... - 对应的语言:
{ “”, “1”, “11”, “111”, … }
例2:(1+0) 1
这个表达式表示两个字符串的集合。
- 解析:
(1+0)表示集合{“1”, “0”},与1(即{“1”})连接。根据连接规则,我们取前者的每个字符串与后者的每个字符串拼接。 - 对应的语言:
{ “1”+“1”, “0”+“1” } = { “11”, “01” } - 另一种写法:这个语言也可以直接写成
11 + 01。
例3:0* + 1*
这个表达式表示所有全由 0 组成的字符串与所有全由 1 组成的字符串的并集。
- 解析:
0*是{ “”, “0”, “00”, … },1*是{ “”, “1”, “11”, … },取它们的并集。 - 对应的语言:
{ 所有全0串 } ∪ { 所有全1串 }
例4:(0+1)*
这是最重要的例子之一,它表示字母表上所有可能的字符串。
- 解析:
(0+1)表示{“0”, “1”}。其克林闭包意味着,我们可以从{“0”, “1”}中任意选择字符,重复任意次来组成字符串。 - 对应的语言:所有由
0和1构成的字符串,如“”, “0”, “1”, “00”, “01”, “10”, “11”, “000”, …。 - 特殊记法:这个语言通常记为
Σ*。
重要提示:同一个正规语言可以用多个不同的正则表达式来描述。例如,1* 也可以写成 (1* + 1),因为增加的 1 并没有改变原有的集合。
总结 🎯
本节课中我们一起学习了正规语言及其描述工具——正则表达式。
- 正则表达式是一种语法,是我们写下来用于表示一个字符串集合(即正规语言)的表达式。
- 正则表达式有五种核心构成部分:
- 基本表达式:
ε(空字符串)和单个字符c。 - 复合表达式:并集 (
+)、连接(直接拼接)、克林闭包 (*)。
- 基本表达式:
- 正则表达式是定义编程语言词法结构(如标识符、关键字、数字常量等标记类)的基础。

理解正则表达式是理解编译器如何识别源代码中基本元素的第一步。

课程 P90:Java 语言概览与历史 🚀

在本节课中,我们将学习 Java 语言的核心概念、历史背景及其在编程语言发展中的地位。我们将看到 Java 如何从一个特定项目演变为互联网时代的关键语言,并了解其设计背后的主要思想。

Java 的历史起源 📜
上一节我们介绍了本课程的目标,本节中我们来看看 Java 语言的起源。
Java 最初是 Sun Microsystems 公司内部一个名为 “Oak” 的项目。该项目始于 20 世纪 90 年代初,其最初目标是开发用于机顶盒设备的软件。这种设备是一个连接在电视机上的小盒子,旨在控制有线电视节目并增强电视的互动性。Oak 项目的初步开发持续了数年,大约从 1991 年运行到 1994 年。

然而,机顶盒市场在当时并未真正兴起,消费者接受度有限,因此 Oak 项目的发展潜力受到了限制。随后,互联网革命在 90 年代初加速发展,为编程语言带来了新的需求和机遇。
互联网时代的机遇与挑战 🌐
上一节我们了解了 Java 的早期目标,本节中我们来看看它如何抓住了互联网的机遇。
在 1993 至 1994 年间,互联网的普及使得人们对安全性和代码可移植性的需求变得尤为突出。人们不希望从网上下载由 C/C++ 等语言编写的、可能不可信或不安全的二进制程序。这催生了对一种更安全、更适合网络环境的新编程语言的需求。
当时有几个候选语言竞争成为“互联网编程语言”,除了 Java,还有 Tickle 和 Python 等。最终,凭借 Sun Microsystems 公司的强力支持,Java 抓住了这个机会,成为了互联网编程的主流语言之一。每一种新语言都需要一个“杀手级应用”来推动其普及,而 Java 的安全性、垃圾回收机制和类型系统恰好满足了当时互联网编程的兴起需求,从而变得非常流行。
Java 的设计影响与特性 🧩
上一节我们看到了 Java 如何顺应时代,本节中我们来看看它的设计哲学和核心特性。
Java 的设计深受其时代背景和前辈语言的影响。新语言常常大量借鉴前代语言的思想,并在其基础上进行创新或重新设计。Java 的类型系统和其对类型的承诺,旨在帮助构建可扩展的大型系统。其面向对象的特性借鉴了如 Objective-C、C++ 和 Eiffel 等语言。
Java 的一个显著特征是引入了 接口(Interface) 的概念。此外,Java 也是一门相当动态的语言,许多操作(如反射)是在运行时而非编译时完成的。

以下是 Java 受到主要影响的几个方面:
- 类型系统:旨在构建强类型的大型系统。
- 面向对象:源于 C++、Objective-C 等语言。
- 接口:一个显著的语言特征。
- 动态特性:支持反射等运行时特性。
后续内容预告 📋
如开头所说,本视频只是一个介绍和概览。在接下来的几个视频中,我们将深入研究 Java 的特定功能及其工作原理。
我们将要探讨的特性包括:
- 异常处理
- 接口
- 线程

需要明白的是,Java 是一门非常庞大的语言,其语言手册长达数百页,功能众多。设计语言的难点之一在于确保所有功能能够正确交互,处理好所有特性的组合。
总结 ✨

本节课中,我们一起学习了 Java 语言的历史背景和设计概览。我们了解到 Java 从 Oak 项目起步,最初面向机顶盒设备,随后抓住了互联网革命的机遇,凭借其安全性和适合网络环境的特性得以广泛流行。它的设计融合了多种前辈语言的优点,并引入了接口等关键概念。Java 的成功印证了编程语言的发展往往与特定的技术需求和时代机遇紧密相连。在接下来的课程中,我们将深入探讨其具体的语言特性。

课程 P91:Java 数组与子类型 🧩

在本节课中,我们将要学习 Java 中数组与子类型的关系。我们将探讨一个看似合理但会导致运行时错误的场景,并分析其背后的原因。通过理解 Java 数组的子类型规则及其运行时检查机制,你将能更好地编写安全的代码。
一个数组别名的例子 🔍
上一节我们介绍了课程主题,本节中我们来看看一个具体的代码示例。假设有两个类 A 和 B,且 B 是 A 的子类。考虑执行以下代码会发生什么。
B[] b = new B[10]; // 创建一个用于存储 B 类型对象的数组
A[] a = b; // 变量 a 也指向与 b 相同的数组
a[0] = new A(); // 将一个新 A 对象赋值给 a[0]
b[0].someMethod(); // 通过 b[0] 调用 B 类中声明但 A 类中未声明的方法
首先,分配一个 B 类型的数组。有一个数组变量 b 指向它。然后有一个变量 a,也指向与 b 相同的数组。注意 a 的类型是 A[],而 b 的类型是 B[]。
现在执行赋值操作 a[0] = new A();。这看起来没问题,因为 a 是一个 A 类型的数组。第一个位置将存储一个 A 对象。

然后访问 b[0]。因为 a 和 b 指向相同的数组,b[0] 与 a[0] 是同一个对象。代码试图调用一个在 B 中声明但在 A 中未声明的方法。由于这是一个 B 类型的数组,理论上应该能调用所有 B 的方法。
但当我们调用该方法时,将出现运行时错误。因为数组中实际存储的对象是一个 A 对象,它并不具备 B 类独有的方法。
Java 数组的子类型规则 📐
要理解这个例子,需要查看 Java 的子类型规则。在 Java 中,如果 B 继承自 A,那么 B 是 A 的子类。这是标准的面向对象继承关系。

类型子类型是传递的。如果 C 是 B 的子类,B 是 A 的子类,那么 C 也是 A 的子类。
但 Java 还有另一个非标准的规则:如果 B 是 A 的子类型,那么数组类型 B[] 也是数组类型 A[] 的子类型。
用公式表示即:
若 B <: A,则 B[] <: A[]
这个规则在其他许多有对象和子类型的语言中并不常见,也是导致上述问题的根源。
别名与类型安全 ⚠️
让我们以另一种方式解释这个问题。我们有一块可读写的内存(数组),有两个指针 a 和 b 指向它。它们都可以读写这部分内存。
当两个程序名称指向同一部分内存时,这叫做别名。别名在真实程序中很常见,本身并不坏。
但在这个例子中,a 和 b 有不同的类型。一般来说,如果存在可更新引用的别名,并且这两个名字类型不同,那么类型系统就可能不安全。
问题在于:因为 B 是 A 的子类型,我们可以通过 A[] 类型的指针 a 将一个 A 对象写入内存位置。然后,我们又可以通过 B[] 类型的指针 b 将其作为 B 对象读取,并尝试调用 B 独有的方法,从而导致错误。
即使我们交换 A 和 B 的角色(假设 A 是 B 的子类型),由于别名是对称的,也会出现同样的问题。总的来说,多个不同类型的可更新位置的别名是不安全的。

解决方案对比:静态检查 vs 运行时检查 🛡️
这个问题在许多编程语言中都出现过。编程语言研究社区最广泛接受的解决方案是在类型级别上制定不同的规则。
以下是解决此问题的标准方案:
- 只允许在数组元素类型完全相同的情况下,数组之间才有子类型关系。
- 即,
B[]是A[]的子类型,仅当B等于A。 - 用公式表示即:
B[] <: A[]仅当B = A。
这样,我们就无法创建两个指向同一可更新内存位置但元素类型不同的数组引用,从而在编译时确保类型安全。

然而,Java 选择了不同的修复方式。Java 不是在编译时静态检查,而是在运行时进行检查。
在 Java 中,每当向数组中执行赋值操作时,虚拟机会检查被赋值的对象的运行时类型是否与数组声明的元素类型兼容。
例如,对于 B[] arr = new B[10];,每当执行 arr[i] = someObj; 时,Java 会检查 someObj 是否是 B 类型或其子类型。如果不是,则会抛出 ArrayStoreException。
这会给数组的赋值操作带来运行时开销。幸运的是,最常见的数组(如 int[]、float[] 等原始类型数组)不受影响,因为原始类型没有子类型关系,因此不需要这些额外的检查。
总结 📝
本节课中我们一起学习了 Java 数组与子类型的交互。
- 我们看到了一个由于数组别名和 Java 特殊的数组协变规则(
B[] <: A[]若B <: A)而可能引发运行时错误的例子。 - 我们分析了问题的核心在于不同类型对同一可更新内存位置的别名访问会破坏类型安全。
- 我们探讨了两种解决方案:一种是在编译时禁止数组协变(许多语言采用),另一种是 Java 采用的运行时类型检查,这会在向对象数组赋值时带来开销,但保证了兼容性。

理解这些机制有助于你更深入地认识 Java 类型系统的设计权衡,并在编程时避免此类陷阱。


课程 P92:Java 异常处理 🚨
在本节课中,我们将要学习 Java 中的异常处理机制。异常是一种用于处理程序中意外错误情况的语言特性。我们将了解如何抛出和捕获异常,其背后的工作原理,以及 Java 异常设计的一些独特之处。

程序员定义的异常
上一节我们介绍了课程概述,本节中我们来看看程序员如何定义和使用异常。
考虑以下典型编程场景:在深入复杂的代码部分时,可能会遇到出现意外错误的地方。例如,可能发现内存不足,或者数据结构不满足某些要求(如一个本应排序的列表并未排序)。
问题在于如何处理这些错误。目标是编写能够优雅处理错误的代码,而不是让程序变得混乱。

异常处理方案
许多语言(包括 Java)的流行解决方案是在语言中添加一种新的值类型,称为异常,并配备相应的控制结构来处理它。

以下是 Java 中最核心的两个结构:
- 抛出异常:在检测到错误的地方创建并抛出异常对象。
- 捕获异常:使用
try-catch结构来捕获并处理异常。
其基本思想是:异常在实际检测到错误的地方(可能在代码深处)被抛出,但这通常不是处理它的好地方。通过抛出异常,可以退出当前代码块,让异常向上传播到更高层次的、能够进行清理和处理的代码位置(即 catch 块)。
一个简单的例子
以下是使用异常的一个小例子:
public class Main {
public static void main(String[] args) {
try {
x(); // 调用可能抛出异常的方法
} catch (Exception e) {
System.out.println("出现错误"); // 捕获并处理异常
}
}
static void x() throws Exception {
throw new Exception(); // 抛出一个异常对象
}
}
在这个例子中,x 方法抛出一个异常。当 main 方法调用 x 时,异常被抛出,导致 x 异常终止。随后,控制权返回到 main 方法中的 catch 块,执行清理代码(这里只是打印一条消息)。
异常的操作语义

为了更清晰地理解 try-catch 和 throw 的工作方式,我们可以借助操作语义来描述。
首先,需要区分异常对象的两种状态:
- 普通值:刚创建的异常对象,行为与任何其他对象无异。
- 已抛出的异常:被
throw语句抛出的异常,是一种特殊状态的值。
try-catch 的规则如下:
- 如果
try块中的表达式e1评估为一个普通值V,那么整个try-catch表达式的结果就是V。 - 如果
e1评估为一个已抛出的异常t,那么我们会解开这个异常,将其内部的值绑定到catch子句中指定的变量名,然后评估清理代码e2。e2的结果就是整个try-catch表达式的结果。
throw 的规则非常简单:
throw e 会先评估表达式 e 得到一个值 V,然后将其标记为已抛出的异常 t。

异常传播规则:
对于语言中的其他结构(如加法 e1 + e2、if 语句等),如果其子表达式之一评估为已抛出的异常,则立即停止当前表达式的进一步评估,并将该异常作为整个表达式的结果向外传播。唯一能阻止异常传播的结构就是 try-catch。
异常的常见实现方式
一种简单的实现异常处理的方法是使用栈标记。
当遇到 try 表达式时,在调用栈中标记当前位置。然后继续执行 try 块内的代码。当 throw 异常发生时,系统会展开(弹出)堆栈,直到找到最近的一个 try 标记,然后跳转到对应的 catch 块开始执行。
这种设计的缺点是,即使没有异常抛出,执行 try-catch 块也需要付出标记栈的成本。更复杂的技术会尝试降低 try 的成本(因为异常相对罕见),但这可能会让 throw 的代价稍高一些。

Java 中的特殊问题:终结器中的异常
Java 允许对象拥有 finalize 方法(终结器),该方法在对象被垃圾回收器回收之前调用,常用于释放资源(如关闭文件句柄)。
如果终结器方法抛出了异常,会发生什么?由于终结器是由垃圾回收器在不可预测的时间调用的,没有明确的上下文来处理这个异常。答案是:如果终结器中的异常未被自身捕获,它将被静默丢弃。这是编写终结器代码时需要特别注意的一点。
Java 的创新:受检异常

Java 的一个创新是受检异常。异常类型是方法接口的一部分,编译器会进行检查。
在方法声明中,可以使用 throws 关键字列出该方法可能抛出的异常。调用该方法的代码必须处理(捕获或继续声明抛出)这些异常,否则编译器会报错。
为什么这样做?在早期的 Java 项目中,程序员常常忽略处理可能抛出的异常。通过编译器强制检查,促使程序员编写更健壮的代码,因为他们能清楚地看到必须处理哪些错误情况。
当然,也有一些例外(如 NullPointerException, ArithmeticException),它们属于运行时异常,不强制在方法签名中声明,因为静态检查它们是否会发生非常困难。
总结

本节课中我们一起学习了 Java 的异常处理机制。我们了解了如何使用 throw 和 try-catch 来抛出和处理异常,探讨了其背后的操作语义和常见实现原理。我们还特别讨论了 Java 中终结器异常的处理方式,以及其独特的受检异常特性,这一特性有助于在编译期发现潜在的错误处理漏洞,提升代码的健壮性。

课程 P93:Java 接口详解 🧩

在本节课中,我们将要学习 Java 编程语言中的一个核心概念——接口。我们将了解接口是什么,它与继承有何不同,以及如何在程序中有效地使用它。
什么是接口? 🤔
接口定义了类之间的关系,但它不使用继承。这是一个例子:我们有一个名为 Point 的接口。Point 接口可以包含许多方法,我们只声明这些方法的签名。接口中也可以有其他东西,但通常它们主要用于声明方法。

这是一个特定方法的示例:move 方法。它接受参数并有特定的返回类型。任何将实现 Point 接口的类,都必须提供具有相同签名的方法。因此,因为 Point 接口有 move 方法,Point 类也必须有 move 方法,并且其签名必须与接口中声明的完全一致。如果 Point 接口还有其他方法,那么 Point 类也需要实现那些同名、带适当参数和结果类型的方法。
接口的作用:模拟多重继承 🔄
上一节我们介绍了接口的基本定义,本节中我们来看看接口的核心作用。
Java 语言手册指出,Java 程序可使用接口,使相关类无需共享抽象超类,或为对象添加方法。接口在 Java 中起到了类似 C++ 中多重继承的作用。原因是,一个类可以实现多个接口。
例如,若我有一个类 X 实现了三个接口 A、B 和 C,这意味着 X 对象在适当的上下文中可被视为 A 对象、B 对象或 C 对象。这几乎就像 X 有三个父类 A、B 和 C。虽然存在一些重要区别,但效果类似。若要让一个类具备多种功能,或实现多个接口的功能,在 Java 中可以直接声明该类实现所有这些接口。
以下是接口的一个应用示例:考虑大学里的研究生。研究生通常是学生,他们上课,具有学生属性,获得学位和成绩等。同时,研究生通常也为大学工作,他们常是课堂助教或研究助理,因此他们还有另一个角色,即大学雇员。
如果在人事管理软件中,我们已经实现了处理学生的功能和处理员工的功能,那么在设计研究生类时,我们希望复用这些功能。一种方法是让研究生类同时实现 Employee 接口和 Student 接口。这样,研究生就既是员工也是学生。
在单继承体系中,如果有一个 Employee 类和一个 Student 类,你只能选择其中一个让 GraduateStudent 类去继承,从而无法同时获得两者的功能。接口的优势在于,它允许一个类表达与多种不同事物的功能关系,从而实现多重功能。
接口与继承的区别 ⚖️

可能最大的区别是,实现接口不如继承高效。这就是为什么两者并存,并且如果可以,你倾向于使用继承,因为它通常比接口更高效。
接口效率较低的主要原因是什么?主要原因是,实现接口的类不必将接口中的方法分配到对象内存布局的固定偏移量中。
让我们看一个例子。这是我们的 Point 接口。假设我们有一个 Point 类实现了 Point 接口,并实现了 move 方法。同时,我们有另一个类也实现了 Point 接口,但还实现了其他不属于该接口的功能。
那么,如何决定 move 方法在这两个类中的内存位置呢?如果我们采用类似 C++ 的简单实现方式(方法按声明顺序排列),move 方法可能不在这些类的第一个位置。我们可以想象一个编译器优化过程,让 Point 接口的所有方法在实现它的任何类中都位于固定位置。但只要我们实现多个接口,这个方法就不起作用了。
假设 Point2 类还实现了另一个接口 A。如果我们已经决定对于 Point 接口,move 方法应该首先出现;同时对于 A 接口,其某个方法也应该首先出现,这就会产生冲突。通常,没有一个完美的排序方式能为所有方法和接口安排位置,使得它们在实现这些接口的所有类中都能保持一致,尤其是在不预先知道所有未来声明的类和接口的情况下。Java 的设计允许未来扩展,因此接口中的方法不在类中拥有固定的内存偏移量。
接口是如何实现的? 🛠️
既然接口方法没有固定偏移量,那么如何实现接口方法的调用分发呢?这比普通的方法调用更复杂。
假设变量 e 具有某种接口类型,我们在调用该接口的 f 方法。一种可行但效率较低的方法是:每个实现接口的类,都会有一个关联的查找表,将方法名(字符串)映射到具体的方法实现。我们可以对方法名进行哈希以加快查找速度(实际上哈希值可以在编译时计算)。

具体思路是,每个对象内部有一个分发指针,它指向该类常规方法表。在方法表的末尾,可能还有另一个指针,指向一个专门的查找表。这个查找表将接口方法名称映射到具体的方法代码。
因此,与每个类的每个对象相关联,我们都有这样一个查找表,用于映射接口方法名称到其实现。
总结 📝
本节课中我们一起学习了 Java 接口。我们了解到接口是一种定义类之间关系、但不使用继承的机制。它通过声明方法签名来约定行为,任何实现该接口的类都必须提供这些方法的具体实现。

接口的核心作用在于模拟多重继承,允许一个类具备多种角色或功能,这是单继承体系难以做到的。然而,这种灵活性带来了一定的性能开销,因为接口方法的调用分发机制比普通的继承方法调用更复杂,通常需要通过额外的查找表来实现。
通过理解接口的定义、作用、与继承的区别以及其底层实现原理,我们可以更明智地在 Java 程序设计中运用接口,在代码的灵活性与执行效率之间做出合适的权衡。

课程 P94:Java 中的类型强制转换 🧩
在本节课中,我们将要学习编程语言类型系统中的一个重要概念——强制转换。我们将以 Java 语言为例,探讨编译器如何自动或根据指令在不同类型之间进行转换,以及这种机制带来的便利与潜在问题。

什么是强制转换?🤔
上一节我们介绍了课程的主题。本节中,我们来看看强制转换的基本定义。
许多编程语言都具备强制转换的特性。在 Java 中,它允许在某些上下文中将一种原始类型转换为另一种类型。
考虑表达式 1 + 2.0。这里的困难在于,1 是整数(int),而 2.0 是浮点数(float 或 double)。不同类型的数据无法直接进行加法运算。
在执行操作前,必须将它们转换为统一的表示。通常的做法(也是 Java 的做法)是将整数转换为浮点数,即 1.0。
理解强制转换的一种有效方式是:它们就像是编译器为你自动插入的原始函数。当你遗漏了必要的类型转换时,编译器会注意到并帮你补上。
在这个例子中,可以认为存在一个将整数转换为浮点数的原始函数 intToFloat。因此,表达式 1 + 2.0 实际上被编译器转换为:
intToFloat(1) + 2.0
强制转换本质上是为程序员提供的一种便利,让你在类型转换显而易见时,无需显式编写转换代码。这种机制在数字类型之间尤为常见,并不仅限于 Java。

Java 中的两种转换:扩展与窄化 ⚖️
理解了强制转换的基本概念后,本节我们来看看 Java 如何对它们进行分类。
Java 明确区分两种类型的转换:扩展强制转换和窄化强制转换。
以下是两种转换的核心区别:
- 扩展强制转换:这种转换总是安全的,能够成功。编译器或运行时系统不会对此报错。
- 例子:从
int到float的转换。因为浮点数可以无损地表示整数范围内的值。
- 例子:从
- 窄化强制转换:这种转换可能失败。
- 例子1:从
float到int。2.0可以转换为2,但2.5没有直接的整数表示。Java 会阻止这种可能导致信息丢失或错误的隐式转换。 - 例子2:向下转型。假设有类
A和其子类B。一个A类型的变量x可以被强制转换为B类型(B)x。编译器会允许此代码通过类型检查,但在运行时,系统会检查x实际引用的对象是否是B的实例。如果不是,则会抛出异常。
- 例子1:从
Java 的规则是:窄化转换必须是显式的。你必须在代码中明确写出类型转换操作(如 (int)floatValue 或 (B)aObject),这表明你确实意图进行此操作。而扩展强制转换可以是隐式的,由编译器自动完成。
一个特殊的类型:布尔值 🚫

讨论了数值和对象类型的转换后,我们来看一个 Java 中的特例。
在 Java 中,有一个原始类型没有定义任何到其他类型的隐式或显式强制转换。
这个类型就是 boolean。
因此,你不能将 boolean 值(true 或 false)直接转换为数字(如 1 或 0)或其他任何类型,这在 Java 中是不被允许的。

强制转换的潜在问题与示例 ⚠️
虽然强制转换带来了便利,但它也可能导致程序行为与程序员的预期不符。本节我们通过一个历史语言中的例子来理解其潜在风险。
以 IBM 在 20 世纪 60 年代设计的 PL/I 语言为例,它拥有非常广泛的强制转换规则,有时会导致令人惊讶的结果。
考虑以下 PL/I 代码片段,其中 a, b, c 被声明为长度为 3 的字符串:
b = ‘123’;
c = ‘456’;
a = b + c;
问题是,变量 a 的结果是什么?
以下是运算步骤:
- 表达式解释:
b + c中的+被解释为整数加法。因此,字符串b和c被强制转换为整数123和456。 - 整数运算:两者相加得到整数结果
579。 - 结果转换:由于
a是长度为 3 的字符串,整数579需要被转换回去。 - 转换步骤:
- 首先,整数
579被转换为默认长度(假设为6)的字符串,结果可能是" 579"(前面有3个空格)。 - 然后,这个6字符的字符串被截断为
a所要求的3个字符长度,只取前3个字符。
- 首先,整数
- 最终结果:
a的值是三个空格" "。
这个结果显然不是将 "123" 和 "456" 作为字符串连接起来的预期结果 "123456"。这个例子展示了过度依赖隐式强制转换可能带来的混淆。
总结 📝
本节课中我们一起学习了 Java 中的类型强制转换。

我们首先了解了强制转换是编译器自动插入类型转换函数的一种便利机制。然后,我们区分了 Java 中扩展强制转换(隐式、安全)和窄化强制转换(显式、可能失败)两种类型,并特别指出 boolean 类型不允许任何转换。最后,我们通过一个历史语言的例子,探讨了隐式强制转换可能带来的出人意料的行为,提醒我们在享受便利的同时,也需对类型转换保持清晰的认识。

课程 P95:Java 线程入门 🧵

在本节课中,我们将要学习编程语言中的并发概念,特别是 Java 中线程的基本使用方法。我们将了解线程如何工作,如何通过同步来控制线程间的交互,以及 Java 并发编程中一些需要注意的关键点。
什么是线程?🤔
上一节我们介绍了课程概述,本节中我们来看看线程的基本概念。
Java 语言内置了对并发的支持,主要通过线程实现。本教程假设您对线程已有一定了解,这里仅作简单回顾。线程类似于一个独立的程序,它拥有自己的程序计数器(用于跟踪下一条要执行的指令)、局部变量和激活记录(用于管理函数调用)。
在 Java 或任何支持线程的语言中,一个程序可以同时运行多个线程。抽象地看,每个线程都在执行一系列语句,并拥有自己的局部变量。但它们可以引用共享的数据,例如相同的堆数据结构。

假设有三个线程(线程一、线程二、线程三)在程序中执行各自的指令。调度器负责决定哪个线程在何时执行。概念上,调度器会循环执行以下操作:选择一个线程,让它执行一条语句,然后重复此过程。例如,它可能先选择线程一执行一条语句,然后切换到线程二,再切换到线程三,之后可能又回到线程二或线程一执行多条语句。
线程的执行顺序是不确定的。每次执行时,哪个线程运行、运行多少条指令都可能不同。线程的指令可能会以各种方式交错执行,顺序几乎是随机的。
如何在 Java 中创建和使用线程?⚙️
了解了线程的基本概念后,本节我们来看看在 Java 中如何具体实现线程。
在 Java 中,所有线程对象都基于 Thread 类。要创建一个线程,通常需要继承这个特殊的 Thread 类。继承后,您的类将拥有 start 和 stop 等方法,用于启动和结束线程。
线程有一个重要特性:它们可以同步对象。这意味着线程可以通过同步构造来获取对象的锁。在 Java 中,同步的主要方式是通过 synchronized 关键字。
以下是同步的基本语法:
synchronized (x) {
// 执行表达式 e
}
这段代码意味着,在执行块内的代码之前,程序会先获取对象 x 的锁。执行完毕后,会释放锁。这是一种结构化的同步方式,也是 Java 中控制多线程交错执行的主要(几乎是唯一)方法。当一个线程执行这段同步代码时,其他试图锁定同一对象 x 的线程必须等待。
两个线程能否同时执行相同的同步代码块?如果它们的局部变量 x 指向不同的对象,那么它们互不干扰,可以同时执行。只有当它们试图锁定同一个对象时,执行才不会交错。
Java 中还有一种更常用的简写形式,可以将 synchronized 关键字直接应用于方法:
public synchronized void methodName() {
// 方法体
}
当 synchronized 附加在方法声明上时,意味着调用此方法时会自动锁定当前对象(即 this)。

一个简单的线程交互示例 🔄
现在我们已经知道了如何创建线程和使用同步,本节我们通过一个具体例子来看看线程间可能如何交互,以及同步的重要性。
假设我们有一个 Simple 类,它有两个方法 two 和 fro,以及两个共享的整型变量 a 和 b。
class Simple {
int a = 1;
int b = 2;
public void two() {
a = 3;
b = 4;
}
public void fro() {
System.out.println("a = " + a + ", b = " + b);
}
}
现在,假设线程一调用 two() 方法,线程二调用 fro() 方法。我们来看看几种可能的执行结果:
以下是几种可能的执行顺序:
- 顺序执行一:线程一先完整执行
two(),然后线程二执行fro()。输出将是:a = 3, b = 4。 - 顺序执行二:线程二先完整执行
fro(),然后线程一执行two()。输出将是:a = 1, b = 2。 - 交错执行:线程一执行了
a = 3;后,线程二开始执行fro()。它可能先读取a(值为3),在读取b之前,线程一又执行了b = 4;,但线程二读取到的b可能仍是旧值2。最终输出可能是:a = 3, b = 2。这展示了一个“中间状态”,即一个线程只完成了部分更新。
如果我们认为第三种交错执行产生的输出是错误的(即看到了不一致的数据状态),我们就需要使用同步来防止这种情况。
一个常见且错误的同步尝试 ❌
上一节我们看到线程交错可能导致问题,本节我们来看一个试图修复但失败的同步尝试,这是一个 Java 程序员常犯的错误。
常见的错误想法是:只有写入数据的方法需要同步,读取数据的方法是安全的,可以不同步。基于此,可能只同步 two() 方法:
class Simple {
int a = 1;
int b = 2;
public synchronized void two() {
a = 3;
b = 4;
}
public void fro() { // 注意:这里没有同步!
System.out.println("a = " + a + ", b = " + b);
}
}
这个修复是无效的。因为 fro() 方法没有同步,它不会去检查对象锁。即使线程一正在执行同步的 two() 方法并持有锁,线程二仍然可以自由地进入并执行 fro() 方法。因此,之前提到的所有交错情况(包括看到中间状态)仍然可能发生。

核心问题:同步只有在所有访问共享数据的方法都进行检查(即都同步)时才有效。无论是读者(fro)还是写者(two),都需要同步。
正确的同步方式 ✅
认识到错误后,本节我们来看看正确的做法。
正确的解决方案是将 synchronized 关键字放在两个方法上:
class Simple {
int a = 1;
int b = 2;
public synchronized void two() {
a = 3;
b = 4;
}
public synchronized void fro() {
System.out.println("a = " + a + ", b = " + b);
}
}
现在,当两个方法都同步后,可能的执行结果就只有两种了:
fro()在two()之前完整执行,输出:a = 1, b = 2。two()在fro()之前完整执行,输出:a = 3, b = 4。
不可能再出现看到部分更新的中间状态。
Java 并发的其他重要注意事项 ⚠️
掌握了基本的线程创建与同步后,本节我们补充一些 Java 并发编程中其他重要的知识点。

1. 原子性与“凭空出现”的值
我们希望的一个基本属性是:即使没有同步,一个变量也应该只持有由某个线程实际写入的值。例如,线程一写 a = 3.14,线程二写 a = 2.78。执行后,a 的值应该是 3.14 或 2.78,而不应该是从未被写入过的值(如 3.78)。这种混合了不同写入片段的值被称为“凭空出现”的值。
Java 保证了大多数基本数据类型(如 int, boolean)的读写是原子的,但 double 和 float 除外。因为 double 占64位(两个字),在有些硬件平台上,对其的写入可能需要多条指令,从而可能被其他线程的写入操作交错,导致位混合。
解决方案:如果需要在多线程环境中并发读写 double 或 float 变量,应将其声明为 volatile:
private volatile double a;
volatile 关键字可以保证对 double 的读写是原子的(在当前Java规范下)。这是一个对性能的折衷,未来可能会改变。
2. Java 并发语义的复杂性
Java 是最早将线程作为一等公民的主流语言之一,并试图将其与丰富的语言特性集成。因此,其完整的并发语义非常复杂且难以精确理解,某些边界情况下的行为甚至仍是研究和争论的领域。对于大多数直接、常见的并发任务,Java 线程工作良好。但如果你进行非常复杂或底层的并发编程,可能需要更深入地研究 Java 内存模型等规范。
总结 📝
本节课中我们一起学习了 Java 线程的基础知识:
- 线程概念:线程是独立的执行流,拥有自己的计数器和局部变量,但可共享数据。
- 线程创建:通过继承
Thread类来创建线程。 - 线程同步:使用
synchronized关键字(代码块或方法)来获取对象锁,控制线程对共享资源的访问,避免数据不一致。 - 常见错误:只同步写入方法而不同步读取方法是无效的,所有访问共享数据的方法都需要同步。
- 注意事项:
double和float类型的读写不是原子的,在多线程环境下应使用volatile声明。Java 的完整并发语义较为复杂,对于常规使用是足够的。
理解这些基础是编写正确、可靠的多线程 Java 程序的关键。

课程P96:Java语言的其他主题 🧩

在本节课中,我们将结束对Java语言的讨论,通过了解几个附加主题及其在语言设计中的体现。我们将探讨Java的动态类加载、字节码验证、复杂的类初始化过程,并从中理解设计复杂系统时特性交互所带来的挑战。
动态类加载与字节码验证 🔄
上一节我们介绍了Java语言的整体框架,本节中我们来看看Java的动态特性之一:运行时类加载。
Java允许在运行时动态加载类。这意味着可以向正在执行的Java程序中添加新功能。然而,这种动态性也带来了类型安全和安全性方面的问题。

编译时和加载时存在区别。源代码的类型检查在编译时进行,这是我们之前讨论过的类型检查。但类加载器在加载类时,加载的是字节码,而非源代码。这些字节码不会被再次进行类型检查,并且可能来自不可信的来源。
这些字节码可能不是由经过类型检查的编译器生成的。在生成字节码之前,它们可能不满足任务实现的类型假设。因此,必须在类被加载时再次检查字节码。这个过程称为字节码验证。
字节码验证本质上是对字节码进行类型检查。由于字节码的抽象级别更低,其算法与源码类型检查略有不同,但其核心目的仍是进行类型检查。加载策略由类加载器处理。
类加载器是Java中的一个特殊类,它决定哪些类可以被加载。在Java早期,曾发现一系列安全问题,攻击者可以控制类加载器,安装比标准类加载器更宽松的自定义类加载器,从而破坏系统。但这些问题早已被修复。
Java另一个有趣的特点是类也可以被卸载。这意味着不仅可以加载类,也可以卸载类。不过,类卸载的具体语义(例如,该类的现有对象会发生什么)在定义上并不完全明确。

复杂的类初始化过程 ⚙️
了解了动态加载后,我们来看看Java中另一个复杂的机制:类初始化。这相当复杂,因为Java是Cool语言的超集,它继承了Cool的所有初始化问题,并因并发等特性而变得更加复杂。
事实上,如果你想理解一门新的面向对象语言,研究其对象和类的初始化过程是一个很好的切入点。因为在初始化过程中,语言的所有特性都会相互作用,必须清晰地定义这些交互。
现在让我们谈谈类初始化(即代表类的那个类对象是如何初始化的),而不讨论对象实例的初始化。
首先需要知道的是,类是在其内部的符号首次被使用时初始化的,而不是在类被加载时。这样做的原因是,如果类初始化中有错误,错误会在一个可预测的位置发生。这使得错误是可重复和可预测的。如果错误发生在加载时,而加载可能发生在多个不确定的时间点,那么错误的发生将变得非确定性。
以下是Java中初始化类对象的主要过程步骤:
- 获取锁:首先,尝试锁定该类的类对象。如果已被其他线程锁定,则等待。
- 检查状态:获得锁后,检查类是否已被初始化。
- 情况A:发现当前线程已经在初始化这个类(由于递归结构,例如类有一个类型为自己的字段)。此时释放锁并返回。
- 情况B:发现类已被其他线程初始化完毕。此时无事可做,正常返回。
- 标记初始化:如果类既未初始化,当前线程也非正在初始化它,则标记该类“正在由本线程初始化”,然后解锁。
- 初始化超类:递归地初始化其超类。
- 初始化字段:按文本顺序初始化所有静态字段。但在初始化任何其他字段之前,会先为所有字段赋予默认值,并优先初始化静态最终(
static final)字段。 - 处理结果:
- 若出现异常:在初始化过程中如果抛出异常,则将类标记为“错误”状态,此类将不可用。
- 若成功:再次锁定类,将其标记为“已初始化”,然后通知所有正在等待该类对象的线程,最后解锁。
这个过程简化了一些细节,但涵盖了主要观点,展示了并发、异常、静态/最终字段、继承等特性是如何交织在一起的。
系统设计中的特性交互难题 🤔
退一步看,关于Java类初始化的讨论阐明了一个关于设计复杂系统的普遍观点。

任何具有一定数量特性(设为 n)的系统,随着特性的增加,特性之间潜在的交互数量会超线性增长。如果只考虑两两交互,其数量级约为 O(n²)。这意味着每增加一个新特性,都需要考虑它与系统中所有现有特性的交互。
如果开始考虑特性子集之间的交互,那么潜在交互的数量将呈指数级增长。这就是为什么构建或扩展具有众多特性的系统非常困难。
这个教训适用于任何复杂系统的设计,尤其在编程语言设计中力量显著。因为语言特性间的交互发生在非常细的粒度上,并且可以任意组合。语言设计者必须厘清所有这些交互,程序员才能有效理解和使用这门语言。这是本课程希望传达的重要思想之一。
总结与课程结束 🎯

本节课中,我们一起学习了Java语言的几个高级主题。
总结并结束我们对Java的讨论:Java是一种在工业标准上做得非常出色的语言。它是当今设计最完善、规范最严谨的流行语言之一,并将一些重要思想(如强静态类型和垃圾回收内存管理)引入了主流。
但这并不意味着它是完美的。Java包含一些在设计时并未被完全理解的功能,这些可能构成了语言设计中仍显粗糙的部分。例如,并发时的内存语义可能仍存在一些多数人认同的问题和灰色地带。此外,正如之前所述,特性众多会导致复杂的交互,使得系统难以管理和理解。
本节课中我们一起学习了:
- Java的动态类加载机制及其伴随的字节码验证过程。
- Java中复杂的类初始化流程,它集中体现了多种语言特性的交互。
- 从Java初始化机制中引申出的关于复杂系统设计中“特性交互”难题的普遍思考。
- 对Java语言整体的评价:其成就与遗留的挑战。

至此,我们关于Java语言的专题讨论就告一段落了。
课程 P97:DeduceIt 系统演示教程 🧮
在本节课中,我们将学习如何使用斯坦福大学演绎CEA研究项目的演示工具——DeduceIt。该系统旨在帮助学生在在线课程中学习形式系统。其核心思想是让学生完成形式推导,并使用系统提供的即时反馈技术来检查推导步骤的正确性。通过这种方式,学生可以深入理解形式推理的细节。
概述:系统界面与规则分类
上一节我们介绍了DeduceIt系统的目标。本节中,我们来看看一个具体的代数证明示例及其界面。
我们的目标是证明 x = 11。证明从一个给定的等式开始:2x - 4 = x + 7。在系统中,可用的推导规则分为两类:
以下是两类规则的说明:
- 必需规则:位于规则列表顶部。每当推导步骤使用这些规则时,必须明确命名并显示该步骤。
- 自由规则:位于规则列表下方。使用这些规则时,步骤可以跳过,系统会尝试自动填补。这些规则(如加法结合律)被认为是已掌握的,可以省略以简化推导过程。
推导的每一步都包含三个部分:
- 结论:这一步要证明的命题。
- 理由:这一步所依据的规则。
- 依据:这一步所依赖的、先前已证明为真的事实。
第一步:应用平衡方程规则
现在,让我们开始推导。第一步是如何取得进展?我们可以在等式两边同时加上4。

这一步的合理性在于“平衡方程”的加法规则,它允许在等式两边加上相同的量。
我们选择“平衡方程(加法)”这条必需规则。依据是目前唯一已知的事实,即初始给定的等式。点击“更新证明”后,系统确认这一步是有效的。

错误处理与系统提示

如果操作错误会怎样?例如,如果在等式两边添加了不同的值,系统会立即用红色高亮显示错误。

此时,步骤旁边会出现一个问号图标。点击它可以查看错误详情和修正建议。例如,系统会提示“平衡方程意味着两边都要加相同的值”。
并非所有错误都有详细提示,但当提示出现时,它能有效帮助定位问题。
应用简化与必需规则

回到正确的推导路径。下一步是简化表达式。左边 2x - 4 + 4 可简化为 2x,右边 x + 7 + 4 可简化为 x + 11。

这里,将 -4 + 4 合并为 0 属于自由规则(加常数),可以跳过。但是,将 2x + 0 简化为 2x 需要使用“加法恒等式”规则,而这是一条必需规则,必须明确显示。
如果我们错误地跳过了必需规则,系统会报错并给出提示。
修正后,这一步应声明使用了“加法恒等式”规则,并依据上一步的结论。而其中涉及的常数合并等自由规则则无需列出。
后续推导步骤
完成简化后,我们得到 2x = x + 11。后续步骤遵循类似的模式:
- 移项:两边加上
-x,使用“平衡方程(加法)”规则,得到2x + (-x) = x + 11 + (-x)。 - 简化右边:
x + (-x)运用“加法逆元”(必需规则)得到0,11 + 0运用“加法恒等式”(必需规则)得到11。于是右边简化为11。 - 处理左边:将
2x + (-x)转化为(2 + (-1)) * x,这需要用到“分配律”(必需规则)。其中-x转化为(-1)*x属于自由规则,可跳过。 - 最终简化:计算
2 + (-1)得到1(自由规则),然后运用“乘法恒等式”1 * x = x(必需规则),最终得到x = 11。
当推导出目标结论 x = 11 时,系统会识别出证明已完成。
总结与系统通用性
本节课中,我们一起学习了使用DeduceIt系统进行形式推导的全过程。我们了解了必需规则与自由规则的区别,掌握了每一步需要填写的结论、理由和依据。我们还体验了系统的即时错误检查和提示功能,这对于学习正确的推理流程至关重要。
需要强调的是,虽然本例是代数证明,但DeduceIt系统适用于任何形式系统。教师可以为其定义不同的规则集,让学生练习逻辑、集合论或其他领域的正式推导。

通过这种交互式、有反馈的练习,学生能够扎实地掌握形式推理的核心技能。



浙公网安备 33010602011771号