This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
<audio id="audio" title="01 | 理解代码:编译器的前端技术" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1b/72/1b32bb915c6c5ff76cf3e87d1fb58072.mp3"></audio>
在开篇词里,我分享了一些使用编译技术的场景。其中有的场景,你只要掌握编译器的前端技术就能解决。比如文本分析场景,软件需要用户自定义功能的场景以及前端编程语言的翻译场景等。而且咱们大学讲的编译原理,也是侧重讲解前端技术,可见编译器的前端技术有多么重要。
当然了,**这里的“前端Front End”指的是编译器对程序代码的分析和理解过程。**它通常只跟语言的语法有关,跟目标机器无关。**而与之对应的“后端Back End”则是生成目标代码的过程跟目标机器有关。**为了方便你理解,我用一张图直观地展现了编译器的整个编译过程。
<img src="https://static001.geekbang.org/resource/image/06/93/06b80f8484f4d88c6510213eb27f2093.jpg" alt="图片: https://uploader.shimo.im/f/4IzHpTLFaDwwTfio.png">
你可以看到,编译器的“前端”技术分为**词法分析、语法分析**和**语义分析**三个部分。而它主要涉及自动机和形式语言方面的基础的计算理论。
这些抽象的理论也许会让你“撞墙”,不过不用担心,我今天会把难懂的理论放到一边,用你听得懂的大白话,联系实际使用的场景,带你直观地理解它们,**让你学完本节课之后,实现以下目标:**
- 对编译过程以及其中的技术点有个宏观、概要的了解。
- 能够在大脑里绘制一张清晰的知识地图,以应对工作需要。比如分析一个日志文件时,你能知道所对应的技术点,从而针对性地解决问题。
好了,接下来让我们正式进入今天的课程吧!
## 词法分析Lexical Analysis
通常编译器的第一项工作叫做词法分析。就像阅读文章一样文章是由一个个的中文单词组成的。程序处理也一样只不过这里不叫单词而是叫做“词法记号”英文叫Token。我嫌“词法记号”这个词太长后面直接将它称作Token吧。
举个例子,看看下面这段代码,如果我们要读懂它,首先要怎么做呢?
```
#include &lt;stdio.h&gt;
int main(int argc, char* argv[]){
int age = 45;
if (age &gt;= 17+8+20) {
printf(&quot;Hello old man!\\n&quot;);
}
else{
printf(&quot;Hello young man!\\n&quot;);
}
return 0;
}
```
我们会识别出if、else、int这样的关键字main、printf、age这样的标识符+、-、=这样的操作符号还有花括号、圆括号、分号这样的符号以及数字字面量、字符串字面量等。这些都是Token。
那么如何写一个程序来识别Token呢可以看到英文内容中通常用空格和标点把单词分开方便读者阅读和理解。但在计算机程序中仅仅用空格和标点分割是不行的。比如“age &gt;= 45”应该分成“age”“&gt;=”和“45”这三个Token但在代码里它们可以是连在一起的中间不用非得有空格。
这和汉语有点儿像,汉语里每个词之间也是没有空格的。但我们会下意识地把句子里的词语正确地拆解出来。比如把“我学习编程”这个句子拆解成“我”“学习”“编程”,这个过程叫做“分词”。如果你要研发一款支持中文的全文检索引擎,需要有分词的功能。
其实我们可以通过制定一些规则来区分每个不同的Token我举了几个例子你可以看一下。
<li>
**识别age这样的标识符。**它以字母开头,后面可以是字母或数字,直到遇到第一个既不是字母又不是数字的字符时结束。
</li>
<li>
**识别&gt;=这样的操作符。** 当扫描到一个&gt;字符的时候就要注意它可能是一个GTGreater Than大于操作符。但由于GEGreater Equal大于等于也是以&gt;开头的,所以再往下再看一位,如果是=那么这个Token就是GE否则就是GT。
</li>
<li>
**识别45这样的数字字面量。**当扫描到一个数字字符的时候,就开始把它看做数字,直到遇到非数字的字符。
</li>
这些规则可以通过手写程序来实现。事实上很多编译器的词法分析器都是手写实现的例如GNU的C语言编译器。
如果嫌手写麻烦或者你想花更多时间陪恋人或家人也可以偷点儿懒用词法分析器的生成工具来生成比如Lex或其GNU版本Flex。这些生成工具是基于一些规则来工作的这些规则用“正则文法”表达符合正则文法的表达式称为“正则表达式”。生成工具可以读入正则表达式生成一种叫“有限自动机”的算法来完成具体的词法分析工作。
不要被“正则文法Regular Grammar”和“有限自动机Finite-state AutomatonFSAor Finite Automaton”吓到。正则文法是一种最普通、最常见的规则写正则表达式的时候用的就是正则文法。我们前面描述的几个规则都可以看成口语化的正则文法。
有限自动机是有限个状态的自动机器。我们可以拿抽水马桶举例,它分为两个状态:“注水”和“水满”。摁下冲马桶的按钮,它转到“注水”的状态,而浮球上升到一定高度,就会把注水阀门关闭,它转到“水满”状态。
<img src="https://static001.geekbang.org/resource/image/9f/05/9f449fcc2781c222061b6e73c6bbec05.jpg" alt="">
词法分析器也是一样它分析整个程序的字符串当遇到不同的字符时会驱使它迁移到不同的状态。例如词法分析程序在扫描age的时候处于“标识符”状态等它遇到一个&gt;符号,就切换到“比较操作符”的状态。词法分析过程,就是这样一个个状态迁移的过程。
<img src="https://static001.geekbang.org/resource/image/6d/7e/6d78396e6426d0ad5c5230203d17da7e.jpg" alt="">
你也许熟悉正则表达式,因为我们在编程过程中经常用正则表达式来做用户输入的校验,例如是否输入了一个正确的电子邮件地址,这其实就是在做词法分析,你应该用过。
## 语法分析 Syntactic Analysis, or Parsing
编译器下一个阶段的工作是语法分析。词法分析是识别一个个的单词,而语法分析就是在词法分析的基础上识别出程序的语法结构。这个结构是一个树状结构,是计算机容易理解和执行的。
以自然语言为例。自然语言有定义良好的语法结构,比如,“我喜欢又聪明又勇敢的你”这个句子包含了“主、谓、宾”三个部分。主语是“我”,谓语是“喜欢”,宾语部分是“又聪明又勇敢的你”。其中宾语部分又可以拆成两部分,“又聪明又勇敢”是定语部分,用来修饰“你”。定语部分又可以分成“聪明”和“勇敢”两个最小的单位。
这样拆下来,会构造一棵树,里面的每个子树都有一定的结构,而这个结构要符合语法。比如,汉语是用“主谓宾”的结构,日语是用“主宾谓”的结构。这时,我们说汉语和日语的语法规则是不同的。
<img src="https://static001.geekbang.org/resource/image/93/fb/9380037e2d2c2c2a8ff50f1367ff37fb.jpg" alt="">
程序也有定义良好的语法结构,它的语法分析过程,就是构造这么一棵树。一个程序就是一棵树,这棵树叫做**抽象语法树**Abstract Syntax TreeAST。树的每个节点子树是一个语法单元这个单元的构成规则就叫“语法”。每个节点还可以有下级节点。
层层嵌套的树状结构,是我们对计算机程序的直观理解。计算机语言总是一个结构套着另一个结构,大的程序套着子程序,子程序又可以包含子程序。
接下来,我们直观地看一下这棵树长什么样子。 我在Mac电脑上打下这个命令
```
clang -cc1 -ast-dump hello.c
```
这个命令是运行苹果公司的C语言编译器来编译hello.c-ast-dump参数使它输出AST而不是做常规的编译。我截取了一部分输出结果给你看从中你可以看到这棵树的结构。 试着修改程序,添加不同的语句,你会看到不同的语法树。
<img src="https://static001.geekbang.org/resource/image/3f/fb/3f53e82a3b2714f99d97f0e66d01c7fb.jpg" alt="">
如果你觉得这棵树还不够直观,可以参考我提供的[网址](https://resources.jointjs.com/demos/javascript-ast)它能够生成JavaScript语言的AST并以更加直观的方式呈现。
在这个网址里输入一个可以计算的表达式例如“2+3*5”你会得到一棵类似下图的AST。
<img src="https://static001.geekbang.org/resource/image/5e/1c/5ed231aced0b65b8c0d343b86634401c.jpg" alt="">
**形成AST以后有什么好处呢就是计算机很容易去处理。**比如,针对表达式形成的这棵树,从根节点遍历整棵树就可以获得表达式的值。基于这个原理,我在后面的课程中会带你实现一个计算器,并实现自定义公式功能。
如果再把循环语句、判断语句、赋值语句等节点加到AST上并解释执行它那么你实际上就实现了一个脚本语言。而执行脚本语言的过程就是遍历AST的过程。当然在后面的课程中我也会带你实际实现一个脚本语言。
**好了你已经知道了AST的作用那么怎样写程序构造它呢**
一种非常直观的构造思路是自上而下进行分析。首先构造根节点代表整个程序之后向下扫描Token串构建它的子节点。当它看到一个int类型的Token时知道这儿遇到了一个变量声明语句于是建立一个“变量声明”节点接着遇到age建立一个子节点这是第一个变量之后遇到=意味着这个变量有初始化值那么建立一个初始化的子节点最后遇到“字面量”其值是45。
这样,一棵子树就扫描完毕了。程序退回到根节点,开始构建根节点的第二个子节点。这样递归地扫描,直到构建起一棵完整的树。
<img src="https://static001.geekbang.org/resource/image/cb/16/cbf2b953cb84ef30b154470804262c16.jpg" alt="">
这个算法就是非常常用的递归下降算法Recursive Descent Parsing。是不是很简单你完全可以动手写出来。
递归下降算法是一种自顶向下的算法,与之对应的,还有自底向上的算法。这个算法会先将最下面的叶子节点识别出来,然后再组装上一级节点。有点儿像搭积木,我们总是先构造出小的单元,然后再组装成更大的单元。原理就是这么简单。
也许你会想,除了手写,有没有偷懒的、更省事的方法呢?多一些时间去陪家人总不是坏事。
你现在已经有了一定的经验大可以去找找看有没有现成的工具比如Yacc或GNU的版本Bison、Antlr、JavaCC等。实际上你可以在维基百科里找到一个挺大的清单我把它放到了CSDN的[博客](https://blog.csdn.net/gongwx/article/details/99645305)上,其中对各种工具的特性做了比较。
顺理成章地,你还能找到很多开源的语法规则文件,改一改,就能用工具生成你的语法分析器。
很多同学其实已经做过语法解析的工作比如编写一个自定义公式的功能对公式的解析就是语法分析过程。另一个例子是分析日志文件等文本文件对每行日志的解析本质上也是语法分析过程。解析用XML、JSON写的各种配置文件、模型定义文件的过程其实本质也是语法分析过程甚至还包含了语义分析工作。
## 语义分析Semantic Analysis
好了,讲完了词法分析、语法分析,编译器接下来做的工作是语义分析。说白了,语义分析就是要让计算机理解我们的真实意图,把一些模棱两可的地方消除掉。
以“You can never drink too much water.” 这句话为例。它的确切含义是什么?是“你不能喝太多水”,还是“你喝多少水都不嫌多”?实际上,这两种解释都是可以的,我们只有联系上下文才能知道它的准确含义。
你可能会觉得理解自然语言的含义已经很难了,所以计算机语言的语义分析也一定很难。其实语义分析没那么复杂,因为计算机语言的语义一般可以表达为一些规则,你只要检查是否符合这些规则就行了。比如:
<li>
某个表达式的计算结果是什么数据类型?如果有数据类型不匹配的情况,是否要做自动转换?
</li>
<li>
如果在一个代码块的内部和外部有相同名称的变量,我在执行的时候到底用哪个? 就像“我喜欢又聪明又勇敢的你”中的“你”,到底指的是谁,需要明确。
</li>
<li>
在同一个作用域内不允许有两个名称相同的变量这是唯一性检查。你不能刚声明一个变量a紧接着又声明同样名称的一个变量a这就不允许了。
</li>
语义分析基本上就是做这样的事情,也就是根据语义规则进行分析判断。
语义分析工作的某些成果会作为属性标注在抽象语法树上比如在age这个标识符节点和45这个字面量节点上都会标识它的数据类型是int型的。
在这个树上还可以标记很多属性,有些属性是在之前的两个阶段就被标注上了,比如所处的源代码行号,这一行的第几个字符。这样,在编译程序报错的时候,就可以比较清楚地了解出错的位置。
做了这些属性标注以后,编译器在后面就可以依据这些信息生成目标代码了,我们在编译技术的后端部分会去讲。
## 课程小结
讲完语义分析,本节课也就告一段落了,我来总结一下本节课的重点内容:
<li>
词法分析是把程序分割成一个个Token的过程可以通过构造有限自动机来实现。
</li>
<li>
语法分析是把程序的结构识别出来,并形成一棵便于由计算机处理的抽象语法树。可以用递归下降的算法来实现。
</li>
<li>
语义分析是消除语义模糊,生成一些属性信息,让计算机能够依据这些信息生成目标代码。
</li>
我想让你知道,上述编译过程其实跟你的实际工作息息相关。比如,词法分析就是你工作中使用正则表达式的过程。而语法分析在你解析文本文件、配置文件、模型定义文件,或者做自定义公式功能的时候都会用到。
我还想让你知道,编译技术并没有那么难,它的核心原理是很容易理解的。学习之后,你能很快上手,如果善用一些辅助生成工具会更省事。所以,我希望你通过学习这篇文章,已经破除了一些心理障碍,并跃跃欲试,想要动手做点儿什么了!
## 一课一思
你有没有觉得,刚开始学编译原理中的某些知识点时特别难,一旦学通了以后,就会发出类似的感慨:“啊!原来就是这么回事!”欢迎在留言区与我分享你的感慨时刻。另外,你是否尝试实现过一个编译器,还颇有一些心得?可以在留言区与大家一起交流。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,300 @@
<audio id="audio" title="02 | 正则文法和有限自动机:纯手工打造词法分析器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/78/b3/78cec54515b3d4e057c843578204f1b3.mp3"></audio>
上一讲我提到词法分析的工作是将一个长长的字符串识别出一个个的单词这一个个单词就是Token。而且词法分析的工作是一边读取一边识别字符串的不是把字符串都读到内存再识别。你在听一位朋友讲话的时候其实也是同样的过程一边听一边提取信息。
那么问题来了字符串是一连串的字符形成的怎么把它断开成一个个的Token呢分割的依据是什么呢本节课我会通过讲解正则表达式Regular Expression和有限自动机的知识带你解决这个问题。
其实我们手工打造词法分析器的过程就是写出正则表达式画出有限自动机的图形然后根据图形直观地写出解析代码的过程。而我今天带你写的词法分析器能够分析以下3个程序语句
- age &gt;= 45
- int age = 40
- 2+3*5
它们分别是关系表达式、变量声明和初始化语句,以及算术表达式。
接下来我们先来解析一下“age &gt;= 45”这个关系表达式这样你就能理解有限自动机的概念知道它是做词法解析的核心机制了。
## 解析 age &gt;= 45
在“[01 | 理解代码:编译器的前端技术](https://time.geekbang.org/column/article/118132)”里,我举了一个词法分析的例子,并且提出词法分析要用到有限自动机。当时,我画了这样一个示意图:
<img src="https://static001.geekbang.org/resource/image/6d/7e/6d78396e6426d0ad5c5230203d17da7e.jpg" alt="">
我们来描述一下标识符、比较操作符和数字字面量这三种Token的词法规则。
- **标识符:**第一个字符必须是字母,后面的字符可以是字母或数字。
- **比较操作符:**&gt;&gt;=(其他比较操作符暂时忽略)。
- **数字字面量:**全部由数字构成(像带小数点的浮点数,暂时不管它)。
我们就是依据这样的规则来构造有限自动机的。这样词法分析程序在遇到age、&gt;=和45时会分别识别成标识符、比较操作符和数字字面量。不过上面的图只是一个简化的示意图一个严格意义上的有限自动机是下面这种画法
<img src="https://static001.geekbang.org/resource/image/15/35/15da400d09ede2ce6ac60fa6d5342835.jpg" alt="">
我来解释一下上图的5种状态。
**1.初始状态:**刚开始启动词法分析的时候,程序所处的状态。
**2.标识符状态:**在初始状态时当第一个字符是字母的时候迁移到状态2。当后续字符是字母和数字时保留在状态2。如果不是就离开状态2写下该Token回到初始状态。
**3.大于操作符GT**在初始状态时,当第一个字符是&gt;时,进入这个状态。它是比较操作符的一种情况。
**4.大于等于操作符GE**如果状态3的下一个字符是=就进入状态4变成&gt;=。它也是比较操作符的一种情况。
**5.数字字面量:**在初始状态时下一个字符是数字进入这个状态。如果后续仍是数字就保持在状态5。
这里我想补充一下你能看到上图中的圆圈有单线的也有双线的。双线的意思是这个状态已经是一个合法的Token了单线的意思是这个状态还是临时状态。
按照这5种状态迁移过程你很容易编成程序我用Java写了代码示例你可以用自己熟悉的语言编写。我们先从状态1开始在遇到不同的字符时分别进入2、3、5三个状态
```
DfaState newState = DfaState.Initial;
if (isAlpha(ch)) { //第一个字符是字母
newState = DfaState.Id; //进入Id状态
token.type = TokenType.Identifier;
tokenText.append(ch);
} else if (isDigit(ch)) { //第一个字符是数字
newState = DfaState.IntLiteral;
token.type = TokenType.IntLiteral;
tokenText.append(ch);
} else if (ch == '&gt;') { //第一个字符是&gt;
newState = DfaState.GT;
token.type = TokenType.GT;
tokenText.append(ch);
}
```
上面的代码中我用Java中的枚举enum类型定义了一些枚举值来代表不同的状态让代码更容易读。
其中Token是自定义的一个数据结构它有两个主要的属性一个是“type”就是Token的类型它用的也是一个枚举类型的值一个是“text”也就是这个Token的文本值。
我们接着处理进入2、3、5三个状态之后的状态迁移过程
```
case Initial:
state = initToken(ch); //重新确定后续状态
break;
case Id:
if (isAlpha(ch) || isDigit(ch)) {
tokenText.append(ch); //保持标识符状态
} else {
state = initToken(ch); //退出标识符状态并保存Token
}
break;
case GT:
if (ch == '=') {
token.type = TokenType.GE; //转换成GE
state = DfaState.GE;
tokenText.append(ch);
} else {
state = initToken(ch); //退出GT状态并保存Token
}
break;
case GE:
state = initToken(ch); //退出当前状态并保存Token
break;
case IntLiteral:
if (isDigit(ch)) {
tokenText.append(ch); //继续保持在数字字面量状态
} else {
state = initToken(ch); //退出当前状态并保存Token
}
break;
```
运行这个示例程序你就会成功地解析类似“age &gt;= 45”这样的程序语句。不过你可以先根据我的讲解自己实现一下然后再去参考这个示例程序。
示例程序的输出如下其中第一列是Token的类型第二列是Token的文本值
```
Identifier age
GE &gt;=
IntLiteral 45
```
上面的例子虽然简单,但其实已经讲清楚了词法原理,**就是依据构造好的有限自动机在不同的状态中迁移从而解析出Token来。**你只要再扩展这个有限自动机,增加里面的状态和迁移路线,就可以逐步实现一个完整的词法分析器了。
## 初识正则表达式
但是,这里存在一个问题。我们在描述词法规则时用了自然语言。比如,在描述标识符的规则时,我们是这样表达的:
>
第一个字符必须是字母,后面的字符可以是字母或数字。
这样描述规则并不精确,我们需要换一种严谨的表达方式,这种方式就是**正则表达式。**
上面的例子涉及了4种Token这4种Token用正则表达式表达是下面的样子
```
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*
IntLiteral: [0-9]+
GT : '&gt;'
GE : '&gt;='
```
我先来解释一下这几个规则中用到的一些符号:
<img src="https://static001.geekbang.org/resource/image/f6/17/f6601b74204140836bd409137924be17.jpg" alt="">
需要注意的是不同语言的标识符、整型字面量的规则可能是不同的。比如有的语言可以允许用Unicode作为标识符也就是说变量名称可以是中文的。还有的语言规定十进制数字字面量的第一位不能是0。这时候正则表达式会有不同的写法对应的有限自动机自然也不同。而且不同工具的正则表达式写法会略有不同但大致是差不多的。
我在本节课讲正则表达式,主要是为了让词法规则更为严谨,当然了,也是为后面的内容做铺垫。在后面的课程中,我会带你用工具生成词法分析器,而工具读取的就是用正则表达式描述的词法规则。到时候,我们会把所有常用的词法都用正则表达式描述出来。
不过在这之前如果你想主动了解更完整的正则表达式规则完全可以参考自己所采用的正则表达式工具的文档。比如Java的正则式表达式工具在java.util.regex包中在其Javadoc中有详细的规则说明。
## 解析int age = 40处理标识符和关键字规则的冲突
说完正则表达式我们接着去处理其他词法比如解析“int age = 40”这个语句以这个语句为例研究一下词法分析中会遇到的问题多个规则之间的冲突。
如果我们把这个语句涉及的词法规则用正则表达式写出来,是下面这个样子:
```
Int: 'int'
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*
Assignment : '='
```
这时候你可能会发现这样一个问题int这个关键字与标识符很相似都是以字母开头后面跟着其他字母。
换句话说int这个字符串既符合标识符的规则又符合int这个关键字的规则这两个规则发生了重叠。这样就起冲突了我们扫描字符串的时候到底该用哪个规则呢
当然我们心里知道int这个关键字的规则比标识符的规则优先级高。普通的标识符是不允许跟这些关键字重名的。
**在这里,我们来回顾一下:什么是关键字?**
关键字是语言设计中作为语法要素的词汇例如表示数据类型的int、char表示程序结构的while、if表述特殊数据取值的null、NAN等。
除了关键字,还有一些词汇叫保留字。保留字在当前的语言设计中还没用到,但是保留下来,因为将来会用到。我们命名自己的变量、类名称,不可以用到跟关键字和保留字相同的字符串。**那么我们在词法分析器中,如何把关键字和保留字跟标识符区分开呢?**
以“int age = 40”为例我们把有限自动机修改成下面的样子借此解决关键字和标识符的冲突。
<img src="https://static001.geekbang.org/resource/image/11/3c/11cf7add8fb07db41f4eb067db4ac13c.jpg" alt="">
这个思路其实很简单。在识别普通的标识符之前,你先看看它是关键字还是保留字就可以了。具体做法是:
>
当第一个字符是i的时候我们让它进入一个特殊的状态。接下来如果它遇到n和t就进入状态4。但这还没有结束如果后续的字符还有其他的字母和数字它又变成了普通的标识符。比如我们可以声明一个intAint和A是连着的这样的变量而不会跟int关键字冲突。
相应的代码也修改一下,文稿里的第一段代码要改成:
```
if (isAlpha(ch)) {
if (ch == 'i') {
newState = DfaState.Id_int1; //对字符i特殊处理
} else {
newState = DfaState.Id;
}
... //后续代码
}
```
第二段代码要增加下面的语句:
```
case Id_int1:
if (ch == 'n') {
state = DfaState.Id_int2;
tokenText.append(ch);
}
else if (isDigit(ch) || isAlpha(ch)){
state = DfaState.Id; //切换回Id状态
tokenText.append(ch);
}
else {
state = initToken(ch);
}
break;
case Id_int2:
if (ch == 't') {
state = DfaState.Id_int3;
tokenText.append(ch);
}
else if (isDigit(ch) || isAlpha(ch)){
state = DfaState.Id; //切换回Id状态
tokenText.append(ch);
}
else {
state = initToken(ch);
}
break;
case Id_int3:
if (isBlank(ch)) {
token.type = TokenType.Int;
state = initToken(ch);
}
else{
state = DfaState.Id; //切换回Id状态
tokenText.append(ch);
}
break;
```
接着,我们运行示例代码,就会输出下面的信息:
```
Int int
Identifier age
Assignment =
IntLiteral 45
```
而当你试着解析“intA = 10”程序的时候会把intA解析成一个标识符。输出如下
```
Identifier intA
Assignment =
IntLiteral 10
```
## 解析算术表达式
解析完“int age = 40”之后我们再按照上面的方法增加一些规则这样就能处理算术表达式例如“2+3*5”。 增加的词法规则如下:
```
Plus : '+'
Minus : '-'
Star : '*'
Slash : '/'
```
然后再修改一下有限自动机和代码就能解析“2+3*5”了会得到下面的输出
```
IntLiteral 2
Plus +
IntLiteral 3
Star *
IntLiteral 5
```
好了,现在我们已经能解析不少词法了,之后的课程里,我会带你实现一个公式计算器,所以在这里要先准备好所需要的词法分析功能。
## 课程小结
本节课,我们实现了一个简单的词法分析器。你可以看到,要实现一个词法分析器,首先需要写出每个词法的正则表达式,并画出有限自动机,之后,只要用代码表示这种状态迁移过程就可以了。
**我们总是说理解原理以后,实现并不困难。**今天的分享,你一定有所共鸣。
反之,如果你在编程工作中遇到困难,往往是因为不清楚原理,没有将原理吃透。而这门课就是要帮助你真正吃透编译技术中的几个核心原理,让你将知识应用到实际工作中,解决工作中遇到的困难。
小试了词法分析器之后,在下一讲,我会带你手工打造一下语法分析器,并实现一个公式计算器的功能。
## 一课一思
很多同学已经用到过正则表达式,这是学计算机必懂的知识点,十分有用。正则表达式工具其实就可以看做一个通用的词法分析器。那么你都用正则表达式功能做过哪些事情?有没有发现一些软件工具因为支持正则表达式而变得特别强大的情况呢?可以在留言区与大家一起交流。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
另外,为了便于你更好地学习,我将本节课的示例程序放到了[GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/craft/SimpleLexer.java)上,你可以看一下。

View File

@@ -0,0 +1,326 @@
<audio id="audio" title="03 | 语法分析(一):纯手工打造公式计算器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/23/b6841e1f4becc7a7663d85d739b51d23.mp3"></audio>
我想你应该知道公式是Excel电子表格软件的灵魂和核心。除此之外在HR软件中可以用公式自定义工资。而且如果你要开发一款通用报表软件也会大量用到自定义公式来计算报表上显示的数据。总而言之很多高级一点儿的软件都会用到自定义公式功能。
既然公式功能如此常见和重要,我们不妨实现一个公式计算器,给自己的软件添加自定义公式功能吧!
本节课将继续“手工打造”之旅,让你纯手工实现一个公式计算器,借此掌握**语法分析的原理**和**递归下降算法Recursive Descent Parsing<strong>并初步了解**上下文无关文法Context-free GrammarCFG</strong>
我所举例的公式计算器支持加减乘除算术运算比如支持“2 + 3 * 5”的运算。
在学习语法分析时,我们习惯把上面的公式称为表达式。这个表达式看上去很简单,但你能借此学到很多语法分析的原理,例如左递归、优先级和结合性等问题。
当然了,要实现上面的表达式,你必须能分析它的语法。不过在此之前,我想先带你解析一下变量声明语句的语法,以便让你循序渐进地掌握语法分析。
## 解析变量声明语句:理解“下降”的含义
在“[01 | 理解代码:编译器的前端技术](https://time.geekbang.org/column/article/118132)”里我提到语法分析的结果是生成AST。算法分为自顶向下和自底向上算法其中递归下降算法是一种常见的自顶向下算法。
与此同时我给出了一个简单的代码示例也针对“int age = 45”这个语句画了一个语法分析算法的示意图
<img src="https://static001.geekbang.org/resource/image/cb/16/cbf2b953cb84ef30b154470804262c16.jpg" alt="">
我们首先把变量声明语句的规则用形式化的方法表达一下。它的左边是一个非终结符Non-terminal。右边是它的产生式Production Rule。在语法解析的过程中左边会被右边替代。如果替代之后还有非终结符那么继续这个替代过程直到最后全部都是终结符Terminal也就是Token。只有终结符才可以成为AST的叶子节点。这个过程也叫做推导Derivation过程
```
intDeclaration : Int Identifier ('=' additiveExpression)?;
```
你可以看到int类型变量的声明需要有一个Int型的Token加一个变量标识符后面跟一个可选的赋值表达式。我们把上面的文法翻译成程序语句伪代码如下
```
//伪代码
MatchIntDeclare(){
MatchToken(Int) //匹配Int关键字
MatchIdentifier(); //匹配标识符
MatchToken(equal); //匹配等号
MatchExpression(); //匹配表达式
}
```
实际代码在SimpleCalculator.java类的IntDeclare()方法中:
```
SimpleASTNode node = null;
Token token = tokens.peek(); //预读
if (token != null &amp;&amp; token.getType() == TokenType.Int) { //匹配Int
token = tokens.read(); //消耗掉int
if (tokens.peek().getType() == TokenType.Identifier) { //匹配标识符
token = tokens.read(); //消耗掉标识符
//创建当前节点并把变量名记到AST节点的文本值中
//这里新建一个变量子节点也是可以的
node = new SimpleASTNode(ASTNodeType.IntDeclaration, token.getText());
token = tokens.peek(); //预读
if (token != null &amp;&amp; token.getType() == TokenType.Assignment) {
tokens.read(); //消耗掉等号
SimpleASTNode child = additive(tokens); //匹配一个表达式
if (child == null) {
throw new Exception(&quot;invalide variable initialization, expecting an expression&quot;);
}
else{
node.addChild(child);
}
}
} else {
throw new Exception(&quot;variable name expected&quot;);
}
}
```
直白地描述一下上面的算法:
>
解析变量声明语句时我先看第一个Token是不是int。如果是那我创建一个AST节点记下int后面的变量名称然后再看后面是不是跟了初始化部分也就是等号加一个表达式。我们检查一下有没有等号有的话接着再匹配一个表达式。
我们通常会对产生式的每个部分建立一个子节点比如变量声明语句会建立四个子节点分别是int关键字、标识符、等号和表达式。后面的工具就是这样严格生成AST的。但是我这里做了简化只生成了一个子节点就是表达式子节点。变量名称记到ASTNode的文本值里去了其他两个子节点没有提供额外的信息就直接丢弃了。
另外从上面的代码中我们看到程序是从一个Token的流中顺序读取。代码中的peek()方法是预读只是读取下一个Token但并不把它从Token流中移除。在代码中我们用peek()方法可以预先看一下下一个Token是否是等号从而知道后面跟着的是不是一个表达式。而read()方法会从Token流中移除下一个Token变成了当前的Token。
这里需要注意的是通过peek()方法来预读,实际上是对代码的优化,这有点儿预测的意味。我们后面会讲带有预测的自顶向下算法,它能减少回溯的次数。
我们把解析变量声明语句和表达式的算法分别写成函数。在语法分析的时候调用这些函数跟后面的Token串做模式匹配。匹配上了就返回一个AST节点否则就返回null。如果中间发现跟语法规则不符就报编译错误。
在这个过程中上级文法嵌套下级文法上级的算法调用下级的算法。表现在生成AST中上级算法生成上级节点下级算法生成下级节点。**这就是“下降”的含义。**
分析上面的伪代码和程序语句,你可以看到这样的特点:**程序结构基本上是跟文法规则同构的。这就是递归下降算法的优点,非常直观。**
接着说回来我们继续运行这个示例程序输出AST
```
Programm Calculator
IntDeclaration age
AssignmentExp =
IntLiteral 45
```
前面的文法和算法都很简单,这样级别的文法没有超出正则文法。也就是说,并没有超出我们做词法分析时用到的文法。
好了,解析完变量声明语句,带你理解了“下降”的含义之后,我们来看看如何用上下文无关文法描述算术表达式。
## 用上下文无关文法描述算术表达式
我们解析算术表达式的时候,会遇到更复杂的情况,这时,正则文法不够用,我们必须用上下文无关文法来表达。你可能会问:“正则文法为什么不能表示算术表达式?”别着急,我们来分析一下算术表达式的语法规则。
算术表达式要包含加法和乘法两种运算(简单起见,我们把减法与加法等同看待,把除法也跟乘法等同看待),加法和乘法运算有不同的优先级。我们的规则要能匹配各种可能的算术表达式:
- 2+3*5
- 2*3+5
- 2*3
- ……
思考一番之后我们把规则分成两级第一级是加法规则第二级是乘法规则。把乘法规则作为加法规则的子规则这样在解析形成AST时乘法节点就一定是加法节点的子节点从而被优先计算。
```
additiveExpression
: multiplicativeExpression
| additiveExpression Plus multiplicativeExpression
;
multiplicativeExpression
: IntLiteral
| multiplicativeExpression Star IntLiteral
;
```
你看我们可以通过文法的嵌套实现对运算优先级的支持。这样我们在解析“2 + 3 * 5”这个算术表达式时会形成类似下面的AST
<img src="https://static001.geekbang.org/resource/image/5e/1c/5ed231aced0b65b8c0d343b86634401c.jpg" alt="">
如果要计算表达式的值只需要对根节点求值就可以了。为了完成对根节点的求值需要对下级节点递归求值所以我们先完成“3 * 5 = 15”然后再计算“2 + 15 = 17”。
有了这个认知,我们在解析算术表达式的时候,便能拿加法规则去匹配。在加法规则中,会嵌套地匹配乘法规则。我们通过文法的嵌套,实现了计算的优先级。
应该注意的是加法规则中还递归地又引用了加法规则。通过这种递归的定义我们能展开、形成所有各种可能的算术表达式。比如“2+3*5” 的推导过程:
```
--&gt;additiveExpression + multiplicativeExpression
--&gt;multiplicativeExpression + multiplicativeExpression
--&gt;IntLiteral + multiplicativeExpression
--&gt;IntLiteral + multiplicativeExpression * IntLiteral
--&gt;IntLiteral + IntLiteral * IntLiteral
```
这种文法已经没有办法改写成正则文法了,它比正则文法的表达能力更强,叫做**“上下文无关文法”。**正则文法是上下文无关文法的一个子集。它们的区别呢,就是上下文无关文法允许递归调用,而正则文法不允许。
上下文无关的意思是,无论在任何情况下,文法的推导规则都是一样的。比如,在变量声明语句中可能要用到一个算术表达式来做变量初始化,而在其他地方可能也会用到算术表达式。不管在什么地方,算术表达式的语法都一样,都允许用加法和乘法,计算优先级也不变。好在你见到的大多数计算机语言,都能用上下文无关文法来表达它的语法。
那有没有上下文相关的情况需要处理呢?也是有的,但那不是语法分析阶段负责的,而是放在语义分析阶段来处理的。
## 解析算术表达式:理解“递归”的含义
在讲解上下文无关文法时,我提到了文法的递归调用,你也许会问,是否在算法上也需要递归的调用呢?要不怎么叫做“递归下降算法”呢?
的确,我们之前的算法只算是用到了“下降”,没有涉及“递归”,现在,我们就来看看如何用递归的算法翻译递归的文法。
我们先按照前面说的,把文法直观地翻译成算法。但是,我们遇到麻烦了。这个麻烦就是出现了无穷多次调用的情况。我们来看个例子。
为了简单化,我们采用下面这个简化的文法,去掉了乘法的层次:
```
additiveExpression
: IntLiteral
| additiveExpression Plus IntLiteral
;
```
在解析 “2 + 3”这样一个最简单的加法表达式的时候我们直观地将其翻译成算法结果出现了如下的情况
- 首先匹配是不是整型字面量,发现不是;
- 然后匹配是不是加法表达式,这里是递归调用;
- 会重复上面两步,无穷无尽。
“additiveExpression Plus multiplicativeExpression”这个文法规则的第一部分就递归地引用了自身这种情况叫做**左递归。**通过上面的分析,我们知道左递归是递归下降算法无法处理的,这是递归下降算法最大的问题。
怎么解决呢把“additiveExpression”调换到加号后面怎么样我们来试一试。
```
additiveExpression
: multiplicativeExpression
| multiplicativeExpression Plus additiveExpression
;
```
我们接着改写成算法,这个算法确实不会出现无限调用的问题:
```
private SimpleASTNode additive(TokenReader tokens) throws Exception {
SimpleASTNode child1 = multiplicative(); //计算第一个子节点
SimpleASTNode node = child1; //如果没有第二个子节点,就返回这个
Token token = tokens.peek();
if (child1 != null &amp;&amp; token != null) {
if (token.getType() == TokenType.Plus) {
token = tokens.read();
SimpleASTNode child2 = additive(); //递归地解析第二个节点
if (child2 != null) {
node = new SimpleASTNode(ASTNodeType.AdditiveExp, token.getText());
node.addChild(child1);
node.addChild(child2);
} else {
throw new Exception(&quot;invalid additive expression, expecting the right part.&quot;);
}
}
}
return node;
}
```
为了便于你理解,我解读一下上面的算法:
>
我们先尝试能否匹配乘法表达式如果不能那么这个节点肯定不是加法节点因为加法表达式的两个产生式都必须首先匹配乘法表达式。遇到这种情况返回null就可以了调用者就这次匹配没有成功。如果乘法表达式匹配成功那就再尝试匹配加号右边的部分也就是去递归地匹配加法表达式。如果匹配成功就构造一个加法的ASTNode返回。
同样的,乘法的文法规则也可以做类似的改写:
```
multiplicativeExpression
: IntLiteral
| IntLiteral Star multiplicativeExpression
;
```
现在我们貌似解决了左递归问题,运行这个算法解析 “2+3*5”得到下面的AST
```
Programm Calculator
AdditiveExp +
IntLiteral 2
MulticativeExp *
IntLiteral 3
IntLiteral 5
```
是不是看上去一切正常可如果让这个程序解析“2+3+4”呢
```
Programm Calculator
AdditiveExp +
IntLiteral 2
AdditiveExp +
IntLiteral 3
IntLiteral 4
```
问题是什么呢计算顺序发生错误了。连续相加的表达式要从左向右计算这是加法运算的结合性规则。但按照我们生成的AST变成从右向左了先计算了“3+4”然后才跟“2”相加。这可不行
为什么产生上面的问题呢是因为我们修改了文法把文法中加号左右两边的部分调换了一下。造成的影响是什么呢你可以推导一下“2+3+4”的解析过程
- 首先调用乘法表达式匹配函数multiplicative()成功返回了一个字面量节点2。
- 接着看看右边是否能递归地匹配加法表达式。
- 匹配的结果真的返回了一个加法表达式“3+4”这个变成了第二个子节点。错误就出在这里了。这样的匹配顺序“3+4”一定会成为子节点在求值时被优先计算。
所以,我们前面的方法其实并没有完美地解决左递归,因为它改变了加法运算的结合性规则。那么,我们能否既解决左递归问题,又不产生计算顺序的错误呢?答案是肯定的。不过我们下一讲再来解决它。目前先忍耐一下,凑合着用这个“半吊子”的算法吧。
## 实现表达式求值
上面帮助你理解了“递归”的含义接下来我要带你实现表达式的求值。其实要实现一个表达式计算只需要基于AST做求值运算。这个计算过程比较简单只需要对这棵树做深度优先的遍历就好了。
深度优先的遍历也是一个递归算法。以上文中“2 + 3 * 5”的AST为例看一下。
- 对表达式的求值等价于对AST根节点求值。
- 首先求左边子节点算出是2。
- 接着对右边子节点求值这时候需要递归计算下一层。计算完了以后返回是153*5
- 把左右节点相加计算出根节点的值17。
代码参见SimpleCalculator.Java中的evaluate()方法。
还是以“2+3*5”为例。它的求值过程输出如下你可以看到求值过程中遍历了整棵树
```
Calculating: AdditiveExp //计算根节点
Calculating: IntLiteral //计算第一个子节点
Result: 2 //结果是2
Calculating: MulticativeExp //递归计算第二个子节点
Calculating: IntLiteral
Result: 3
Calculating: IntLiteral
Result: 5
Result: 15 //忽略递归的细节得到结果是15
Result: 17 //根节点的值是17
```
你可以运行一下示例程序看看输出结果,而且我十分建议你修改表达式,自己做做实验,并试着让表达式不符合语法,看看语法分析程序能不能找出错误来。
## 课程小结
今天我们实现了一个简单的公式计算器,尽管简单,相信你已经有了收获。那么我来总结一下今天的重点:
- 初步了解上下文无关文法,知道它能表达主流的计算机语言,以及与正则文法的区别。
- 理解递归下降算法中的“下降”和“递归”两个特点。它跟文法规则基本上是同构的,通过文法一定能写出算法。
- 通过遍历AST对表达式求值加深对计算机程序执行机制的理解。
在后面的课程中,我们会在此基础上逐步深化,比如在变量声明中可以使用表达式,在表达式中可以使用变量,例如能够执行像这样的语句:
```
int A = 17
int B = A + 10*2;
```
实现了上述功能以后,这个程序就越来越接近一个简单的脚本解释器了!当然,在此之前,我们还必须解决左递归的问题。所以下一讲,我会带你填掉左递归这个坑。我们学习和工作的过程,就是在不停地挖坑、填坑,你要有信心,只要坚强走过填坑这段路,你的职业生涯将会愈发平坦!
## 一课一思
递归算法是很好的自顶向下解决问题的方法,是计算机领域的一个核心的思维方式。拥有这种思维方式,可以说是程序员相对于非程序员的一种优势。
那么,你是否用递归算法或递归思维解决过工作中或者生活中存在的某些问题?你能否再找一些证据证明一下,哪些语法规则只能用上下文无关文法表达,用正则文法是怎样都写不出来的? 欢迎在留言区和我一起讨论。
最后,十分感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
另外,为了便于你更好地学习,我将本节课的示例程序放到了[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/craft/SimpleCalculator.java)和[GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/craft/SimpleCalculator.java)上,你可以看一下。

View File

@@ -0,0 +1,211 @@
<audio id="audio" title="04 | 语法分析(二):解决二元表达式中的难点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/e7/be331dde13aa0d446ba5369ffedab7e7.mp3"></audio>
在“[03 | 语法分析(一):纯手工打造公式计算器](https://time.geekbang.org/column/article/119891)”中,我们已经初步实现了一个公式计算器。而且你还在这个过程中,直观地获得了写语法分析程序的体验,在一定程度上破除了对语法分析算法的神秘感。
当然了你也遇到了一些问题比如怎么消除左递归怎么确保正确的优先级和结合性。所以本节课的主要目的就是解决这几个问题让你掌握像算术运算这样的二元表达式Binary Expression
不过在课程开始之前我想先带你简单地温习一下什么是左递归Left Recursive、优先级Priority和结合性Associativity
在二元表达式的语法规则中,如果产生式的第一个元素是它自身,那么程序就会无限地递归下去,这种情况就叫做**左递归。**比如加法表达式的产生式“加法表达式 + 乘法表达式”,就是左递归的。而优先级和结合性则是计算机语言中与表达式有关的核心概念。它们都涉及了语法规则的设计问题。
我们要想深入探讨语法规则设计,需要像在词法分析环节一样,先了解如何用形式化的方法表达语法规则。“工欲善其事必先利其器”。熟练地阅读和书写语法规则,是我们在语法分析环节需要掌握的一项基本功。
所以本节课我会先带你了解如何写语法规则,然后在此基础上,带你解决上面提到的三个问题。
## 书写语法规则,并进行推导
我们已经知道,语法规则是由上下文无关文法表示的,而上下文无关文法是由一组替换规则(又叫产生式)组成的,比如算术表达式的文法规则可以表达成下面这种形式:
```
add -&gt; mul | add + mul
mul -&gt; pri | mul * pri
pri -&gt; Id | Num | (add)
```
按照上面的产生式add可以替换成mul或者add + mul。这样的替换过程又叫做“推导”。以“2+3*5” 和 “2+3+4”这两个算术表达式为例这两个算术表达式的推导过程分别如下图所示
<img src="https://static001.geekbang.org/resource/image/e9/81/e9aa620c009aaae5505cf568a54de381.jpg" alt="">
通过上图的推导过程你可以清楚地看到这两个表达式是怎样生成的。而分析过程中形成的这棵树其实就是AST。只不过我们手写的算法在生成AST的时候通常会做一些简化省略掉中间一些不必要的节点。比如“add-add-mul-pri-Num”这一条分支实际手写时会被简化成“add-Num”。其实简化AST也是优化编译过程的一种手段如果不做简化呈现的效果就是上图的样子。
那么上图中两颗树的叶子节点有哪些呢Num、+和*都是终结符终结符都是词法分析中产生的Token。而那些非叶子节点就是非终结符。文法的推导过程就是把非终结符不断替换的过程让最后的结果没有非终结符只有终结符。
而在实际应用中,语法规则经常写成下面这种形式:
```
add ::= mul | add + mul
mul ::= pri | mul * pri
pri ::= Id | Num | (add)
```
这种写法叫做**“巴科斯范式”,**简称BNF。Antlr和Yacc这两个工具都用这种写法。为了简化书写我有时会在课程中把“::=”简化成一个冒号。你看到的时候,知道是什么意思就可以了。
你有时还会听到一个术语,叫做**扩展巴科斯范式(EBNF)。**它跟普通的BNF表达式最大的区别就是里面会用到类似正则表达式的一些写法。比如下面这个规则中运用了*号来表示这个部分可以重复0到多次
```
add -&gt; mul (+ mul)*
```
其实这种写法跟标准的BNF写法是等价的但是更简洁。为什么是等价的呢因为一个项多次重复就等价于通过递归来推导。从这里我们还可以得到一个推论就是上下文无关文法包含了正则文法比正则文法能做更多的事情。
## 确保正确的优先级
掌握了语法规则的写法之后我们来看看如何用语法规则来保证表达式的优先级。刚刚我们由加法规则推导到乘法规则这种方式保证了AST中的乘法节点一定会在加法节点的下层也就保证了乘法计算优先于加法计算。
听到这儿,你一定会想到,我们应该把关系运算(&gt;、=、&lt;放在加法的上层逻辑运算and、or放在关系运算的上层。的确如此我们试着将它写出来
```
exp -&gt; or | or = exp
or -&gt; and | or || and
and -&gt; equal | and &amp;&amp; equal
equal -&gt; rel | equal == rel | equal != rel
rel -&gt; add | rel &gt; add | rel &lt; add | rel &gt;= add | rel &lt;= add
add -&gt; mul | add + mul | add - mul
mul -&gt; pri | mul * pri | mul / pri
```
这里表达的优先级从低到高是赋值运算、逻辑运算or、逻辑运算and、相等比较equal、大小比较rel、加法运算add、乘法运算mul和基础表达式pri
实际语言中还有更多不同的优先级,比如位运算等。而且优先级是能够改变的,比如我们通常会在语法里通过括号来改变计算的优先级。不过这怎么表达成语法规则呢?
其实我们在最低层也就是优先级最高的基础表达式pri这里用括号把表达式包裹起来递归地引用表达式就可以了。这样的话只要在解析表达式的时候遇到括号那么就知道这个是最优先的。这样的话就实现了优先级的改变
```
pri -&gt; Id | Literal | (exp)
```
了解了这些内容之后到目前为止你已经会写整套的表达式规则了也能让公式计算器支持这些规则了。另外在使用一门语言的时候如果你不清楚各种运算确切的优先级除了查阅常规的资料你还多了一项新技能就是阅读这门语言的语法规则文件这些规则可能就是用BNF或EBNF的写法书写的。
弄明白优先级的问题以后,我们再来讨论一下结合性这个问题。
## 确保正确的结合性
在上一讲中我针对算术表达式写的第二个文法是错的因为它的计算顺序是错的。“2+3+4”这个算术表达式先计算了“3+4”然后才和“2”相加计算顺序从右到左正确的应该是从左往右才对。
**这就是运算符的结合性问题。**什么是结合性呢?同样优先级的运算符是从左到右计算还是从右到左计算叫做结合性。我们常见的加减乘除等算术运算是左结合的,“.”符号也是左结合的。
比如“rectangle.center.x” 是先获得长方形rectangle的中心点center再获得这个点的x坐标。计算顺序是从左向右的。那有没有右结合的例子呢肯定是有的。赋值运算就是典型的右结合的例子比如“x = y = 10”。
我们再来回顾一下“2+3+4”计算顺序出错的原因。用之前错误的右递归的文法解析这个表达式形成的简化版本的AST如下
<img src="https://static001.geekbang.org/resource/image/db/16/db287af5a94ac03c6528fb6ed3767116.jpg" alt="">
根据这个AST做计算会出现计算顺序的错误。不过如果我们将递归项写在左边就不会出现这种结合性的错误。于是我们得出一个规律**对于左结合的运算符,递归项要放在左边;而右结合的运算符,递归项放在右边。**
所以你能看到,我们在写加法表达式的规则的时候,是这样写的:
```
add -&gt; mul | add + mul
```
这是我们犯错之后所学到的知识。那么问题来了,大多数二元运算都是左结合的,那岂不是都要面临左递归问题?不用担心,我们可以通过改写左递归的文法,解决这个问题。
## 消除左递归
我提到过左递归的情况也指出递归下降算法不能处理左递归。这里我要补充一点并不是所有的算法都不能处理左递归对于另外一些算法左递归是没有问题的比如LR算法。
消除左递归用一个标准的方法就能够把左递归文法改写成非左递归的文法。以加法表达式规则为例原来的文法是“add -&gt; add + mul”现在我们改写成
```
add -&gt; mul add'
add' -&gt; + mul add' | ε
```
文法中ε读作epsilon是空集的意思。接下来我们用刚刚改写的规则再次推导一下 “2+3+4”这个表达式得到了下图中左边的结果
<img src="https://static001.geekbang.org/resource/image/50/22/50a501fc747b23aa0dca319fa87e6622.jpg" alt="">
左边的分析树是推导后的结果。问题是由于add的规则是右递归的如果用标准的递归下降算法我们会跟上一讲一样又会出现运算符结合性的错误。我们期待的AST是右边的那棵它的结合性才是正确的。那么有没有解决办法呢
答案是有的。我们仔细分析一下上面语法规则的推导过程。只有第一步是按照add规则推导之后都是按照add规则推导一直到结束。
如果用EBNF方式表达也就是允许用*号和+号表示重复,上面两条规则可以合并成一条:
```
add -&gt; mul (+ mul)*
```
写成这样有什么好处呢?能够优化我们写算法的思路。对于(+ mul)*这部分,我们其实可以写成一个循环,而不是一次次的递归调用。伪代码如下:
```
mul();
while(next token is +){
mul()
createAddNode
}
```
我们扩展一下话题。在研究递归函数的时候,有一个概念叫做**尾递归,**尾递归函数的最后一句是递归地调用自身。
编译程序通常都会把尾递归转化为一个循环语句,使用的原理跟上面的伪代码是一样的。相对于递归调用来说,循环语句对系统资源的开销更低,因此,把尾递归转化为循环语句也是一种编译优化技术。
好了,我们继续左递归的话题。现在我们知道怎么写这种左递归的算法了,大概是下面的样子:
```
private SimpleASTNode additive(TokenReader tokens) throws Exception {
SimpleASTNode child1 = multiplicative(tokens); //应用add规则
SimpleASTNode node = child1;
if (child1 != null) {
while (true) { //循环应用add'
Token token = tokens.peek();
if (token != null &amp;&amp; (token.getType() == TokenType.Plus || token.getType() == TokenType.Minus)) {
token = tokens.read(); //读出加号
SimpleASTNode child2 = multiplicative(tokens); //计算下级节点
node = new SimpleASTNode(ASTNodeType.Additive, token.getText());
node.addChild(child1); //注意,新节点在顶层,保证正确的结合性
node.addChild(child2);
child1 = node;
} else {
break;
}
}
}
return node;
}
```
修改完后再次运行语法分析器分析“2+3+4+5”会得到正确的AST
```
Programm Calculator
AdditiveExp +
AdditiveExp +
AdditiveExp +
IntLiteral 2
IntLiteral 3
IntLiteral 4
IntLiteral 5
```
这样,我们就把左递归问题解决了。左递归问题是我们用递归下降算法写语法分析器遇到的最大的一只“拦路虎”。解决这只“拦路虎”以后,你的道路将会越来越平坦。
## 课程小结
今天我们针对优先级、结合性和左递归这三个问题做了更系统的研究。我来带你梳理一下本节课的重点知识:
- 优先级是通过在语法推导中的层次来决定的,优先级越低的,越先尝试推导。
- 结合性是跟左递归还是右递归有关的,左递归导致左结合,右递归导致右结合。
- 左递归可以通过改写语法规则来避免而改写后的语法又可以表达成简洁的EBNF格式从而启发我们用循环代替右递归。
为了研究和解决这三个问题我们还特别介绍了语法规则的产生式写法以及BNF、EBNF写法。在后面的课程中我们会不断用到这个技能还会用工具来生成语法分析器我们提供给工具的就是书写良好的语法规则。
到目前为止,你已经闯过了语法分析中比较难的一关。再增加一些其他的语法,你就可以实现出一个简单的脚本语言了!
## 一课一思
本节课提到了语法的优先级、结合性。那么,你能否梳理一下你熟悉的语言的运算优先级?你能说出更多的左结合、右结合的例子吗?可以在留言区与大家一起交流。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,288 @@
<audio id="audio" title="05 | 语法分析(三):实现一门简单的脚本语言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/4e/28181825ec4e4a7df69bf55a2d03ff4e.mp3"></audio>
前两节课结束后,我们已经掌握了表达式的解析,并通过一个简单的解释器实现了公式的计算。但这个解释器还是比较简单的,看上去还不大像一门语言。那么如何让它支持更多的功能,更像一门脚本语言呢?本节课,我会带你寻找答案。
我将继续带你实现一些功能,比如:
- 支持变量声明和初始化语句就像“int age” “int age = 45”和“int age = 17+8+20”
- 支持赋值语句“age = 45”
- 在表达式中可以使用变量例如“age + 10 *2”
- 实现一个命令行终端,能够读取输入的语句并输出结果。
实现这些功能之后,我们的成果会更像一个脚本解释器。而且在这个过程中,我还会带你巩固语法分析中的递归下降算法,和你一起讨论“回溯”这个特征,让你对递归下降算法的特征理解得更加全面。
不过,为了实现这些新的语法,我们首先要把它们用语法规则描述出来。
## 增加所需要的语法规则
首先一门脚本语言是要支持语句的比如变量声明语句、赋值语句等等。单独一个表达式也可以视为语句叫做“表达式语句”。你在终端里输入2+3就能回显出5来这就是表达式作为一个语句在执行。按照我们的语法无非是在表达式后面多了个分号而已。C语言和Java都会采用分号作为语句结尾的标识我们也可以这样写。
我们用扩展巴科斯范式EBNF写出下面的语法规则
```
programm: statement+;
statement
: intDeclaration
| expressionStatement
| assignmentStatement
;
```
**变量声明语句**以int开头后面跟标识符然后有可选的初始化部分也就是一个等号和一个表达式最后再加分号
```
intDeclaration : 'int' Id ( '=' additiveExpression)? ';';
```
**表达式语句**目前只支持加法表达式,未来可以加其他的表达式,比如条件表达式,它后面同样加分号:
```
expressionStatement : additiveExpression ';';
```
**赋值语句**是标识符后面跟着等号和一个表达式,再加分号:
```
assignmentStatement : Identifier '=' additiveExpression ';';
```
为了在表达式中可以使用变量我们还需要把primaryExpression改写除了包含整型字面量以外还要包含标识符和用括号括起来的表达式
```
primaryExpression : Identifier| IntLiteral | '(' additiveExpression ')';
```
这样,我们就把想实现的语法特性,都用语法规则表达出来了。接下来,我们就一步一步实现这些特性。
## 让脚本语言支持变量
之前实现的公式计算器只支持了数字字面量的运算,如果能在表达式中用上变量,会更有用,比如能够执行下面两句:
```
int age = 45;
age + 10 * 2;
```
这两个语句里面的语法特性包含了变量声明、给变量赋值,以及在表达式里引用变量。为了给变量赋值,我们必须在脚本语言的解释器中开辟一个存储区,记录不同的变量和它们的值:
```
private HashMap&lt;String, Integer&gt; variables = new HashMap&lt;String, Integer&gt;();
```
我们简单地用了一个HashMap作为变量存储区。在变量声明语句和赋值语句里都可以修改这个变量存储区中的数据而获取变量值可以采用下面的代码
```
if (variables.containsKey(varName)) {
Integer value = variables.get(varName); //获取变量值
if (value != null) {
result = value; //设置返回值
} else { //有这个变量,没有值
throw new Exception(&quot;variable &quot; + varName + &quot; has not been set any value&quot;);
}
}
else{ //没有这个变量。
throw new Exception(&quot;unknown variable: &quot; + varName);
}
```
通过这样的一个简单的存储机制,我们就能支持变量了。当然,这个存储机制可能过于简单了,我们后面讲到作用域的时候,这么简单的存储机制根本不够。不过目前我们先这么用着,以后再考虑改进它。
## 解析赋值语句
接下来我们来解析赋值语句例如“age = age + 10 * 2
```
private SimpleASTNode assignmentStatement(TokenReader tokens) throws Exception {
SimpleASTNode node = null;
Token token = tokens.peek(); //预读,看看下面是不是标识符
if (token != null &amp;&amp; token.getType() == TokenType.Identifier) {
token = tokens.read(); //读入标识符
node = new SimpleASTNode(ASTNodeType.AssignmentStmt, token.getText());
token = tokens.peek(); //预读,看看下面是不是等号
if (token != null &amp;&amp; token.getType() == TokenType.Assignment) {
tokens.read(); //取出等号
SimpleASTNode child = additive(tokens);
if (child == null) { //出错,等号右面没有一个合法的表达式
throw new Exception(&quot;invalide assignment statement, expecting an expression&quot;);
}
else{
node.addChild(child); //添加子节点
token = tokens.peek(); //预读,看看后面是不是分号
if (token != null &amp;&amp; token.getType() == TokenType.SemiColon) {
tokens.read(); //消耗掉这个分号
} else { //报错,缺少分号
throw new Exception(&quot;invalid statement, expecting semicolon&quot;);
}
}
}
else {
tokens.unread(); //回溯,吐出之前消化掉的标识符
node = null;
}
}
return node;
}
```
为了方便你理解,我来解读一下上面这段代码的逻辑:
>
<p>我们既然想要匹配一个赋值语句那么首先应该看看第一个Token是不是标识符。如果不是那么就返回null匹配失败。如果第一个Token确实是标识符我们就把它消耗掉接着看后面跟着的是不是等号。如果不是等号那证明我们这个不是一个赋值语句可能是一个表达式什么的。那么我们就要回退刚才消耗掉的Token就像什么都没有发生过一样并且返回null。回退的时候调用的方法就是unread()。<br>
如果后面跟着的确实是等号,那么在继续看后面是不是一个表达式,表达式后面跟着的是不是分号。如果不是,就报错就好了。这样就完成了对赋值语句的解析。</p>
利用上面的代码我们还可以改造一下变量声明语句中对变量初始化的部分让它在初始化的时候支持表达式因为这个地方跟赋值语句很像例如“int newAge = age + 10 * 2”。
## 理解递归下降算法中的回溯
不知道你有没有发现,我在设计语法规则的过程中,其实故意设计了一个陷阱,这个陷阱能帮我们更好地理解递归下降算法的一个特点:**回溯。**理解这个特点能帮助你更清晰地理解递归下降算法的执行过程,从而再去想办法优化它。
考虑一下age = 45这个语句。肉眼看过去你马上知道它是个赋值语句但是当我们用算法去做模式匹配时就会发生一些特殊的情况。看一下我们对statement语句的定义
```
statement
: intDeclaration
| expressionStatement
| assignmentStatement
;
```
我们首先尝试intDeclaration但是age = 45语句不是以int开头的所以这个尝试会返回null。然后我们接着尝试expressionStatement看一眼下面的算法
```
private SimpleASTNode expressionStatement() throws Exception {
int pos = tokens.getPosition(); //记下初始位置
SimpleASTNode node = additive(); //匹配加法规则
if (node != null) {
Token token = tokens.peek();
if (token != null &amp;&amp; token.getType() == TokenType.SemiColon) { //要求一定以分号结尾
tokens.read();
} else {
node = null;
tokens.setPosition(pos); // 回溯
}
}
return node;
}
```
出现了什么情况呢age = 45语句最左边是一个标识符。根据我们的语法规则标识符是一个合法的addtiveExpresion因此additive()函数返回一个非空值。接下来,后面应该扫描到一个分号才对,但是显然不是,标识符后面跟的是等号,这证明模式匹配失败。
失败了该怎么办呢我们的算法一定要把Token流的指针拨回到原来的位置就像一切都没发生过一样。因为我们不知道addtive()这个函数往下尝试了多少步因为它可能是一个很复杂的表达式消耗掉了很多个Token所以我们必须记下算法开始时候的位置并在失败时回到这个位置。**尝试一个规则不成功之后,恢复到原样,再去尝试另外的规则,这个现象就叫做“回溯”。**
因为有可能需要回溯所以递归下降算法有时会做一些无用功。在assignmentStatement的算法中我们就通过unread()回溯了一个Token。而在expressionStatement中我们不确定要回溯几步只好提前记下初始位置。匹配expressionStatement失败后算法去尝试匹配assignmentStatement。这次获得了成功。
试探和回溯的过程,是递归下降算法的一个典型特征。通过上面的例子,你应该对这个典型特征有了更清晰的理解。递归下降算法虽然简单,但它通过试探和回溯,却总是可以把正确的语法匹配出来,这就是它的强大之处。当然,缺点是回溯会拉低一点儿效率。但我们可以在这个基础上进行改进和优化,实现带有预测分析的递归下降,以及非递归的预测分析。有了对递归下降算法的清晰理解,我们去学习其他的语法分析算法的时候,也会理解得更快。
我们接着再讲回溯牵扯出的另一个问题:**什么时候该回溯,什么时候该提示语法错误?**
大家在阅读示例代码的过程中,应该发现里面有一些错误处理的代码,并抛出了异常。比如在赋值语句中,如果等号后面没有成功匹配一个加法表达式,我们认为这个语法是错的。因为在我们的语法中,等号后面只能跟表达式,没有别的可能性。
```
token = tokens.read(); //读出等号
node = additive(); //匹配一个加法表达式
if (node == null) {
//等号右边一定需要有另一个表达式
throw new Exception(&quot;invalide assignment expression, expecting an additive expression&quot;);
}
```
你可能会意识到一个问题,当我们在算法中匹配不成功的时候,我们前面说的是应该回溯呀,应该再去尝试其他可能性呀,为什么在这里报错了呢?换句话说,什么时候该回溯,什么时候该提示这里发生了语法错误呢?
其实这两种方法最后的结果是一样的。我们提示语法错误的时候,是说我们知道已经没有其他可能的匹配选项了,不需要浪费时间去回溯。就比如,在我们的语法中,等号后面必然跟表达式,否则就一定是语法错误。你在这里不报语法错误,等试探完其他所有选项后,还是需要报语法错误。所以说,提前报语法错误,实际上是我们写算法时的一种优化。
在写编译程序的时候,我们不仅仅要能够解析正确的语法,还要尽可能针对语法错误提供友好的提示,帮助用户迅速定位错误。错误定位越是准确、提示越是友好,我们就越喜欢它。
好了,到目前为止,已经能够能够处理几种不同的语句,如变量声明语句,赋值语句、表达式语句,那么我们把所有这些成果放到一起,来体会一下使用自己的脚本语言的乐趣吧!
我们需要一个交互式的界面来输入程序,并执行程序,这个交互式的界面就叫做**REPL。**
## 实现一个简单的REPL
脚本语言一般都会提供一个命令行窗口让你输入一条一条的语句马上解释执行它并得到输出结果比如Node.js、Python等都提供了这样的界面。**这个输入、执行、打印的循环过程就叫做REPLRead-Eval-Print Loop。**你可以在REPL中迅速试验各种语句REPL即时反馈的特征会让你乐趣无穷。所以即使是非常资深的程序员也会经常用REPL来验证自己的一些思路它相当于一个语言的PlayGround游戏场是个必不可少的工具。
在SimpleScript.java中我们也实现了一个简单的REPL。基本上就是从终端一行行的读入代码当遇到分号的时候就解释执行代码如下
```
SimpleParser parser = new SimpleParser();
SimpleScript script = new SimpleScript();
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); //从终端获取输入
String scriptText = &quot;&quot;;
System.out.print(&quot;\n&gt;&quot;); //提示符
while (true) { //无限循环
try {
String line = reader.readLine().trim(); //读入一行
if (line.equals(&quot;exit();&quot;)) { //硬编码退出条件
System.out.println(&quot;good bye!&quot;);
break;
}
scriptText += line + &quot;\n&quot;;
if (line.endsWith(&quot;;&quot;)) { //如果没有遇到分号的话,会再读一行
ASTNode tree = parser.parse(scriptText); //语法解析
if (verbose) {
parser.dumpAST(tree, &quot;&quot;);
}
script.evaluate(tree, &quot;&quot;); //对AST求值并打印
System.out.print(&quot;\n&gt;&quot;); //显示一个提示符
scriptText = &quot;&quot;;
}
} catch (Exception e) { //如果发现语法错误,报错,然后可以继续执行
System.out.println(e.getLocalizedMessage());
System.out.print(&quot;\n&gt;&quot;); //提示符
scriptText = &quot;&quot;;
}
}
```
运行java craft.SimpleScript你就可以在终端里尝试各种语句了。如果是正确的语句系统马上会反馈回结果。如果是错误的语句REPL还能反馈回错误信息并且能够继续处理下面的语句。我们前面添加的处理语法错误的代码现在起到了作用下面是在我电脑上的运行情况
<img src="https://static001.geekbang.org/resource/image/bd/7a/bd7a1629ec9c6ce4d4eb474fb60d4b7a.jpg" alt="">
如果你用java craft.SimpleScript -v启动REPL则进入Verbose模式它还会每次打印出AST你可以尝试一下。
退出REPL需要在终端输入ctl+c或者调用exit()函数。我们目前的解释器并没有支持函数所以我们是在REPL里硬编码来实现exit()函数的。后面的课程里,我会带你真正地实现函数特性。
我希望你能编译一下这个程序,好好的玩一玩它,然后再修改一下源代码,增加一些你感兴趣的特性。我们学习跟打游戏一样,好玩、有趣才能驱动我们不停地学下去,一步步升级打怪。我个人觉得,我们作为软件工程师,拿出一些时间来写点儿有趣的东西作为消遣,乐趣和成就感也是很高的,况且还能提高水平。
## 课程小结
本节课我们通过对三种语句的支持实现了一个简单的脚本语言。REPL运行代码的时候你会有一种真真实实的感觉这确实是一门脚本语言了虽然它没做性能的优化但你运行的时候也还觉得挺流畅。
学完这讲以后你也能找到了一点感觉Shell脚本也好PHP也好JavaScript也好Python也好其实都可以这样写出来。
回顾过去几讲,你已经可以分析词法、语法、进行计算,还解决了左递归、优先级、结合性的问题。甚至,你还能处理语法错误,让脚本解释器不会因为输入错误而崩溃。
想必这个时候你已经开始相信我的承诺了:**每个人都可以写一个编译器。**这其实也是我最想达到的效果。相信自己,只要你不给自己设限,不设置玻璃天花板,其实你能够做出很多让自己惊讶、让自己骄傲的成就。
**收获对自己的信心,掌握编译技术,将是你学习这门课程后最大的收获!**
## 一课一思
本节课,我们设计了一个可能导致递归下降算法中回溯的情景。在你的计算机语言中,有哪些语法在运用递归下降算法的时候,也是会导致回溯的?
如果你还想进一步挑战自己,可以琢磨一下,递归下降算法的回溯,会导致多少计算时间的浪费?跟代码长度是线性关系还是指数关系?我们在后面梳理算法的时候,会涉及到这个问题。
欢迎在留言区里分享你的发现,与大家一起讨论。最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
另外第2讲到第5讲的代码都在代码库中的lab子目录的craft子目录下代码库在[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/craft/SimpleScript.java)和[GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/craft/SimpleScript.java)上都有,希望你能下载玩一玩。

View File

@@ -0,0 +1,304 @@
<audio id="audio" title="06 | 编译器前端工具用Antlr生成词法、语法分析器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/66/ae7ff2cc300ab83c9f3250f2fb435e66.mp3"></audio>
前面的课程中,我重点讲解了词法分析和语法分析,在例子中提到的词法和语法规则也是高度简化的。虽然这些内容便于理解原理,也能实现一个简单的原型,在实际应用中却远远不够。实际应用中,一个完善的编译程序还要在词法方面以及语法方面实现很多工作,我这里特意画了一张图,你可以直观地看一下。
<img src="https://static001.geekbang.org/resource/image/49/c1/49098ee32e1344550c41312862ec8ec1.jpg" alt="">
如果让编译程序实现上面这么多工作,完全手写效率会有点儿低,那么我们有什么方法可以提升效率呢?答案是借助工具。
编译器前端工具有很多比如Lex以及GNU的版本Flex、Yacc以及GNU的版本Bison、JavaCC等等。你可能会问了“那为什么我们这节课只讲Antlr不选别的工具呢”主要有两个原因。
第一个原因是Antlr能支持更广泛的目标语言包括Java、C#、JavaScript、Python、Go、C++、Swift。无论你用上面哪种语言都可以用它生成词法和语法分析的功能。而我们就使用它生成了Java语言和C++语言两个版本的代码。
第二个原因是Antlr的语法更加简单。它能把类似左递归的一些常见难点在工具中解决对提升工作效率有很大的帮助。这一点你会在后面的课程中直观地感受到。
而我们今天的目标就是了解Antlr然后能够使用Antlr生成词法分析器与语法分析器。在这个过程中我还会带你借鉴成熟的词法和语法规则让你快速成长。
接下来我们先来了解一下Antlr这个工具。
## 初识Antlr
Antlr是一个开源的工具支持根据规则文件生成词法分析器和语法分析器它自身是用Java实现的。
你可以[下载Antlr工具](https://www.antlr.org/)并根据说明做好配置。同时你还需要配置好机器上的Java环境可以在[Oracle官网](https://www.oracle.com/index.html)找到最新版本的JDK
因为我用的是Mac所以我用macOS平台下的软件包管理工具Homebrew安装了Antlr它可以自动设置好antlr和grun两个命令antlr和grun分别是java org.antlr.v4.Tool和java org.antlr.v4.gui.TestRig这两个命令的别名。这里需要注意的是你要把Antlr的JAR文件设置到CLASSPATH环境变量中以便顺利编译所生成的Java源代码。
[GitHub](https://github.com/antlr/grammars-v4)上还有很多供参考的语法规则,你可以下载到本地硬盘随时查阅。
现在你已经对Antlr有了初步的了解也知道如何安装它了。接下来我带你实际用一用Antlr让你用更轻松的方式生成词法分析器和语法分析器。
## 用Antlr生成词法分析器
你可能对Antlr还不怎么熟悉所以我会先带你使用前面课程中你已经比较熟悉的那些词法规则让Antlr生成一个新的词法分析器然后再借鉴一些成熟的规则文件把词法分析器提升到更加专业、实用的级别。
Antlr通过解析规则文件来生成编译器。规则文件以.g4结尾词法规则和语法规则可以放在同一个文件里。不过为了清晰起见我们还是把它们分成两个文件先用一个文件编写词法规则。
**为了让你快速进入状态,我们先做一个简单的练习预热一下。**我们创建一个Hello.g4文件用于保存词法规则然后把之前用过的一些词法规则写进去。
```
lexer grammar Hello; //lexer关键字意味着这是一个词法规则文件名称是Hello要与文件名相同
//关键字
If : 'if';
Int : 'int';
//字面量
IntLiteral: [0-9]+;
StringLiteral: '&quot;' .*? '&quot;' ; //字符串字面量
//操作符
AssignmentOP: '=' ;
RelationalOP: '&gt;'|'&gt;='|'&lt;' |'&lt;=' ;
Star: '*';
Plus: '+';
Sharp: '#';
SemiColon: ';';
Dot: '.';
Comm: ',';
LeftBracket : '[';
RightBracket: ']';
LeftBrace: '{';
RightBrace: '}';
LeftParen: '(';
RightParen: ')';
//标识符
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*;
//空白字符,抛弃
Whitespace: [ \t]+ -&gt; skip;
Newline: ( '\r' '\n'?|'\n')-&gt; skip;
```
你能很直观地看到每个词法规则都是大写字母开头这是Antlr对词法规则的约定。而语法规则是以小写字母开头的。其中每个规则都是用我们已经了解的正则表达式编写的。
接下来,我们来编译词法规则,在终端中输入命令:
```
antlr Hello.g4
```
这个命令是让Antlr编译规则文件并生成Hello.java文件和其他两个辅助文件。你可以打开看一看文件里面的内容。接着我用下面的命令编译Hello.java
```
javac *.java
```
结果会生成Hello.class文件这就是我们生成的词法分析器。接下来我们来写个脚本文件让生成的词法分析器解析一下
```
int age = 45;
if (age &gt;= 17+8+20){
printf(&quot;Hello old man!&quot;);
}
```
我们将上面的脚本存成hello.play文件然后在终端输入下面的命令
```
grun Hello tokens -tokens hello.play
```
grun命令实际上是调用了我们刚才生成的词法分析器即Hello类打印出对hello.play词法分析的结果
<img src="https://static001.geekbang.org/resource/image/dc/e9/dc9f9dcebd4c73eecd05fece12ba38e9.jpg" alt="">
从结果中看到我们的词法分析器把每个Token都识别了还记录了它们在代码中的位置、文本值、类别。上面这些都是Token的属性。
以第二行[@1, 4:6=age,&lt; Id &gt;,1:4]为例,其中@1是Token的流水编号表明这是1号Token4:6是Token在字符流中的开始和结束位置age是文本值Id是其Token类别最后的1:4表示这个Token在源代码中位于第1行、第4列。
非常好现在我们已经让Antlr顺利跑起来了接下来让词法规则更完善、更严密一些吧**怎么做呢?当然是参考成熟的规则文件。**
从Antlr的一些示范性的规则文件中我选了Java的作为参考。先看看我们之前写的字符串字面量的规则
```
StringLiteral: '&quot;' .*? '&quot;' ; //字符串字面量
```
我们的版本相当简化,就是在双引号可以包含任何字符。可这在实际中不大好用,因为连转义功能都没有提供。我们对于一些不可见的字符,比如回车,要提供转义功能,如“\n”。同时如果字符串里本身有双引号的话也要将它转义如“\”。Unicode也要转义。最后转义字符本身也需要转义如“\\”。
下面这一段内容是Java语言中的字符串字面量的完整规则。你可以看一下文稿这个规则就很细致了把各种转义的情况都考虑进去了
```
STRING_LITERAL: '&quot;' (~[&quot;\\\r\n] | EscapeSequence)* '&quot;';
fragment EscapeSequence
: '\\' [btnfr&quot;'\\]
| '\\' ([0-3]? [0-7])? [0-7]
| '\\' 'u'+ HexDigit HexDigit HexDigit HexDigit
;
fragment HexDigit
: [0-9a-fA-F]
;
```
在这个规则文件中fragment指的是一个语法片段是为了让规则定义更清晰。它本身并不生成Token只有StringLiteral规则才会生成Token。
当然了,除了字符串字面量,数字字面量、标识符的规则也可以定义得更严密。不过,因为这些规则文件都很严密,写出来都很长,在这里我就不一一展开了。如果感兴趣,我推荐你在下载的规则文件中找到这些部分看一看。你还可以参考不同作者写的词法规则,体会一下他们的设计思路。和高手过招,会更快地提高你的水平。
我也拷贝了一些成熟的词法规则编写了一个CommonLexer.g4的规则文件这个词法规则是我们后面工作的基础它基本上已经达到了专业、实用的程度。
在带你借鉴了成熟的规则文件之后我想穿插性地讲解一下在词法规则中对Token归类的问题。在设计词法规则时你经常会遇到这个问题解决这个问题词法规则会更加完善。
在前面练习的规则文件中,我们把&gt;=、&gt;&lt;都归类为关系运算符算作同一类Token而+、*等都单独作为另一类Token。那么哪些可以归并成一类哪些又是需要单独列出的呢
其实这主要取决于语法的需要。也就是在语法规则文件里是否可以出现在同一条规则里。它们在语法层面上没有区别只是在语义层面上有区别。比如加法和减法虽然是不同的运算但它们可以同时出现在同一条语法规则中它们在运算时的特性完全一致包括优先级和结合性乘法和除法可以同时出现在乘法规则中。你把加号和减号合并成一类把乘号和除号合并成一类是可以的。把这4个运算符每个都单独作为一类也是可以的。但是不能把加号和乘号作为同一类因为它们在算术运算中的优先级不同肯定出现在不同的语法规则中。
我们再来回顾一下在“[02 | 正则文法和有限自动机:纯手工打造词法分析器](https://time.geekbang.org/column/article/118378)”里做词法分析时遇到的一个问题。当时我们分析了词法冲突的问题即标识符和关键字的规则是有重叠的。Antlr是怎么解决这个问题的呢很简单它引入了优先级的概念。在Antlr的规则文件中越是前面声明的规则优先级越高。所以我们把关键字的规则放在ID的规则前面。算法在执行的时候会首先检查是否为关键字然后才会检查是否为ID也就是标识符。
这跟我们当时构造有限自动机做词法分析是一样的。那时我们先判断是不是关键字如果不是关键字才识别为标识符。而在Antlr里仅仅通过声明的顺序就解决了这个问题省了很多事儿啊
再说个有趣的题外话。之前国内有人提“中文编程语言”的概念,也就是语法中的关键字采用中文,比如“如果”“那么”等。他们似乎觉得这样更容易理解和掌握。我不太提倡这种想法,别的不说,用中文写关键字和变量名,需要输入更多的字符,有点儿麻烦。中国的英语教育很普及,用英语来写代码,其实就够了。
不过你大可以试一下让自己的词法规则支持中文关键字。比如把“If”的规则改成同时支持英文的“if”以及中文的“如果”
```
If: 'if' | '如果';
```
再把测试用的脚本hello.play中的“if”也改成“如果”写成
```
如果 (age &gt;= 17+8+20){
```
重新生成词法分析器并运行,你会发现输出中有这么一行:
```
[@5,14:15='如果',&lt;If&gt;,2:0]
```
这个Token的文本值是“如果”但类别仍然是“If”。所以要想实现所谓的“中文编程语言”把C、Java等语言的词法规则改一改再把编译器重新编译一下就行了
## 用Antlr生成语法分析器
说回我们的话题。现在你已经知道如何用Antlr做一个词法分析器还知道可以借鉴成熟的规则文件让自己的词法规则文件变得更完善、更专业。接下来试着用Antlr生成一个语法分析器替代之前手写的语法分析器吧
这一次的文件名叫做PlayScript.g4。playscript是为我们的脚本语言起的名称文件开头是这样的
```
grammar PlayScript;
import CommonLexer; //导入词法定义
/*下面的内容加到所生成的Java源文件的头部如包名称import语句等。*/
@header {
package antlrtest;
}
```
然后把之前做过的语法定义放进去。Antlr内部有自动处理左递归的机制你可以放心大胆地把语法规则写成下面的样子
```
expression
: assignmentExpression
| expression ',' assignmentExpression
;
assignmentExpression
: additiveExpression
| Identifier assignmentOperator additiveExpression
;
assignmentOperator
: '='
| '*='
| '/='
| '%='
| '+='
| '-='
;
additiveExpression
: multiplicativeExpression
| additiveExpression '+' multiplicativeExpression
| additiveExpression '-' multiplicativeExpression
;
multiplicativeExpression
: primaryExpression
| multiplicativeExpression '*' primaryExpression
| multiplicativeExpression '/' primaryExpression
| multiplicativeExpression '%' primaryExpression
;
```
你可能会问“既然用Antlr可以不管左递归问题那之前为什么要费力气解决它呢”那是因为当你遇到某些问题却没有现成工具时还是要用纯手工的方法去解决问题。而且有的工具可能没有这么智能你需要写出符合这个工具的规则文件比如说不能有左递归的语法规则。**还是那句话:懂得基础原理,会让你站得更高。**
我们继续运行下面的命令,生成语法分析器:
```
antlr PlayScript.g4
javac antlrtest/*.java
```
然后测试一下生成的语法分析器:
```
grun antlrtest.PlayScript expression -gui
```
这个命令的意思是测试PlayScript这个类的expression方法也就是解析表达式的方法结果用图形化界面显示。
我们在控制台界面中输入下面的内容:
```
age + 10 * 2 + 10
^D
```
其中^D是按下Ctl键的同时按下D相当于在终端输入一个EOF字符即文件结束符号Windows操作系统要使用^Z。当然你也可以提前把这些语句放到文件中把文件名作为命令参数。之后语法分析器会分析这些语法并弹出一个窗口来显示AST
<img src="https://static001.geekbang.org/resource/image/96/17/96ef2f2ca1f1465893a70e742b93fd17.jpg" alt="">
看得出来AST完全正确优先级和结合性也都没错。所以Antlr生成的语法分析器还是很靠谱的。以后你专注写语法规则就行了可以把精力放在语言的设计和应用上。
## 课程小结
今天我带你了解了Antlr并用Antlr生成了词法分析器和语法分析器。有了工具的支持你可以把主要的精力放在编写词法和语法规则上提升了工作效率。
除此之外,我带你借鉴了成熟的词法规则和语法规则。你可以将这些规则用到自己的语言设计中。采用工具和借鉴成熟规则十分重要,站在别人的肩膀上能让自己更快成长。
在后面的课程中我会带你快速实现报表工具、SQL解析器这种需要编译功能的应用。那时你就更能体会到用编译技术实现一个功能的过程是非常高效的与此同时我也会带你扩展更多的语法规则并生成一个更强大的脚本语言解释器。这样你就会实现流程控制语句接着探索函数、闭包、面向对象功能的实现机制。几节课之后你的手里就真的有一门不错的脚本语言了
## 一课一思
今天我们介绍了Antlr这个工具你有没有使用类似工具的经验在使用过程中又有什么心得或问题呢欢迎在留言区分享你的心得或问题。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本讲的示例代码位于lab/antlrtest代码链接我放在了文末供你参考。
<li>
Hello.g4用Antlr重写了前几讲的词法规则[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/Hello.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/Hello.g4)
</li>
<li>
CommonLexer.g4比较成熟的词法文件[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/CommonLexer.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/CommonLexer.g4)
</li>
<li>
PlayScript.g4用Antlr重写了前几讲的语法规则[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/PlayScript.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/PlayScript.g4)
</li>
<li>
ASTEvaluator.java对AST遍历实现整数的算术运算[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/ASTEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/ASTEvaluator.java)
</li>
<li>
PlayScript.java一个测试程序实现词法分析、语法分析、公式计算[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/PlayScript.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/antlrtest/src/antlrtest/PlayScript.java)
</li>

View File

@@ -0,0 +1,423 @@
<audio id="audio" title="07 | 编译器前端工具用Antlr重构脚本语言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/7f/ede2ed1805a1e395548c83786aedf97f.mp3"></audio>
上一讲我带你用Antlr生成了词法分析器和语法分析器也带你分析了跟一门成熟的语言相比在词法规则和语法规则方面要做的一些工作。
在词法方面我们参考Java的词法规则文件形成了一个CommonLexer.g4词法文件。在这个过程中我们研究了更完善的字符串字面量的词法规则还讲到要通过规则声明的前后顺序来解决优先级问题比如关键字的规则一定要在标识符的前面。
目前来讲,我们已经完善了词法规则,所以今天我们来补充和完善一下语法规则,看一看怎样用最高效的速度,完善语法功能。比如一天之内,我们是否能为某个需要编译技术的项目实现一个可行性原型?
而且,我还会带你熟悉一下常见语法设计的最佳实践。这样当后面的项目需要编译技术做支撑时,你就会很快上手,做出成绩了!
接下来我们先把表达式的语法规则梳理一遍让它达到成熟语言的级别然后再把语句梳理一遍包括前面几乎没有讲过的流程控制语句。最后再升级解释器用Visitor模式实现对AST的访问这样我们的代码会更清晰更容易维护了。
好了,让我们正式进入课程,先将表达式的语法完善一下吧!
## 完善表达式Expression的语法
在“[06 | 编译器前端工具用Antlr生成词法、语法分析器](https://time.geekbang.org/column/article/126910)”中我提到Antlr能自动处理左递归的问题所以在写表达式时我们可以大胆地写成左递归的形式节省时间。
但这样,我们还是要为每个运算写一个规则,逻辑运算写完了要写加法运算,加法运算写完了写乘法运算,这样才能实现对优先级的支持,还是有些麻烦。
其实Antlr能进一步地帮助我们。我们可以把所有的运算都用一个语法规则来涵盖然后用最简洁的方式支持表达式的优先级和结合性。在我建立的PlayScript.g4语法规则文件中只用了一小段代码就将所有的表达式规则描述完了
```
expression
: primary
| expression bop='.'
( IDENTIFIER
| functionCall
| THIS
)
| expression '[' expression ']'
| functionCall
| expression postfix=('++' | '--')
| prefix=('+'|'-'|'++'|'--') expression
| prefix=('~'|'!') expression
| expression bop=('*'|'/'|'%') expression
| expression bop=('+'|'-') expression
| expression ('&lt;' '&lt;' | '&gt;' '&gt;' '&gt;' | '&gt;' '&gt;') expression
| expression bop=('&lt;=' | '&gt;=' | '&gt;' | '&lt;') expression
| expression bop=INSTANCEOF typeType
| expression bop=('==' | '!=') expression
| expression bop='&amp;' expression
| expression bop='^' expression
| expression bop='|' expression
| expression bop='&amp;&amp;' expression
| expression bop='||' expression
| expression bop='?' expression ':' expression
| &lt;assoc=right&gt; expression
bop=('=' | '+=' | '-=' | '*=' | '/=' | '&amp;=' | '|=' | '^=' | '&gt;&gt;=' | '&gt;&gt;&gt;=' | '&lt;&lt;=' | '%=')
expression
;
```
这个文件几乎包括了我们需要的所有的表达式规则,包括几乎没提到的点符号表达式、递增和递减表达式、数组表达式、位运算表达式规则等,已经很完善了。
那么它是怎样支持优先级的呢?原来,优先级是通过右侧不同产生式的顺序决定的。在标准的上下文无关文法中,产生式的顺序是无关的,但在具体的算法中,会按照确定的顺序来尝试各个产生式。
你不可能一会儿按这个顺序一会儿按那个顺序。然而同样的文法按照不同的顺序来推导的时候得到的AST可能是不同的。我们需要注意这一点从文法理论的角度是无法接受的但从实践的角度是可以接受的。比如LL文法和LR文法的概念是指这个文法在LL算法或LR算法下是工作正常的。又比如我们之前做加法运算的那个文法就是递归项放在右边的那个在递归下降算法中会引起结合性的错误但是如果用LR算法就完全没有这个问题生成的AST完全正确。
```
additiveExpression
: IntLiteral
| IntLiteral Plus additiveExpression
;
```
Antlr的这个语法实际上是把产生式的顺序赋予了额外的含义用来表示优先级提供给算法。所以我们可以说这些文法是Antlr文法因为是与Antlr的算法相匹配的。当然这只是我起的一个名字方便你理解免得你产生困扰。
我们再来看看Antlr是如何依据这个语法规则实现结合性的。在语法文件中Antlr对于赋值表达式做了&lt;assoc=right&gt;的属性标注说明赋值表达式是右结合的。如果不标注就是左结合的交给Antlr实现了
我们不妨继续猜测一下Antlr内部的实现机制。我们已经分析了保证正确的结合性的算法比如把递归转化成循环然后在构造AST时确定正确的父子节点关系。那么Antlr是不是也采用了这样的思路呢或者说还有其他方法你可以去看看Antlr生成的代码验证一下。
在思考这个问题的同时你会发现,**学习原理是很有用的。**因为当你面对Antlr这样工具时能够猜出它的实现机制。
通过这个简化的算法AST被成功简化不再有加法节点、乘法节点等各种不同的节点而是统一为表达式节点。你可能会问了“如果都是同样的表达式节点怎么在解析器里把它们区分开呢怎么知道哪个节点是做加法运算或乘法运算呢
很简单我们可以查找一下当前节点有没有某个运算符的Token。比如如果出现了或者运算的Token“||”就是做逻辑或运算而且语法里面的bop=、postfix=、prefix=这些属性作为某些运算符Token的别名也会成为表达式节点的属性。通过查询这些属性的值你可以很快确定当前运算的类型。
到目前为止,我们彻底完成了表达式的语法工作,可以放心大胆地在脚本语言里使用各种表达式,把精力放在完善各类语句的语法工作上了。
## 完善各类语句Statement的语法
我先带你分析一下PlayScript.g4文件中语句的规则
```
statement
: blockLabel=block
| IF parExpression statement (ELSE statement)?
| FOR '(' forControl ')' statement
| WHILE parExpression statement
| DO statement WHILE parExpression ';'
| SWITCH parExpression '{' switchBlockStatementGroup* switchLabel* '}'
| RETURN expression? ';'
| BREAK IDENTIFIER? ';'
| SEMI
| statementExpression=expression ';'
;
```
同表达式一样一个statement规则就可以涵盖各类常用语句包括if语句、for循环语句、while循环语句、switch语句、return语句等等。表达式后面加一个分号也是一种语句叫做表达式语句。
从语法分析的难度来看,上面这些语句的语法比表达式的语法简单的多,左递归、优先级和结合性的问题这里都没有出现。这也算先难后易,苦尽甘来了吧。实际上,我们后面要设计的很多语法,都没有想象中那么复杂。
既然我们尝到了一些甜头不如趁热打铁深入研究一下if语句和for语句看看怎么写这些语句的规则多做这样的训练再看到这些语句你的脑海里就能马上反映出它的语法规则。
#### 1.研究一下if语句
在C和Java等语言中if语句通常写成下面的样子
```
if (condition)
做一件事情;
else
做另一件事情;
```
但更多情况下if和else后面是花括号起止的一个语句块比如
```
if (condition){
做一些事情;
}
else{
做另一些事情;
}
```
它的语法规则是这样的:
```
statement :
...
| IF parExpression statement (ELSE statement)?
...
;
parExpression : '(' expression ')';
```
我们用了IF和ELSE这两个关键字也复用了已经定义好的语句规则和表达式规则。你看语句规则和表达式规则一旦设计完毕就可以被其他语法规则复用多么省心
但是if语句也有让人不省心的地方比如会涉及到二义性文法问题。所以接下来我们就借if语句分析一下二义性文法这个现象。
#### 2.解决二义性文法
学计算机语言的时候提到if语句会特别提一下嵌套if语句和悬挂else的情况比如下面这段代码
```
if (a &gt; b)
if (c &gt; d)
做一些事情;
else
做另外一些事情;
```
在上面的代码中我故意取消了代码的缩进。那么你能不能看出else是跟哪个if配对的呢
一旦你语法规则写得不够好就很可能形成二义性也就是用同一个语法规则可以推导出两个不同的句子或者说生成两个不同的AST。这种文法叫做二义性文法比如下面这种写法
```
stmt -&gt; if expr stmt
| if expr stmt else stmt
| other
```
按照这个语法规则先采用第一条产生式推导或先采用第二条产生式推导会得到不同的AST。左边的这棵AST中else跟第二个if配对右边的这棵AST中else跟第一个if配对。
<img src="https://static001.geekbang.org/resource/image/58/69/589ae549366701286417475fbc361469.jpg" alt="">
大多数高级语言在解析这个示例代码时都会产生第一个AST即else跟最邻近的if配对也就是下面这段带缩进的代码表达的意思
```
if (a &gt; b)
if (c &gt; d)
做一些事情;
else
做另外一些事情;
```
那么,有没有办法把语法写成没有二义性的呢?当然有了。
```
stmt -&gt; fullyMatchedStmt | partlyMatchedStmt
fullyMatchedStmt -&gt; if expr fullyMatchedStmt else fullyMatchedStmt
| other
partlyMatchedStmt -&gt; if expr stmt
| if expr fullyMatchedStmt else partlyMatchedStmt
```
按照上面的语法规则只有唯一的推导方式也只能生成唯一的AST
<img src="https://static001.geekbang.org/resource/image/49/08/493e98268dac0e100ca745f6e379fe08.jpg" alt="">
其中解析第一个if语句时只能应用partlyMatchedStmt规则解析第二个if语句时只能适用fullyMatchedStmt规则。
这时,我们就知道可以通过改写语法规则来解决二义性文法。至于怎么改写规则,确实不像左递归那样有清晰的套路,但是可以多借鉴成熟的经验。
再说回我们给Antlr定义的语法这个语法似乎并不复杂怎么就能确保不出现二义性问题呢因为Antlr解析语法时用到的是LL算法。
LL算法是一个深度优先的算法所以在解析到第一个statement时就会建立下一级的if节点在下一级节点里会把else子句解析掉。如果Antlr不用LL算法就会产生二义性。这再次验证了我们前面说的那个知识点文法要经常和解析算法配合。
分析完if语句并借它说明了二义性文法之后我们再针对for语句做一个案例研究。
#### 3.研究一下for语句
for语句一般写成下面的样子
```
for (int i = 0; i &lt; 10; i++){
println(i);
}
```
相关的语法规则如下:
```
statement :
...
| FOR '(' forControl ')' statement
...
;
forControl
: forInit? ';' expression? ';' forUpdate=expressionList?
;
forInit
: variableDeclarators
| expressionList
;
expressionList
: expression (',' expression)*
;
```
从上面的语法规则中看到for语句归根到底是由语句、表达式和变量声明构成的。代码中的for语句解析后形成的AST如下
<img src="https://static001.geekbang.org/resource/image/2a/0a/2aafdb592342d5d694b32a347d4c430a.jpg" alt="">
熟悉了for语句的语法之后我想提一下语句块block。在if语句和for语句中会用到它所以我捎带着把语句块的语法构成写了一下供你参考
```
block
: '{' blockStatements '}'
;
blockStatements
: blockStatement*
;
blockStatement
: variableDeclarators ';' //变量声明
| statement
| functionDeclaration //函数声明
| classDeclaration //类声明
;
```
现在我们已经拥有了一个相当不错的语法体系除了要放到后面去讲的函数、类有关的语法之外我们几乎完成了playscript的所有的语法设计工作。接下来我们再升级一下脚本解释器让它能够支持更多的语法同时通过使用Visitor模式让代码结构更加完善。
## 用Vistor模式升级脚本解释器
我们在纯手工编写的脚本语言解释器里用了一个evaluate()方法自上而下地遍历了整棵树。随着要处理的语法越来越多这个方法的代码量会越来越大不便于维护。而Visitor设计模式针对每一种AST节点都会有一个单独的方法来负责处理能够让代码更清晰也更便于维护。
Antlr能帮我们生成一个Visitor处理模式的框架我们在命令行输入
```
antlr -visitor PlayScript.g4
```
-visitor参数告诉Antlr生成下面两个接口和类
```
public interface PlayScriptVisitor&lt;T&gt; extends ParseTreeVisitor&lt;T&gt; {...}
public class PlayScriptBaseVisitor&lt;T&gt; extends AbstractParseTreeVisitor&lt;T&gt; implements PlayScriptVisitor&lt;T&gt; {...}
```
在PlayScriptBaseVisitor中可以看到很多visitXXX()这样的方法每一种AST节点都对应一个方法例如
```
@Override public T visitPrimitiveType(PlayScriptParser.PrimitiveTypeContext ctx) {...}
```
其中泛型&lt; T &gt;指的是访问每个节点时返回的数据的类型。在我们手工编写的版本里当时只处理整数所以返回值一律用Integer现在我们实现的版本要高级一点AST节点可能返回各种类型的数据比如
- 浮点型运算的时候,会返回浮点数;
- 字符类型运算的时候,会返回字符型数据;
- 还可能是程序员自己设计的类型,如某个类的实例。
所以我们就让Visitor统一返回Object类型好了能够适用于各种情况。这样我们的Visitor就是下面的样子泛型采用了Object
```
public class MyVisitor extends PlayScriptBaseVisitor&lt;Object&gt;{
...
}
```
这样在visitExpression()方法中,我们可以编写各种表达式求值的代码,比如,加法和减法运算的代码如下:
```
public Object visitExpression(ExpressionContext ctx) {
Object rtn = null;
//二元表达式
if (ctx.bop != null &amp;&amp; ctx.expression().size() &gt;= 2) {
Object left = visitExpression(ctx.expression(0));
Object right = visitExpression(ctx.expression(1));
...
Type type = cr.node2Type.get(ctx);//数据类型是语义分析的成果
switch (ctx.bop.getType()) {
case PlayScriptParser.ADD: //加法运算
rtn = add(leftObject, rightObject, type);
break;
case PlayScriptParser.SUB: //减法运算
rtn = minus(leftObject, rightObject, type);
break;
...
}
}
...
}
```
其中ExpressionContext就是AST中表达式的节点叫做Context意思是你能从中取出这个节点所有的上下文信息包括父节点、子节点等。其中每个子节点的名称跟语法中的名称是一致的比如加减法语法规则是下面这样
```
expression bop=('+'|'-') expression
```
那么我们可以用ExpressionContext的这些方法访问子节点
```
ctx.expression(); //返回一个列表,里面有两个成员,分别是左右两边的子节点
ctx.expression(0); //运算符左边的表达式是另一个ExpressionContext对象
ctx.expression(1); //云算法右边的表达式
ctx.bop(); //一个Token对象其类型是PlayScriptParser.ADD或SUB
ctx.ADD(); //访问ADD终结符当做加法运算的时候该方法返回非空值
ctx.MINUS() //访问MINUS终结符
```
在做加法运算的时候我们还可以递归的对下级节点求值就像代码里的visitExpression(ctx.expression(0))。同样要想运行整个脚本我们只需要visit根节点就行了。
所以我们可以用这样的方式为每个AST节点实现一个visit方法。从而把整个解释器升级一遍。除了实现表达式求值我们还可以为今天设计的if语句、for语句来编写求值逻辑。以for语句为例代码如下
```
// 初始化部分执行一次
if (forControl.forInit() != null) {
rtn = visitForInit(forControl.forInit());
}
while (true) {
Boolean condition = true; // 如果没有条件判断部分,意味着一直循环
if (forControl.expression() != null) {
condition = (Boolean) visitExpression(forControl.expression());
}
if (condition) {
// 执行for的语句体
rtn = visitStatement(ctx.statement(0));
// 执行forUpdate通常是“i++”这样的语句。这个执行顺序不能出错。
if (forControl.forUpdate != null) {
visitExpressionList(forControl.forUpdate);
}
} else {
break;
}
}
```
你需要注意for语句中各个部分的执行规则比如
- forInit部分只能执行一次
- 每次循环都要执行一次forControl看看是否继续循环
- 接着执行for语句中的语句体
- 最后执行forUpdate部分通常是一些“i++”这样的语句。
支持了这些流程控制语句以后,我们的脚本语言就更丰富了!
## 课程小结
今天我带你用Antlr高效地完成了很多语法分析工作比如完善表达式体系完善语句体系。除此之外我们还升级了脚本解释器使它能够执行更多的表达式和语句。
在实际工作中,针对面临的具体问题,我们完全可以像今天这样迅速地建立可以运行的代码,专注于解决领域问题,快速发挥编译技术的威力。
而且在使用工具时,针对工具的某个特性,比如对优先级和结合性的支持,我们大致能够猜到工具内部的实现机制,因为我们已经了解了相关原理。
## 一课一思
我们通过Antlr并借鉴成熟的规则文件很快就重构了脚本解释器这样工作效率很高。那么针对要解决的领域问题你是不是借鉴过一些成熟实践或者最佳实践来提升效率和质量在这个过程中又有什么心得呢欢迎在留言区分享你的心得。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
我把一门功能比较全的脚本语言的示例放在了playscript-java项目下以后几讲的内容都会参考这里面的示例代码。
- playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- PlayScript.java入口程序 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java)
- PlayScript.g4语法规则 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)
- ASTEvaluator.java解释器 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)

View File

@@ -0,0 +1,534 @@
<audio id="audio" title="08 | 作用域和生存期:实现块作用域和函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2c/31/2ccd0efea9df02bb7b7008fba5c9a931.mp3"></audio>
目前我们已经用Antlr重构了脚本解释器有了工具的帮助我们可以实现更高级的功能比如函数功能、面向对象功能。当然了在这个过程中我们还要克服一些挑战比如
- 如果要实现函数功能,要升级变量管理机制;
- 引入作用域机制,来保证变量的引用指向正确的变量定义;
- 提升变量存储机制不能只把变量和它的值简单地扔到一个HashMap里要管理它的生存期减少对内存的占用。
本节课,我将借实现块作用域和函数功能,带你探讨作用域和生存期及其实现机制,并升级变量管理机制。那么什么是作用域和生存期,它们的重要性又体现在哪儿呢?
**“作用域”和“生存期”**是计算机语言中更加基础的概念,它们可以帮你深入地理解函数、块、闭包、面向对象、静态成员、本地变量和全局变量等概念。
而且一旦你深入理解,了解作用域与生存期在编译期和运行期的机制之后,就能解决在学习过程中可能遇到的一些问题,比如:
- 闭包的机理到底是什么?
- 为什么需要栈和堆两种机制来管理内存?它们的区别又是什么?
- 一个静态的内部类和普通的内部类有什么区别?
了解上面这些内容之后,接下来,我们来具体看看什么是作用域。
## 作用域Scope
作用域是指计算机语言中变量、函数、类等起作用的范围,我们来看一个具体的例子。
下面这段代码是用C语言写的我们在全局以及函数fun中分别声明了a和b两个变量然后在代码里对这些变量做了赋值操作
```
/*
scope.c
测试作用域。
*/
#include &lt;stdio.h&gt;
int a = 1;
void fun()
{
a = 2;
//b = 3; //出错不知道b是谁
int a = 3; //允许声明一个同名的变量吗?
int b = a; //这里的a是哪个
printf(&quot;in fun: a=%d b=%d \n&quot;, a, b);
}
int b = 4; //b的作用域从这里开始
int main(int argc, char **argv){
printf(&quot;main--1: a=%d b=%d \n&quot;, a, b);
fun();
printf(&quot;main--2: a=%d b=%d \n&quot;, a, b);
//用本地变量覆盖全局变量
int a = 5;
int b = 5;
printf(&quot;main--3: a=%d b=%d \n&quot;, a, b);
//测试块作用域
if (a &gt; 0){
int b = 3; //允许在块里覆盖外面的变量
printf(&quot;main--4: a=%d b=%d \n&quot;, a, b);
}
else{
int b = 4; //跟if块里的b是两个不同的变量
printf(&quot;main--5: a=%d b=%d \n&quot;, a, b);
}
printf(&quot;main--6: a=%d b=%d \n&quot;, a, b);
}
```
这段代码编译后运行,结果是:
```
main--1: a=1 b=4
in fun: a=3 b=3
main--2: a=2 b=4
main--3: a=5 b=5
main--4: a=5 b=3
main--6: a=5 b=5
```
我们可以得出这样的规律:
- 变量的作用域有大有小,外部变量在函数内可以访问,而函数中的本地变量,只有本地才可以访问。
- 变量的作用域,从声明以后开始。
- 在函数里,我们可以声明跟外部变量相同名称的变量,这个时候就覆盖了外部变量。
下面这张图直观地显示了示例代码中各个变量的作用域:
<img src="https://static001.geekbang.org/resource/image/2e/fc/2ea46e1b2d1a6c863f6830a7af5fd3fc.jpg" alt="">
另外C语言里还有块作用域的概念就是用花括号包围的语句if和else后面就跟着这样的语句块。块作用域的特征跟函数作用域的特征相似都可以访问外部变量也可以用本地变量覆盖掉外部变量。
你可能会问“其他语言也有块作用域吗特征是一样的吗”其实各个语言在这方面的设计机制是不同的。比如下面这段用Java写的代码里我们用了一个if语句块并且在if部分、else部分和外部分别声明了一个变量c
```
/**
* Scope.java
* 测试Java的作用域
*/
public class ScopeTest{
public static void main(String args[]){
int a = 1;
int b = 2;
if (a &gt; 0){
//int b = 3; //不允许声明与外部变量同名的变量
int c = 3;
}
else{
int c = 4; //允许声明另一个c各有各的作用域
}
int c = 5; //这里也可以声明一个新的c
}
}
```
你能看到Java的块作用域跟C语言的块作用域是不同的它不允许块作用域里的变量覆盖外部变量。那么和C、Java写起来很像的JavaScript呢来看一看下面这段测试JavaScript作用域的代码
```
/**
* Scope.js
* 测试JavaScript的作用域
*/
var a = 5;
var b = 5;
console.log(&quot;1: a=%d b=%d&quot;, a, b);
if (a &gt; 0) {
a = 4;
console.log(&quot;2: a=%d b=%d&quot;, a, b);
var b = 3; //看似声明了一个新变量,其实还是引用的外部变量
console.log(&quot;3: a=%d b=%d&quot;, a, b);
}
else {
var b = 4;
console.log(&quot;4: a=%d b=%d&quot;, a, b);
}
console.log(&quot;5: a=%d b=%d&quot;, a, b);
for (var b = 0; b&lt; 2; b++){ //这里是否能声明一个新变量用于for循环
console.log(&quot;6-%d: a=%d b=%d&quot;,b, a, b);
}
console.log(&quot;7: a=%d b=%d&quot;, a, b);
```
这段代码编译后运行,结果是:
```
1: a=5 b=5
2: a=4 b=5
3: a=4 b=3
5: a=4 b=3
6-0: a=4 b=0
6-1: a=4 b=1
7: a=4 b=2
```
你可以看到JavaScript是没有块作用域的。我们在块里和for语句试图重新定义变量b语法上是允许的但我们每次用到的其实是同一个变量。
对比了三种语言的作用域特征之后,你是否发现原来看上去差不多的语法,内部机理却不同?这种不同其实是语义差别的一个例子。**你要注意的是,现在我们讲的很多内容都已经属于语义的范畴了,对作用域的分析就是语义分析的任务之一。**
## 生存期Extent
了解了什么是作用域之后,我们再理解一下跟它紧密相关的生存期。它是变量可以访问的时间段,也就是从分配内存给它,到收回它的内存之间的时间。
在前面几个示例程序中,变量的生存期跟作用域是一致的。出了作用域,生存期也就结束了,变量所占用的内存也就被释放了。这是本地变量的标准特征,这些本地变量是用栈来管理的。
但也有一些情况,变量的生存期跟语法上的作用域不一致,比如在堆中申请的内存,退出作用域以后仍然会存在。
下面这段C语言的示例代码中fun函数返回了一个整数的指针。出了函数以后本地变量b就消失了这个指针所占用的内存&amp;b就收回了其中&amp;b是取b的地址这个地址是指向栈里的一小块空间因为b是栈里申请的。在这个栈里的小空间里保存了一个地址指向在堆里申请的内存。这块内存也就是用来实际保存数值2的空间并没有被收回我们必须手动使用free()函数来收回。
```
/*
extent.c
测试生存期。
*/
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
int * fun(){
int * b = (int*)malloc(1*sizeof(int)); //在堆中申请内存
*b = 2; //给该地址赋值2
return b;
}
int main(int argc, char **argv){
int * p = fun();
*p = 3;
printf(&quot;after called fun: b=%lu *b=%d \n&quot;, (unsigned long)p, *p);
free(p);
}
```
类似的情况在Java里也有。Java的对象实例缺省情况下是在堆中生成的。下面的示例代码中从一个方法中返回了对象的引用我们可以基于这个引用继续修改对象的内容这证明这个对象的内存并没有被释放
```
/**
* Extent2.java
* 测试Java的生存期特性
*/
public class Extent2{
StringBuffer myMethod(){
StringBuffer b = new StringBuffer(); //在堆中生成对象实例
b.append(&quot;Hello &quot;);
System.out.println(System.identityHashCode(b)); //打印内存地址
return b; //返回对象引用,本质是一个内存地址
}
public static void main(String args[]){
Extent2 extent2 = new Extent2();
StringBuffer c = extent2.myMethod(); //获得对象引用
System.out.println(c);
c.append(&quot;World!&quot;); //修改内存中的内容
System.out.println(c);
//跟在myMethod()中打印的值相同
System.out.println(System.identityHashCode(c));
}
}
```
因为Java对象所采用的内存超出了申请内存时所在的作用域所以也就没有办法自动收回。所以Java采用的是自动内存管理机制也就是垃圾回收技术。
那么为什么说作用域和生存期是计算机语言更加基础的概念呢?其实是因为它们对应到了运行时的内存管理的基本机制。虽然各门语言设计上的特性是不同的,但在运行期的机制都很相似,比如都会用到栈和堆来做内存管理。
好了,理解了作用域和生存期的原理之后,我们就来实现一下,先来设计一下作用域机制,然后再模拟实现一个栈。
## 实现作用域和栈
在之前的PlayScript脚本的实现中处理变量赋值的时候我们简单地把变量存在一个哈希表里用变量名去引用就像下面这样
```
public class SimpleScript {
private HashMap&lt;String, Integer&gt; variables = new HashMap&lt;String, Integer&gt;();
...
}
```
但如果变量存在多个作用域这样做就不行了。这时我们就要设计一个数据结构区分不同变量的作用域。分析前面的代码你可以看到作用域是一个树状的结构比如Scope.c的作用域
<img src="https://static001.geekbang.org/resource/image/2d/c8/2d3fc83aba7fe2fd7b29227e97184fc8.jpg" alt="">
面向对象的语言不太相同它不是一棵树是一片树林每个类对应一棵树所以它也没有全局变量。在我们的playscript语言中我们设计了下面的对象结构来表示Scope
```
//编译过程中产生的变量、函数、类、块,都被称作符号
public abstract class Symbol {
//符号的名称
protected String name = null;
//所属作用域
protected Scope enclosingScope = null;
//可见性比如public还是private
protected int visibility = 0;
//Symbol关联的AST节点
protected ParserRuleContext ctx = null;
}
//作用域
public abstract class Scope extends Symbol{
// 该Scope中的成员包括变量、方法、类等。
protected List&lt;Symbol&gt; symbols = new LinkedList&lt;Symbol&gt;();
}
//块作用域
public class BlockScope extends Scope{
...
}
//函数作用域
public class Function extends Scope implements FunctionType{
...
}
//类作用域
public class Class extends Scope implements Type{
...
}
```
目前我们划分了三种作用域分别是块作用域Block、函数作用域Function和类作用域Class
我们在解释执行playscript的AST的时候需要建立起作用域的树结构对作用域的分析过程是语义分析的一部分。也就是说并不是有了AST我们马上就可以运行它在运行之前我们还要做语义分析比如对作用域做分析让每个变量都能做正确的引用这样才能正确地执行这个程序。
解决了作用域的问题以后再来看看如何解决生存期的问题。还是看Scope.c的代码随着代码的执行各个变量的生存期表现如下
- 进入程序,全局变量逐一生效;
- 进入main函数main函数里的变量顺序生效
- 进入fun函数fun函数里的变量顺序生效
- 退出fun函数fun函数里的变量失效
- 进入if语句块if语句块里的变量顺序生效
- 退出if语句块if语句块里的变量失效
- 退出main函数main函数里的变量失效
- 退出程序,全局变量失效。
通过下面这张图,你能直观地看到运行过程中栈的变化:
<img src="https://static001.geekbang.org/resource/image/51/06/51f278ccd4fc7f28c6840e1d6b20bd06.jpg" alt="">
代码执行时进入和退出一个个作用域的过程,可以用栈来实现。每进入一个作用域,就往栈里压入一个数据结构,这个数据结构叫做**栈桢Stack Frame**。栈桢能够保存当前作用域的所有本地变量的值,当退出这个作用域的时候,这个栈桢就被弹出,里面的变量也就失效了。
你可以看到栈的机制能够有效地使用内存变量超出作用域的时候就没有用了就可以从内存中丢弃。我在ASTEvaluator.java中用下面的数据结构来表示栈和栈桢其中的PlayObject通过一个HashMap来保存各个变量的值
```
private Stack&lt;StackFrame&gt; stack = new Stack&lt;StackFrame&gt;();
public class StackFrame {
//该frame所对应的scope
Scope scope = null;
//enclosingScope所对应的frame
StackFrame parentFrame = null;
//实际存放变量的地方
PlayObject object = null;
}
public class PlayObject {
//成员变量
protected Map&lt;Variable, Object&gt; fields = new HashMap&lt;Variable, Object&gt;();
}
```
目前我们只是在概念上模仿栈桢当我们用Java语言实现的时候PlayObject对象是存放在堆里的Java的所有对象都是存放在堆里的只有基础数据类型比如int和对象引用是放在栈里的。虽然只是模仿这不妨碍我们建立栈桢的概念在后端技术部分我们会实现真正意义上的栈桢。
要注意的是栈的结构和Scope的树状结构是不一致的。也就是说栈里的上一级栈桢不一定是Scope的父节点。要访问上一级Scope中的变量数据要顺着栈桢的parentFrame去找。我在上图中展现了这种情况在调用fun函数的时候栈里一共有三个栈桢全局栈桢、main()函数栈桢和fun()函数栈桢其中main()函数栈桢的parentFrame和fun()函数栈桢的parentFrame都是全局栈桢。
## 实现块作用域
目前我们已经做好了作用域和栈在这之后就能实现很多功能了比如让if语句和for循环语句使用块作用域和本地变量。以for语句为例visit方法里首先为它生成一个栈桢并加入到栈中运行完毕之后再从栈里弹出
```
BlockScope scope = (BlockScope) cr.node2Scope.get(ctx); //获得Scope
StackFrame frame = new StackFrame(scope); //创建一个栈桢
pushStack(frame); //加入栈中
...
//运行完毕,弹出栈
stack.pop();
```
当我们在代码中需要获取某个变量的值的时候,首先在当前桢中寻找。找不到的话,就到上一级作用域对应的桢中去找:
```
StackFrame f = stack.peek(); //获取栈顶的桢
PlayObject valueContainer = null;
while (f != null) {
//看变量是否属于当前栈桢里
if (f.scope.containsSymbol(variable)){
valueContainer = f.object;
break;
}
//从上一级scope对应的栈桢里去找
f = f.parentFrame;
}
```
运行下面的测试代码你会看到在执行完for循环以后我们仍然可以声明另一个变量i跟for循环中的i互不影响这证明它们确实属于不同的作用域
```
String script = &quot;int age = 44; for(int i = 0;i&lt;10;i++) { age = age + 2;} int i = 8;&quot;;
```
进一步的,我们可以实现对函数的支持。
## 实现函数功能
先来看一下与函数有关的语法:
```
//函数声明
functionDeclaration
: typeTypeOrVoid? IDENTIFIER formalParameters ('[' ']')*
functionBody
;
//函数体
functionBody
: block
| ';'
;
//类型或void
typeTypeOrVoid
: typeType
| VOID
;
//函数所有参数
formalParameters
: '(' formalParameterList? ')'
;
//参数列表
formalParameterList
: formalParameter (',' formalParameter)* (',' lastFormalParameter)?
| lastFormalParameter
;
//单个参数
formalParameter
: variableModifier* typeType variableDeclaratorId
;
//可变参数数量情况下,最后一个参数
lastFormalParameter
: variableModifier* typeType '...' variableDeclaratorId
;
//函数调用
functionCall
: IDENTIFIER '(' expressionList? ')'
| THIS '(' expressionList? ')'
| SUPER '(' expressionList? ')'
;
```
在函数里,我们还要考虑一个额外的因素:**参数。**在函数内部,参数变量跟普通的本地变量在使用时没什么不同,在运行期,它们也像本地变量一样,保存在栈桢里。
我们设计一个对象来代表函数的定义,它包括参数列表和返回值的类型:
```
public class Function extends Scope implements FunctionType{
// 参数
protected List&lt;Variable&gt; parameters = new LinkedList&lt;Variable&gt;();
//返回值
protected Type returnType = null;
...
}
```
在调用函数时,我们实际上做了三步工作:
- 建立一个栈桢;
- 计算所有参数的值,并放入栈桢;
- 执行函数声明中的函数体。
我把相关代码放在了下面,你可以看一下:
```
//函数声明的AST节点
FunctionDeclarationContext functionCode = (FunctionDeclarationContext) function.ctx;
//创建栈桢
functionObject = new FunctionObject(function);
StackFrame functionFrame = new StackFrame(functionObject);
// 计算实参的值
List&lt;Object&gt; paramValues = new LinkedList&lt;Object&gt;();
if (ctx.expressionList() != null) {
for (ExpressionContext exp : ctx.expressionList().expression()) {
Object value = visitExpression(exp);
if (value instanceof LValue) {
value = ((LValue) value).getValue();
}
paramValues.add(value);
}
}
//根据形参的名称,在栈桢中添加变量
if (functionCode.formalParameters().formalParameterList() != null) {
for (int i = 0; i &lt; functionCode.formalParameters().formalParameterList().formalParameter().size(); i++) {
FormalParameterContext param = functionCode.formalParameters().formalParameterList().formalParameter(i);
LValue lValue = (LValue) visitVariableDeclaratorId(param.variableDeclaratorId());
lValue.setValue(paramValues.get(i));
}
}
// 调用方法体
rtn = visitFunctionDeclaration(functionCode);
// 运行完毕,弹出栈
stack.pop();
```
你可以用playscript测试一下函数执行的效果看看参数传递和作用域的效果
```
String script = &quot;int b= 10; int myfunc(int a) {return a+b+3;} myfunc(2);&quot;;
```
## 课程小结
本节课,我带你实现了块作用域和函数,还跟你一起探究了计算机语言的两个底层概念:作用域和生存期。你要知道:
- 对作用域的分析是语义分析的一项工作。Antlr能够完成很多词法分析和语法分析的工作但语义分析工作需要我们自己做。
- 变量的生存期涉及运行期的内存管理,也引出了栈桢和堆的概念,我会在编译器后端技术时进一步阐述。
我建议你在学习新语言的时候,先了解它在作用域和生存期上的特点,然后像示例程序那样做几个例子,借此你会更快理解语言的设计思想。比如,为什么需要命名空间这个特性?全局变量可能带来什么问题?类的静态成员与普通成员有什么区别?等等。
下一讲,我们会尝试实现面向对象特性,看看面向对象语言在语义上是怎么设计的,以及在运行期有什么特点。
## 一课一思
既然我强调了作用域和生存期的重要性,那么在你熟悉的语言中,有哪些特性是能用作用域和生存期的概念做更基础的解读呢?比如,面向对象的语言中,对象成员的作用域和生存期是怎样的?欢迎在留言区与大家一起交流。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
今天讲的功能照样能在playscript-java项目中找到示例代码其中还有用playscript写的脚本你可以多玩一玩。
- playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- PlayScript.java入口程序[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java)
- PlayScript.g4语法规则[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)
- ASTEvaluator.java解释器[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)
- BlockScope.play演示块作用域[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/BlockScope.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/BlockScope.play)
- function.play演示基础函数功能[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/function.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/function.play)
- lab/scope目录各种语言的作用域测试[码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/lab/scope) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/lab/scope)

View File

@@ -0,0 +1,309 @@
<audio id="audio" title="09 | 面向对象:实现数据和方法的封装" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/2e/cd978eeea21f4ca0383da71cf633862e.mp3"></audio>
在现代计算机语言中面向对象是非常重要的特性似乎常用的语言都支持面向对象特性比如Swift、C++、Java……不支持的反倒是异类了。
而它重要的特点就是封装。也就是说,对象可以把数据和对数据的操作封装在一起,构成一个不可分割的整体,尽可能地隐藏内部的细节,只保留一些接口与外部发生联系。 在对象的外部只能通过这些接口与对象进行交互,无需知道对象内部的细节。这样能降低系统的耦合,实现内部机制的隐藏,不用担心对外界的影响。那么它们是怎样实现的呢?
本节课我将从语义设计和运行时机制的角度剖析面向对象的特性带你深入理解面向对象的实现机制让你能在日常编程工作中更好地运用面向对象的特性。比如在学完这讲之后你会对对象的作用域和生存期、对象初始化过程等有更清晰的了解。而且你不会因为学习了Java或C++的面向对象机制在学习JavaScript和Ruby的面向对象机制时觉得别扭因为它们的本质是一样的。
接下来,我们先简单地聊一下什么是面向对象。
## 面向对象的语义特征
我的一个朋友在10多年前做过培训师为了吸引学员的注意力他在讲“什么是面向对象”时说“面向对象是世界观是方法论。”
虽然有点儿语不惊人死不休的意思但我必须承认所有的计算机语言都是对世界进行建模的方式只不过建模的视角不同罢了。面向对象的设计思想在上世纪90年代被推崇几乎被视为最好的编程模式。实际上各种不同的编程思想都会表现为这门语言的语义特征所以我就从语义角度利用类型、作用域、生存期这样的概念带你深入剖析一下面向对象的封装特性其他特性在后面的课程中再去讨论。
- **从类型角度**
类型处理是语义分析时的重要工作。现代计算机语言可以用自定义的类来声明变量,这是一个巨大的进步。因为早期的计算机语言只支持一些基础的数据类型,比如各种长短不一的整型和浮点型,像字符串这种我们编程时离不开的类型,往往是在基础数据类型上封装和抽象出来的。所以,我们要扩展语言的类型机制,让程序员可以创建自己的类型。
- **从作用域角度**
首先是类的可见性。作为一种类型它通常在整个程序的范围内都是可见的可以用它声明变量。当然一些像Java的语言也能限制某些类型的使用范围比如只能在某个命名空间内或者在某个类内部。
对象的成员的作用域是怎样的呢我们知道对象的属性“属性”这里指的是类的成员变量可以在整个对象内部访问无论在哪个位置声明。也就是说对象属性的作用域是整个对象的内部方法也是一样。这跟函数和块中的本地变量不一样它们对声明顺序有要求像C和Java这样的语言在使用变量之前必须声明它。
- **从生存期的角度**
对象的成员变量的生存期,一般跟对象的生存期是一样的。在创建对象的时候,就对所有成员变量做初始化,在销毁对象的时候,所有成员变量也随着一起销毁。当然,如果某个成员引用了从堆中申请的内存,这些内存需要手动释放,或者由垃圾收集机制释放。
但还有一些成员不是与对象绑定的而是与类型绑定的比如Java中的静态成员。静态成员跟普通成员的区别就是作用域和生存期不同它的作用域是类型的所有对象实例被所有实例共享。生存期是在任何一个对象实例创建之前就存在在最后一个对象销毁之前不会消失。
你看,我们用这三个语义概念,就把面向对象的封装特性解释清楚了,无论语言在顶层怎么设计,在底层都是这么实现的。
了解了面向对象在语义上的原理之后,我们来实际动手解析一下代码中的类,这样能更深刻地体会这些原理。
## 设计类的语法,并解析它
我们要在语言中支持类的定义在PlayScript.g4中可以这样定义类的语法规则
```
classDeclaration
: CLASS IDENTIFIER
(EXTENDS typeType)?
(IMPLEMENTS typeList)?
classBody
;
classBody
: '{' classBodyDeclaration* '}'
;
classBodyDeclaration
: ';'
| memberDeclaration
;
memberDeclaration
: functionDeclaration
| fieldDeclaration
;
functionDeclaration
: typeTypeOrVoid IDENTIFIER formalParameters ('[' ']')*
(THROWS qualifiedNameList)?
functionBody
;
```
我来简单地讲一下这个语法规则:
- 类声明以class关键字开头有一个标识符是类型名称后面跟着类的主体。
- 类的主体里要声明类的成员。在简化的情况下可以只关注类的属性和方法两种成员。我们故意把类的方法也叫做function而不是method是想把对象方法和函数做一些统一的设计。
- 函数声明现在的角色是类的方法。
- 类的成员变量的声明和普通变量声明在语法上没什么区别。
你能看到我们构造像class这样高级别的结构时越来越得心应手了之前形成的一些基础的语法模块都可以复用比如变量声明、代码块block等。
用上面的语法写出来的playscript脚本的效果如下在示例代码里也有你可以运行它
```
/*
ClassTest.play 简单的面向对象特性。
*/
class Mammal{
//类属性
string name = &quot;&quot;;
//构造方法
Mammal(string str){
name = str;
}
//方法
void speak(){
println(&quot;mammal &quot; + name +&quot; speaking...&quot;);
}
}
Mammal mammal = Mammal(&quot;dog&quot;); //playscript特别的构造方法不需要new关键字
mammal.speak(); //访问对象方法
println(&quot;mammal.name = &quot; + mammal.name); //访问对象的属性
//没有构造方法,创建的时候用缺省构造方法
class Bird{
int speed = 50; //在缺省构造方法里初始化
void fly(){
println(&quot;bird flying...&quot;);
}
}
Bird bird = Bird(); //采用缺省构造方法
println(&quot;bird.speed : &quot; + bird.speed + &quot;km/h&quot;);
bird.fly();
```
接下来我们让playscript解释器处理这些看上去非常现代化的代码怎么处理呢
做完词法分析和语法分析之后playscript会在语义分析阶段扫描AST识别出所有自定义的类型以便在其他地方引用这些类型来声明变量。因为类型的声明可以在代码中的任何位置所以最好用单独的一次遍历来识别和记录类型类型扫描的代码在TypeAndScopeScanner.java里
接着我们在声明变量时就可以引用这个类型了。语义分析的另一个工作就是做变量类型的消解。当我们声明“Bird bird = Bird(); ”时需要知道Bird对象的定义在哪里以便正确地访问它的成员变量类型的消解在TypeResolver.java里
在做语义分析时,要把类型的定义保存在一个数据结构中,我们来实现一下:
```
public class Class extends Scope implements Type{
...
}
public abstract class Scope extends Symbol{
// 该Scope中的成员包括变量、方法、类等。
protected List&lt;Symbol&gt; symbols = new LinkedList&lt;Symbol&gt;(
}
public interface Type {
public String getName(); //类型名称
public Scope getEnclosingScope();
}
```
在这个设计中我们看到Class就是一个ScopeScope里面原来就能保存各种成员现在可以直接复用用来保存类的属性和方法画成类图如下
<img src="https://static001.geekbang.org/resource/image/86/b1/864926c69c3c85c7df771374f78942b1.jpg" alt="">
图里有几个类比如Symbol、Variable、Scope、Function和BlockScope它们是我们的符号体系的主要成员。在做词法分析时我们会解析出很多标识符这些标识符出现在不同的语法规则里包括变量声明、表达式以及作为类名、方法名等出现。
在语义分析阶段,我们要把这些标识符一一识别出来,这个是一个变量,指的是一个本地变量;那个是一个方法名等。
变量、类和函数的名称我们都叫做符号比如示例程序中的Mammal、Bird、mammal、bird、name、speed等。编译过程中的一项重要工作就是建立符号表它帮助我们进一步地编译或执行程序而符号表就用上面几个类来保存信息。
在符号表里,我们保存它的名称、类型、作用域等信息。对于类和函数,我们也有相应的地方来保存类变量、方法、参数、返回值等信息。你可以看一看示例代码里面是如何解析和记录这些符号的。
解析完这些语义信息以后,我们来看运行期如何执行具有面向对象特征的程序,比如如何实例化一个对象?如何在内存里管理对象的数据?以及如何访问对象的属性和方法?
## 对象是怎么实例化的
首先通过构造方法来创建对象。
在语法中我们没有用new这个关键字来表示对象的创建而是省略掉了new直接调用一个跟类名称相同的函数这是我们独特的设计示例代码如下
```
Mammal mammal = Mammal(&quot;dog&quot;); //playscript特别的构造方法不需要new关键字
Bird bird = Bird(); //采用缺省构造方法
```
但在语义检查的时候在当前作用域中是肯定找不到这样一个函数的因为类的初始化方法是在类的内部定义的我们只要检查一下Mammal和Bird是不是一个类名就可以了。
再进一步Mammal类中确实有个构造方法Mammal()而Bird类中其实没有一个显式定义的构造方法但这并不意味着变量成员不会被初始化。我们借鉴了Java的初始化机制就是提供缺省初始化方法在缺省初始化方法里会执行对象成员声明时所做的初始化工作。所以上面的代码里我们调用Bird()实际上就是调用了这个缺省的初始化方法。无论有没有显式声明的构造方法声明对象的成员变量时的初始化部分一定会执行。对于Bird类实际上就会执行“int speed = 50;”这个语句。
在RefResolver.java中做语义分析的时候下面的代码能够检测出某个函数调用其实是类的构造方法或者是缺省构造方法
```
// 看看是不是类的构建函数用相同的名称查找一个class
Class theClass = at.lookupClass(scope, idName);
if (theClass != null) {
function = theClass.findConstructor(paramTypes);
if (function != null) {
at.symbolOfNode.put(ctx, function);
}
//如果是与类名相同的方法,并且没有参数,那么就是缺省构造方法
else if (ctx.expressionList() == null){
at.symbolOfNode.put(ctx, theClass); // TODO 直接赋予class
}
else{
at.log(&quot;unknown class constructor: &quot; + ctx.getText(), ctx);
}
at.typeOfNode.put(ctx, theClass); // 这次函数调用是返回一个对象
}
```
当然,类的构造方法跟普通函数还是有所不同的,例如我们不允许构造方法定义返回值,因为它的返回值一定是这个类的一个实例对象。
对象做了缺省初始化以后,再去调用显式定义的构造方法,这样才能完善整个对象实例化的过程。不过问题来了,我们可以把普通的本地变量的数据保存在栈里,那么如何保存对象的数据呢?
## 如何在内存里管理对象的数据
其实,我们也可以把对象的数据像其他数据一样,保存在栈里。
<img src="https://static001.geekbang.org/resource/image/57/1b/572da99aeee859f8b7cbcf6ebfe9ea1b.jpg" alt="">
C语言的结构体struct和C++语言的对象都可以保存在栈里。保存在栈里的对象是直接声明并实例化的而不是用new关键字来创建的。如果用new关键字来创建实际上是在堆里申请了一块内存并赋值给一个指针变量如下图所示
<img src="https://static001.geekbang.org/resource/image/15/72/15313f8205fa80912e72718685755072.jpg" alt="">
当对象保存在堆里的时候可以有多个变量都引用同一个对象比如图中的变量a和变量b就可以引用同一个对象object1。类的成员变量也可以引用别的对象比如object1中的类成员引用了object2对象。对象的生存期可以超越创建它的栈桢的生存期。
我们可以对比一下这两种方式的优缺点。如果对象保存在栈里,那么它的生存期与作用域是一样的,可以自动的创建和销毁,因此不需要额外的内存管理。缺点是对象没办法长期存在并共享。而在堆里创建的对象虽然可以被共享使用,却增加了内存管理的负担。
所以在C语言和C++语言中要小心管理从堆中申请的内存在合适的时候释放掉这些内存。在Java语言和其他一些语言中采用的是垃圾收集机制也就是说当一个对象不再被引用时就把内存收集回来。
分析到这儿的时候我们其实可以帮Java语言优化一下内存管理。比如我们在分析代码时如果发现某个对象的创建和使用都局限在某个块作用域中并没有跟其他作用域共享那么这个对象的生存期与当前栈桢是一致的可以在栈里申请内存而不是在堆里。这样可以免除后期的垃圾收集工作。
分析完对象的内存管理方式之后回到playscript的实现。在playscript的Java版本里我们用一个ClassObject对象来保存对象数据而ClassObject是PlayObject的子类。上一讲我们已经讲过PlayObject它被栈桢用来保存本地变量可以通过传入Variable来访问对象的属性值
```
//类的实例
public class ClassObject extends PlayObject{
//类型
protected Class type = null;
...
}
//保存对象数据
public class PlayObject {
//成员变量
protected Map&lt;Variable, Object&gt; fields = new HashMap&lt;Variable, Object&gt;();
public Object getValue(Variable variable){
Object rtn = fields.get(variable);
return rtn;
}
public void setValue(Variable variable, Object value){
fields.put(variable, value);
}
}
```
在运行期当需要访问一个对象时我们也会用ClassObject来做一个栈桢这样就可以像访问本地变量一样访问对象的属性了。而不需要访问这个对象的时候就把它从栈中移除如果没有其他对象引用这个对象那么它会被Java的垃圾收集机制回收。
## 访问对象的属性和方法
在示例代码中,我们用点操作符来访问对象的属性和方法,比如:
```
mammal.speak(); //访问对象方法
println(&quot;mammal.name = &quot; + mammal.name); //访问对象的属性
```
属性和方法的引用也是一种表达式,语法定义如下:
```
expression
: ...
| expression bop='.'
( IDENTIFIER //对象属性
| functionCall //对象方法
)
...
;
```
注意,点符号的操作可以是级联的,比如:
```
obj1.obj2.field1;
obj1.getObject2().field1;
```
所以对表达式的求值要能够获得正确的对象引用你可以运行一下ClassTest.play脚本或者去看看我的参考实现。
另外,对象成员还可以设置可见性。也就是说,有些成员只有对象内部才能用,有些可以由外部访问。这个怎么实现呢?这只是个语义问题,是在编译阶段做语义检查的时候,不允许私有的成员被外部访问,报编译错误就可以了,在其他方面,并没有什么不同。
## 课程小结
我们针对面向对象的封装特性,从类型、作用域和生存期的角度进行了重新解读,这样能够更好地把握面向对象的本质特征。我们还设计了与面向对象的相关的语法并做了解析,然后讨论了面向对象程序的运行期机制,例如如何实例化一个对象,如何在内存里管理对象的数据,以及如何访问对象的属性和方法。
通过对类的语法和语义的剖析和运行机制的落地,我相信你会对面向对象的机制有更加本质的认识,也能更好地使用语言的面向对象特性了。
## 一课一思
我们用比较熟悉的语法实现了面向对象的基础特性像Ruby、Go这样的语言还有另外的机制来实现面向对象。思考一下你所熟悉的语言的面向对象机制在底层是如何实现的它们在类型、作用域和生存期三个方面的特点是什么欢迎在留言区分享你的发现。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
我将本节课相关代码的链接放在了文末,供你参考。
- playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- PlayScript.java入口程序 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java)
- PlayScript.g4语法规则 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)
- ASTEvaluator.java解释器 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)
- TypeAndScopeScanner.java识别对象声明 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeAndScopeScanner.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeAndScopeScanner.java)
- TypeResolver.java消解变量声明中引用的类型 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeResolver.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeResolver.java)
- RefResolver.java消解变量引用和函数调用 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java)
- ClassTest.play演示面向对象的基本特征 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/ClassTest.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/ClassTest.play)

View File

@@ -0,0 +1,505 @@
<audio id="audio" title="10 | 闭包: 理解了原理,它就不反直觉了" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/2f/245bcf205ddff77d24e49d1d51709b2f.mp3"></audio>
在讲作用域和生存期时,我提到函数里的本地变量只能在函数内部访问,函数退出之后,作用域就没用了,它对应的栈桢被弹出,作用域中的所有变量所占用的内存也会被收回。
但偏偏跑出来**闭包Closure**这个怪物。
在JavaScript中用外层函数返回一个内层函数之后这个内层函数能一直访问外层函数中的本地变量。按理说这个时候外层函数已经退出了它里面的变量也该作废了。可闭包却非常执着即使外层函数已经退出但内层函数仿佛不知道这个事实一样还继续访问外层函数中声明的变量并且还真的能够正常访问。
不过,闭包是很有用的,对库的编写者来讲,它能隐藏内部实现细节;对面试者来讲,它几乎是前端面试必问的一个问题,比如如何用闭包特性实现面向对象编程?等等。
本节课,我会带你研究闭包的实现机制,让你深入理解作用域和生存期,更好地使用闭包特性。为此,要解决两个问题:
- **函数要变成playscript的一等公民。**也就是要能把函数像普通数值一样赋值给变量,可以作为参数传递给其他函数,可以作为函数的返回值。
- **要让内层函数一直访问它环境中的变量,不管外层函数退出与否。**
我们先通过一个例子,研究一下闭包的特性,看看它另类在哪里。
## 闭包的内在矛盾
来测试一下JavaScript的闭包特性
```
/**
* clojure.js
* 测试闭包特性
* 作者:宫文学
*/
var a = 0;
var fun1 = function(){
var b = 0; // 函数内的局部变量
var inner = function(){ // 内部的一个函数
a = a+1;
b = b+1;
return b; // 返回内部的成员
}
return inner; // 返回一个函数
}
console.log(&quot;outside: a=%d&quot;, a);
var fun2 = fun1(); // 生成闭包
for (var i = 0; i&lt; 2; i++){
console.log(&quot;fun2: b=%d a=%d&quot;,fun2(), a); //通过fun2()来访问b
}
var fun3 = fun1(); // 生成第二个闭包
for (var i = 0; i&lt; 2; i++){
console.log(&quot;fun3: b=%d a=%d&quot;,fun3(), a); // b等于1重新开始
}
```
在Node.js环境下运行上面这段代码的结果如下
```
outside: a=0
fun2: b=1 a=1
fun2: b=2 a=2
fun3: b=1 a=3
fun3: b=2 a=4
```
观察这个结果,可以得出两点:
- 内层的函数能访问它“看得见”的变量包括自己的本地变量、外层函数的变量b和全局变量a。
- 内层函数作为返回值赋值给其他变量以后外层函数就结束了但内层函数仍能访问原来外层函数的变量b也能访问全局变量a。
这样似乎让人感到困惑站在外层函数的角度看明明这个函数已经退出了变量b应该失效了为什么还可以继续访问但是如果换个立场站在inner这个函数的角度来看声明inner函数的时候告诉它可以访问b不能因为把inner函数赋值给了其他变量inner函数里原本正确的语句就不能用了啊。
<img src="https://static001.geekbang.org/resource/image/25/eb/25c5a91dd544ac1801f759ccc5b85ceb.jpg" alt="图片: https://uploader.shimo.im/f/ZmhV8tamLjo5KkP4.png">
其实,只要函数能作为值传来传去,就一定会产生作用域不匹配的情况,这样的内在矛盾是语言设计时就决定了的。**我认为,闭包是为了让函数能够在这种情况下继续运行所提供的一个方案。**这个方案有一些不错的特点,比如隐藏函数所使用的数据,歪打正着反倒成了一个优点了!
在这里,我想补充一下**静态作用域Static Scope**这个知识点,如果一门语言的作用域是静态作用域,那么符号之间的引用关系能够根据程序代码在编译时就确定清楚,在运行时不会变。某个函数是在哪声明的,就具有它所在位置的作用域。它能够访问哪些变量,那么就跟这些变量绑定了,在运行时就一直能访问这些变量。
看一看下面的代码对于静态作用域而言无论在哪里调用foo()函数访问的变量i都是全局变量
```
int i = 1;
void foo(){
println(i); // 访问全局变量
}
foo(); // 访问全局变量
void bar(){
int i = 2;
foo(); // 在这里调用foo(),访问的仍然是全局变量
}
```
我们目前使用的大多数语言都是采用静态作用域的。playscript语言也是在编译时就形成一个Scope的树变量的引用也是在编译时就做了消解不再改变所以也是采用了静态作用域。
反过来讲如果在bar()里调用foo()时foo()访问的是bar()函数中的本地变量i那就说明这门语言使用的是**动态作用域Dynamic Scope**。也就是说变量引用跟变量声明不是在编译时就绑定死了的。在运行时它是在运行环境中动态地找一个相同名称的变量。在macOS或Linux中用的bash脚本语言就是动态作用域的。
静态作用域可以由程序代码决定在编译时就能完全确定所以又叫做词法作用域Lexcical Scope。不过这个词法跟我们做词法分析时说的词法不大一样。这里跟Lexical相对应的词汇可以认为是Runtime一个是编写时一个是运行时。
用静态作用域的概念描述一下闭包,我们可以这样说:因为我们的语言是静态作用域的,它能够访问的变量,需要一直都能访问,为此,需要把某些变量的生存期延长。
当然了,闭包的产生还有另一个条件,就是让函数成为一等公民。这是什么意思?我们又怎样实现呢?
## 函数作为一等公民
在JavaScript和Python等语言里函数可以像数值一样使用比如给变量赋值、作为参数传递给其他函数作为函数返回值等等。**这时,我们就说函数是一等公民。**
作为一等公民的函数很有用比如它能处理数组等集合。我们给数组的map方法传入一个回调函数结果会生成一个新的数组。整个过程很简洁没有出现啰嗦的循环语句这也是很多人提倡函数式编程的原因之一
```
var newArray = [&quot;1&quot;,&quot;2&quot;,&quot;3&quot;].map(
fucntion(value,index,array){
return parseInt(value,10)
})
```
那么在playscript中怎么把函数作为一等公民呢
我们需要支持函数作为基础类型,这样就可以用这种类型声明变量。但问题来了,如何声明一个函数类型的变量呢?
在JavaScript这种动态类型的语言里我们可以把函数赋值给任何一个变量就像前面示例代码里的那样inner函数作为返回值被赋给了fun2和fun3两个变量。
然而在Go语言这样要求严格类型匹配的语言里就比较复杂了
```
type funcType func(int) int // Go语言声明了一个函数类型funcType
var myFun funType // 用这个函数类型声明了一个变量
```
它对函数的原型有比较严格的要求函数必须有一个int型的参数返回值也必须是int型的。
而C语言中函数指针的声明也是比较严格的在下面的代码中myFun指针能够指向一个函数这个函数也是有一个int类型的参数返回值也是int
```
int (*myFun) (int); //C语言声明一个函数指针
```
playscript也采用这种比较严格的声明方式因为我们想实现一个静态类型的语言
```
function int (int) myFun; //playscript中声明一个函数型的变量
```
写成上面这样是因为我个人喜欢把变量名称左边的部分看做类型的描述不像Go语言把类型放在变量名称后面。最难读的就是C语言那种声明方式了竟然把变量名放在了中间。当然这只是个人喜好。
把上面描述函数类型的语法写成Antlr的规则如下
```
functionType
: FUNCTION typeTypeOrVoid '(' typeList? ')'
;
typeList
: typeType (',' typeType)*
;
```
在playscript中我们用FuntionType接口代表一个函数类型通过这个接口可以获得返回值类型、参数类型这两个信息
```
package play;
import java.util.List;
/**
* 函数类型
*/
public interface FunctionType extends Type {
public Type getReturnType(); //返回值类型
public List&lt;Type&gt; getParamTypes(); //参数类型
}
```
试一下实际使用效果如何用Antlr解析下面这句的语法
```
function int(long, float) fun2 = fun1();
```
它的意思是调用fun1()函数会返回另一个函数这个函数有两个参数返回值是int型的。
我们用grun显示一下AST你可以看到它已经把functionType正确地解析出来了
<img src="https://static001.geekbang.org/resource/image/b8/fa/b83891a55f855783eb6b7fd2e9b387fa.jpg" alt="图片: https://uploader.shimo.im/f/812ANkYxJU8Xc6Kp.png">
目前我们只是设计完了语法还要实现运行期的功能让函数真的能像数值一样传来传去就像下面的测试代码它把foo()作为值赋给了bar()
```
/*
FirstClassFunction.play 函数作为一等公民。
也就是函数可以数值,赋给别的变量。
支持函数类型即FunctionType。
*/
int foo(int a){
println(&quot;in foo, a = &quot; + a);
return a;
}
int bar (function int(int) fun){
int b = fun(6);
println(&quot;in bar, b = &quot; + b);
return b;
}
function int(int) a = foo; //函数作为变量初始化值
a(4);
function int(int) b;
b = foo; //函数用于赋值语句
b(5);
bar(foo); //函数做为参数
```
运行结果如下:
```
in foo, a = 4
in foo, a = 5
in foo, a = 6
in bar, b = 6
```
运行这段代码你会发现它实现了用函数来赋值而实现这个功能的重点是做好语义分析。比如编译程序要能识别赋值语句中的foo是一个函数而不是一个传统的值。在调用a()和b()的时候它也要正确地调用foo()的代码而不是报“找不到a()函数的定义”这样的错误。
实现了一等公民函数的功能以后,我们进入本讲最重要的一环:**实现闭包功能。**
## 实现我们自己的闭包机制
在这之前我想先设计好测试用例所以先把一开始提到的那个JavaScript的例子用playscript的语法重写一遍来测试闭包功能
```
/**
* clojure.play
* 测试闭包特性
*/
int a = 0;
function int() fun1(){ //函数的返回值是一个函数
int b = 0; //函数内的局部变量
int inner(){ //内部的一个函数
a = a+1;
b = b+1;
return b; //返回内部的成员
}
return inner; //返回一个函数
}
function int() fun2 = fun1();
for (int i = 0; i&lt; 3; i++){
println(&quot;b = &quot; + fun2() + &quot;, a = &quot;+a);
}
function int() fun3 = fun1();
for (int i = 0; i&lt; 3; i++){
println(&quot;b = &quot; + fun3() + &quot;, a = &quot;+a);
}
```
代码的运行效果跟JavaScript版本的程序是一样的
```
b = 1, a = 1
b = 2, a = 2
b = 3, a = 3
b = 1, a = 4
b = 2, a = 5
b = 3, a = 6
```
这段代码的AST我也让grun显示出来了并截了一部分图你可以直观地看一下外层函数和内层函数的关系
<img src="https://static001.geekbang.org/resource/image/cf/eb/cf93f6a6ffe3a63cc98023d2ea9d39eb.jpg" alt="图片: https://uploader.shimo.im/f/vaWRcnserakKhNWs.png">
现在,测试用例准备好了,我们着手实现一下闭包的机制。
前面提到,闭包的内在矛盾是运行时的环境和定义时的作用域之间的矛盾。那么我们把内部环境中需要的变量,打包交给闭包函数,它就可以随时访问这些变量了。
在AST上做一下图形化的分析看看给fun2这个变量赋值的时候发生了什么事情
<img src="https://static001.geekbang.org/resource/image/ef/10/ef2ce5e3cc4fa01c219ae4a7ab22a610.jpg" alt="">
简单地描述一下给fun2赋值时的执行过程
<li>
先执行fun1()函数内部的inner()函数作为返回值返回给调用者。这时程序能访问两层作用域最近一层是fun1()里面有变量b外层还有一层里面有全局变量a。这时是把环境变量打包的最后的机会否则退出fun1()函数以后变量b就消失了。
</li>
<li>
然后把内部函数连同打包好的环境变量的值创建一个FunctionObject对象作为fun1()的返回值,给到调用者。
</li>
<li>
给fun2这个变量赋值。
</li>
<li>
调用fun2()函数。函数执行时有一个私有的闭包环境可以访问b的值这个环境就是第二步所创建的FunctionObject对象。
</li>
**最终,我们实现了闭包的功能。**
在这个过程中我们要提前记录下inner()函数都引用了哪些外部变量以便对这些变量打包。这是在对程序做语义分析时完成的你可以参考一下ClosureAnalyzer.java中的代码
```
/**
* 为某个函数计算闭包变量,也就是它所引用的外部环境变量。
* 算法:计算所有的变量引用,去掉内部声明的变量,剩下的就是外部的。
* @param function
* @return
*/
private Set&lt;Variable&gt; calcClosureVariables(Function function){
Set&lt;Variable&gt; refered = variablesReferedByScope(function);
Set&lt;Variable&gt; declared = variablesDeclaredUnderScope(function);
refered.removeAll(declared);
return refered;
}
```
下面是ASTEvaluator.java中把环境变量打包进闭包中的代码片段它是在当前的栈里获取数据的
```
/**
* 为闭包获取环境变量的值
* @param function 闭包所关联的函数。这个函数会访问一些环境变量。
* @param valueContainer 存放环境变量的值的容器
*/
private void getClosureValues(Function function, PlayObject valueContainer){
if (function.closureVariables != null) {
for (Variable var : function.closureVariables) {
// 现在还可以从栈里取,退出函数以后就不行了
LValue lValue = getLValue(var);
Object value = lValue.getValue();
valueContainer.fields.put(var, value);
}
}
}
```
你可以把测试用例跑一跑,修改一下,试试其他闭包特性。
## 体验一下函数式编程
现在我们已经实现了闭包的机制函数也变成了一等公民。不经意间我们似乎在一定程度上支持了函数式编程functional programming
它是一种语言风格有很多优点比如简洁、安全等。备受很多程序员推崇的LISP语言就具备函数式编程特征Java等语言也增加了函数式编程的特点。
函数式编程的一个典型特点就是高阶函数High-order function功能高阶函数是这样一种函数它能够接受其他函数作为自己的参数javascript中数组的map方法就是一个高阶函数。我们通过下面的例子测试一下高阶函数功能
```
/**
LinkedList.play
实现了一个简单的链表并演示了高阶函数的功能比如在javascript中常用的map功能
它能根据遍历列表中的每个元素,执行一个函数,并返回一个新的列表。给它传不同的函数,会返回不同的列表。
*/
//链表的节点
class ListNode{
int value;
ListNode next; //下一个节点
ListNode (int v){
value = v;
}
}
//链表
class LinkedList{
ListNode start;
ListNode end;
//添加新节点
void add(int value){
ListNode node = ListNode(value);
if (start == null){
start = node;
end = node;
}
else{
end.next = node;
end = node;
}
}
//打印所有节点内容
void dump(){
ListNode node = start;
while (node != null){
println(node.value);
node = node.next;
}
}
//高阶函数功能参数是一个函数对每个成员做一个计算形成一个新的LinkedList
LinkedList map(function int(int) fun){
ListNode node = start;
LinkedList newList = LinkedList();
while (node != null){
int newValue = fun(node.value);
newList.add(newValue);
node = node.next;
}
return newList;
}
}
//函数:平方值
int square(int value){
return value * value;
}
//函数加1
int addOne(int value){
return value + 1;
}
LinkedList list = LinkedList();
list.add(2);
list.add(3);
list.add(5);
println(&quot;original list:&quot;);
list.dump();
println();
println(&quot;add 1 to each element:&quot;);
LinkedList list2 = list.map(addOne);
list2.dump();
println();
println(&quot;square of each element:&quot;);
LinkedList list3 = list.map(square);
list3.dump();
```
运行后得到的结果如下:
```
original list:
2
3
5
add 1 to each element:
3
4
6
square of each element:
4
9
25
```
高阶函数功能很好玩,你可以修改程序,好好玩一下。
## 课程小结
闭包这个概念,对于初学者来讲是一个挑战。其实,闭包就是把函数在静态作用域中所访问的变量的生存期拉长,形成一份可以由这个函数单独访问的数据。正因为这些数据只能被闭包函数访问,所以也就具备了对信息进行封装、隐藏内部细节的特性。
听上去是不是有点儿耳熟?封装,把数据和对数据的操作封在一起,这不就是面向对象编程嘛!一个闭包可以看做是一个对象。反过来看,一个对象是不是也可以看做一个闭包呢?对象的属性,也可以看做被方法所独占的环境变量,其生存期也必须保证能够被方法一直正常的访问。
你看,两个不相干的概念,在用作用域和生存期这样的话语体系去解读之后,就会很相似,在内部实现上也可以当成一回事。现在,你应该更清楚了吧?
## 一课一思
思考一下我在开头提到的那个面试题:如何用闭包做类似面向对象的编程?
其实我在课程中提供了一个closure-mammal.play的示例代码它完全用闭包的概念实现了面向对象编程的多态特征。而这个闭包的实现是一种更高级的闭包比普通的函数闭包还多了一点有用的特性更像对象了。我希望你能发现它到底不同在哪里也能在代码中找到实现这些特性的位置。
你能发现我一直在讲作用域和生存期不要嫌我啰嗦把它们吃透会对你使用语言有很大帮助。比如有同学非常困扰JavaScript的this我负责任地讲只要对作用域有清晰的了解你就能很容易地掌握this。
那么关于作用域跟this之间的关联如果你有什么想法也欢迎在留言区分享。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友,特别是分享给那些还没搞清楚闭包的朋友。
本节课的示例代码放在了文末,供你参考。
- playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- PlayScript.java入口程序 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.java)
- PlayScript.g4语法规则 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)
- ASTEvaluator.java解释器找找闭包运行期时怎么实现的 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)
- ClosureAnalyzer.java分析闭包所引用的环境变量[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ClosureAnalyzer.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ClosureAnalyzer.java)
- RefResolver.java在这里看看函数型变量是怎么消解的 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java)
- closure.play演示基本的闭包特征 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/closure.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/closure.play)
- closure-fibonacci.play用闭包实现了斐波那契数列计算[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/closure-fibonacci.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/closure-fibonacci.play)
- closure-mammal.play用闭包实现了面向对象特性请找找它比普通闭包强在哪里[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/closure-mammal.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/closure-mammal.play)
- FirstClassFunction.play演示一等公民函数的特征[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/FirstClassFunction.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/FirstClassFunction.play)
- LinkedList.play演示了高阶函数map[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/LinkedList.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/LinkedList.play)

View File

@@ -0,0 +1,239 @@
<audio id="audio" title="11 | 语义分析(上):如何建立一个完善的类型系统?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0e/92/0ef3d9f1bdf9075704dabf24a85d6292.mp3"></audio>
在做语法分析时我们可以得到一棵语法树,而基于这棵树能做什么,是语义的事情。比如,+号的含义是让两个数值相加并且通常还能进行缺省的类型转换。所以如果要区分不同语言的差异不能光看语言的语法。比如Java语言和JavaScript在代码块的语法上是一样的都是用花括号但在语义上是不同的一个有块作用域一个没有。
这样看来,相比词法和语法的设计与处理,语义设计和分析似乎要复杂很多。虽然我们借作用域、生存期、函数等特性的实现涉猎了很多语义分析的场景,但离系统地掌握语义分析,还差一点儿火候。所以,为了帮你攻破语义分析这个阶段,我会用两节课的时间,再梳理一下语义分析中的重要知识,让你更好地建立起相关的知识脉络。
今天这节课,我们把注意力集中在**类型系统**这个话题上。
围绕类型系统产生过一些争论,有的程序员会拥护动态类型语言,有的会觉得静态类型语言好。要想探究这个问题,我们需要对类型系统有个清晰的了解,最直接的方式,就是建立一个完善的类型系统。
那么什么是类型系统?我们又该怎样建立一个完善的类型系统呢?
其实,类型系统是一门语言所有的类型的集合,操作这些类型的规则,以及类型之间怎么相互作用的(比如一个类型能否转换成另一个类型)。如果要建立一个完善的类型系统,形成对类型系统比较完整的认知,需要从两个方面出发:
- 根据领域的需求,设计自己的类型系统的特征。
- 在编译器中支持类型检查、类型推导和类型转换。
先从第一个方面出发看一下。
## 设计类型系统的特征
在进入这个话题之前,我想先问你一个有意义的问题:类型到底是什么?我们说一个类型的时候,究竟在说什么?
要知道在机器代码这个层面其实是分不出什么数据类型的。在机器指令眼里那就是0101它并不对类型做任何要求不需要知道哪儿是一个整数哪儿代表着一个字符哪儿又是内存地址。你让它做什么操作都可以即使这个操作没有意义比如把一个指针值跟一个字符相加。
那么高级语言为什么要增加类型这种机制呢?
对类型做定义很难,但大家公认的有一个说法:类型是针对一组数值,以及在这组数值之上的一组操作。比如,对于数字类型,你可以对它进行加减乘除算术运算,对于字符串就不行。
所以,类型是高级语言赋予的一种语义,有了类型这种机制,就相当于定了规矩,可以检查施加在数据上的操作是否合法。因此类型系统最大的好处,就是可以通过类型检查降低计算出错的概率。所以,现代计算机语言都会精心设计一个类型系统,而不是像汇编语言那样完全不区分类型。
不过,类型系统的设计有很多需要取舍和权衡的方面,比如:
- 面向对象的拥护者希望所有的类型都是对象,而重视数据计算性能的人认为应该支持非对象化的基础数据类型;
- 你想把字符串作为原生数据类型还是像Java那样只是一个普通的类
- 是静态类型语言好还是动态类型语言好?
- ……
虽然类型系统的设计有很多需要取舍和权衡的方面,但它最需要考虑的是,是否符合这门语言想解决的问题,我们用静态类型语言和动态类型语言分析一下。
根据类型检查是在编译期还是在运行期进行的,我们可以把计算机语言分为两类:
- 静态类型语言(全部或者几乎全部的类型检查是在编译期进行的)。
- 动态类型语言(类型的检查是在运行期进行的)。
静态类型语言的拥护者说:
>
因为编译期做了类型检查所以程序错误较少运行期不用再检查类型性能更高。像C、Java和Go语言在编译时就对类型做很多处理包括检查类型是否匹配以及进行缺省的类型转换大大降低了程序出错的可能性还能让程序运行效率更高因为不需要在运行时再去做类型检查和转换。
而动态类型语言的拥护者说:
>
静态语言太严格还要一遍遍编译编程效率低用动态类型语言方便进行快速开发。JavaScript、Python、PHP等都是动态类型的。
客观地讲这些说法都有道理。目前的趋势是某些动态类型语言在想办法增加一些机制在编译期就能做类型检查比如用TypeScript代替JavaScript编写程序做完检查后再输出成JavaScript。而某些静态语言呢却又发明出一些办法部分地绕过类型检查从而提供动态类型语言的灵活性。
再延伸一下跟静态类型和动态类型概念相关联的还有强类型和弱类型。强类型语言中变量的类型一旦声明就不能改变弱类型语言中变量类型在运行期时可以改变。二者的本质区别是强类型语言不允许违法操作因为能够被检查出来弱类型语言则从机制上就无法禁止违法操作所以是不安全的。比如你写了一个表达式a*b。如果a和b这两个变量是数值这个操作就没有问题但如果a或b不是数值那就没有意义了弱类型语言可能就检查不出这类问题。
也就是,静态类型和动态类型说的是什么时候检查的问题,强类型和弱类型说的是就算检查,也检查不出来,或者没法检查的问题,**这两组概念经常会被搞混,所以我在这里带你了解一下。**
接着说回来。关于类型特征的取舍,是根据领域问题而定的。举例来说,很多人可能都觉得强类型更好,但对于儿童编程启蒙来说,他们最好尽可能地做各种尝试,如果必须遵守与类型有关的规则,程序总是跑不起来,可能会打击到他们。
对于playscript而言因为目前是用来做教学演示的所以我们尽可能地多涉及与类型处理有关的情况供大家体会算法或者在自己的工作中借鉴。
首先playscript是静态类型和强类型的所以几乎要做各种类型检查你可以参考看看这些都是怎么做的。
第二我们既支持对象也支持原生的基础数据类型。这两种类型的处理特点不一样你也可以借鉴一下。后面面向对象的一讲我会再讲与之相关的子类型Subtyping和运行时类型信息Run Time Type Information, RTTI的概念这里就不展开了。
第三,我们还支持函数作为一等公民,也就是支持函数的类型。函数的类型是它的原型,包括返回值和参数,原型一样的函数,就看做是同样类型的,可以进行赋值。这样,你也就可以了解实现函数式编程特性时,要处理哪些额外的类型问题。
接下来,我们来说一说如何做类型检查、类型推导和类型转换。
## 如何做类型检查、类型推导和类型转换
先来看一看,如果编写一个编译器,我们在做类型分析时会遇到哪些问题。以下面这个最简单的表达式为例,这个表达式在不同的情况下会有不同的运行结果:
```
a = b + 10
```
- 如果b是一个浮点型b+10的结果也是浮点型。如果b是字符串型的有些语言也是允许执行+号运算的,实际的结果是字符串的连接。这个分析过程,就是**类型推导Type Inference。**
- 当右边的值计算完赋值给a的时候要检查左右两边的类型是否匹配。这个过程就是**类型检查Type Checking。**
- 如果a的类型是浮点型而右边传过来的是整型那么一般就要进行缺省的**类型转换Type Conversion。**
类型的检查、推导和转换是三个工作,可是采用的技术手段差不多,所以我们放在一起讲,**先来看看类型的推导。**
在早期的playscript的实现中是假设运算符两边的类型都是整型的并做了强制转换。
这在实际应用中当然不够用因为我们还需要用到其他的数据类型。那怎么办呢在运行时再去判断和转换吗当然可以但我们还有更好的选择就是在编译期先判断出表达式的类型来。比如下面这段代码是在RefResolve.java中推导表达式的类型
```
case PlayScriptParser.ADD:
if (type1 == PrimitiveType.String ||
type2 == PrimitiveType.String){
type = PrimitiveType.String;
}
else if (type1 instanceof PrimitiveType &amp;&amp;
type2 instanceof PrimitiveType){
//类型“向上”对齐比如一个int和一个float取float
type = PrimitiveType.getUpperType(type1,type2);
}else{
at.log(&quot;operand should be PrimitiveType for additive operation&quot;, ctx);
}
break;
```
这段代码提到如果操作符号两边有一边数据类型是String类型的那整个表达式就是String类型的。如果是其他基础类型的就要按照一定的规则进行类型的转换并确定运算结果的类型。比如+号一边是double类型的另一边是int类型的那就要把int型的转换成double型的最后计算结果也是double类型的。
做了类型的推导以后,我们就可以简化运行期的计算,不需要在运行期做类型判断了:
```
private Object add(Object obj1, Object obj2, Type targetType) {
Object rtn = null;
if (targetType == PrimitiveType.String) {
rtn = String.valueOf(obj1) +
String.valueOf(obj2);
} else if (targetType == PrimitiveType.Integer) {
rtn = ((Number)obj1).intValue() +
((Number)obj2).intValue();
} else if (targetType == PrimitiveType.Float) {
rtn = ((Number)obj1).floatValue()+
((Number)obj2).floatValue();
}
...
return rtn;
}
```
通过这个类型推导的例子,我们又可以引出**S属性Synthesized Attribute**的知识点。如果一种属性能够从下级节点推导出来那么这种属性就叫做S属性字面意思是综合属性就是在AST中从下级的属性归纳、综合出本级的属性。更准确地说是通过下级节点和自身来确定的。
<img src="https://static001.geekbang.org/resource/image/52/0c/52b4dfe5eb96dfeacd6a018c4e97720c.jpg" alt="">
与S属性相对应的是**I属性Inherited Attribute**也就是继承属性即AST中某个节点的属性是由上级节点、兄弟节点和它自身来决定的比如
```
int a;
```
变量a的类型是int这个很直观因为变量声明语句中已经指出了a的类型但这个类型可不是从下级节点推导出来的而是从兄弟节点推导出来的。
在PlayScript.g4中变量声明的相关语法如下
```
variableDeclarators
: typeType variableDeclarator (',' variableDeclarator)*
;
variableDeclarator
: variableDeclaratorId ('=' variableInitializer)?
;
variableDeclaratorId
: IDENTIFIER ('[' ']')*
;
typeType
: (classOrInterfaceType| functionType | primitiveType) ('[' ']')*
;
```
把int a;这样一个简单的变量声明语句解析成AST就形成了一棵有两个分枝的树
<img src="https://static001.geekbang.org/resource/image/25/14/2561a3dd309ba662c82a0bc985c2b614.jpg" alt="">
这棵树的左枝可以从下向上推导类型所以类型属性也就是S属性。而右枝则必须从根节点也就是variableDeclarators往下继承类型属性所以对于a这个节点来说它的类型属性是I属性。
这里插一句RefResolver.java实现了PlayScriptListener接口。这样我们可以用标准的方法遍历AST。代码中的enterXXX()方法表示刚进入这个节点exitXXX()方法表示退出这个节点这时所有的子节点都已经遍历过了。在计算S属性时我一定是在exitXXX()方法中,因为可以利用下级节点的类型推导出自身节点的类型。
很多现代语言会支持自动类型推导例如Go语言就有两种声明变量的方式
```
var a int = 10 //第一种
a := 10 //第二种
```
第一种方式a的类型是显式声明的第二种方式a的类型是由右边的表达式推导出来<br>
的。从生成的AST中你能看到它们都是经历了从下到上的综合再从上到下的继承的过程
<img src="https://static001.geekbang.org/resource/image/32/39/3229353c78b54db03afaa2a9318b9d39.jpg" alt="">
**说完了类型推导,我们再看看类型检查。**
类型检查主要出现在几个场景中:
- 赋值语句(检查赋值操作左边和右边的类型是否匹配)。
- 变量声明语句(因为变量声明语句中也会有初始化部分,所以也需要类型匹配)。
- 函数传参(调用函数的时候,传入的参数要符合形参的要求)。
- 函数返回值(从函数中返回一个值的时候,要符合函数返回值的规定)。
类型检查还有一个特点以赋值语句为例左边的类型是I属性是从声明中得到的右边的类型是S属性是自下而上综合出来的。当左右两边的类型相遇之后就要检查二者是否匹配被赋值的变量要满足左边的类型要求。
如果匹配,自然没有问题,如果不完全匹配,也不一定马上报错,**而是要看看是否能进行类型转换。**比如一般的语言在处理整型和浮点型的混合运算时都能进行自动的转换。像JavaScript和SQL甚至能够在算术运算时自动将字符串转换成数字。在MySQL里运行下面的语句会得到3它自动将2转换成了数字
```
select 1 + '2';
```
这个过程其实是有风险的这就像在强类型的语言中开了一个后门绕过或部分绕过了编译器的类型检查功能。把父类转成子类的场景中编译器顶多能检查这两个类之间是否有继承关系如果连继承关系都没有这当然能检查出错误制止这种转换。但一个基类的子类可能是很多的具体这个转换对不对只有到运行期才能检查出错误来。C语言因为可以强制做各种转换这个后门开的就更大了。不过这也是C语言要达到它的设计目的必须具备的特性。
关于类型的处理大家可以参考playscript的示例代码里面有三个类可以看一看
- TypeResolver.java做了自上而下的类型推导也就是I属性的计算包括变量- 声明、类的继承声明、函数声明)。
- RefResolver.java有自下而上的类型推导的逻辑
- TypeChecker.java类型检查
## 课程小结
本节课我们重点探讨了语义分析和语言设计中的一个重要话题:类型系统。
理解类型系统了解它的本质对我们学习语言会有很大的帮助。我希望在这个过程中你不会再被静态类型和动态类型强类型和弱类型这样的概念难倒甚至可以质疑已有的一些观念。比如如果你仔细研究会发现静态类型和动态类型不是绝对的静态类型的语言如Java也会在运行期去处理一些类型检查。强类型和弱类型可能也不是绝对的就像C语言你如果不允许做任何强制类型转换不允许指针越界那它也就完全变成强类型的了。
掌握对计算机语言更深一点儿的理解能力,将会是你学习编译原理的额外回报!
## 一课一思
针对今天讲的类型系统的知识,你所熟悉的语言是静态类型的,还是动态类型的?是强类型的,还是弱类型的?它的类型系统中有哪些你觉得有意思或者引起你困扰的设计?欢迎在留言区分享你的发现。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本节课相关的示例代码放在文末,供你参考。
- playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- PlayScript.g4语法规则 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)
- TypeAndScopeScanner.java类型和作用域扫描 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeAndScopeScanner.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeAndScopeScanner.java)
- TypeResolver.java自上而下的类型推导 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeResolver.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeResolver.java)
- RefResolver.java自下而上的类型推导 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java)
- TypeChecker.java类型检查 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeChecker.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeChecker.java)

View File

@@ -0,0 +1,270 @@
<audio id="audio" title="12 | 语义分析(下):如何做上下文相关情况的处理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/44/227c112cd102abe3df2462c644296644.mp3"></audio>
我们知道,词法分析和语法分析阶段,进行的处理都是上下文无关的。可仅凭上下文无关的处理,是不能完成一门强大的语言的。比如先声明变量,再用变量,这是典型的上下文相关的情况,我们肯定不能用上下文无关文法表达这种情况,所以语法分析阶段处理不了这个问题,只能在语义分析阶段处理。**语义分析的本质,就是针对上下文相关的情况做处理。**
我们之前讲到的作用域是一种上下文相关的情况因为如果作用域不同能使用的变量也是不同的。类型系统也是一种上下文相关的情况类型推导和类型检查都要基于上下文中相关的AST节点。
本节课,我们再讲两个这样的场景:**引用的消解、左值和右值,**然后再介绍上下文相关情况分析的一种方法:**属性计算。**这样,你会把语义分析就是上下文处理的本质掌握得更清楚,并掌握属性计算这个强大的方法。
我们先来说说引用的消解这个场景。
## 语义分析场景:引用的消解
在程序里使用变量、函数、类等符号时我们需要知道它们指的是谁要能对应到定义它们的地方。下面的例子中当使用变量a时我们需要知道它是全局变量a还是fun()函数中的本地变量a。因为不同作用域里可能有相同名称的变量所以必须找到正确的那个。这个过程可以叫引用消解。
```
/*
scope.c
测试作用域
*/
#include &lt;stdio.h&gt;
int a = 1;
void fun()
{
a = 2; //这是指全局变量a
int a = 3; //声明一个本地变量
int b = a; //这个a指的是本地变量
printf(&quot;in func: a=%d b=%d \n&quot;, a, b);
}
```
在集成开发环境中当我们点击一个变量、函数或类可以跳到定义它的地方。另一方面当我们重构一个变量名称、方法名称或类名称的时候所有引用它的地方都会同步修改。这是因为IDE分析了符号之间的交叉引用关系。
**函数的引用消解比变量的引用消解还要更复杂一些。**
它不仅要比对函数名称还要比较参数和返回值可以叫函数原型又或者叫函数的类型。我们在把函数提升为一等公民的时候提到函数类型FunctionType的概念。两个函数的类型相同需要返回值、参数个数、每个参数的类型都能匹配得上才行。
**在面向对象编程语言中,函数引用的消解也很复杂。**
当一个参数需要一个对象的时候,程序中提供其子类的一个实例也是可以的,也就是子类可以用在所有需要父类的地方,例如下面的代码:
```
class MyClass1{} //父类
class MyClass2 extends MyClass1{} //子类
MyClass1 obj1;
MyClass2 obj2;
function fun(MyClass1 obj){} //参数需要父类的实例
fun(obj2); //提供子类的实例
```
**在C++语言中,引用的消解还要更加复杂。**
它还要考虑某个实参是否能够被自动转换成形参所要求的类型比如在一个需要double类型的地方你给它传一个int也是可以的。
**命名空间也是做引用消解的时候需要考虑的因素。**
像Java、C++都支持命名空间。如果在代码前头引入了某个命名空间,我们就可以直接引用里面的符号,否则需要冠以命名空间。例如:
```
play.PlayScriptCompiler.Compile() //Java语言
play::PlayScriptCompiler.Compile() //C++语言
```
而做引用消解可能会产生几个结果:
- 解析出了准确的引用关系。
- 重复定义(在声明新的符号的时候,发现这个符号已经被定义过了)。
- 引用失败(找不到某个符号的定义)。
- 如果两个不同的命名空间中都有相同名称的符号,编程者需要明确指定。
在playscript中引用消解的结果被存到了AnnotatedTree.java类中的symbolOfNode属性中去了从它可以查到某个AST节点引用的到底是哪个变量或函数从而在运行期正确的执行你可以看一下代码了解引用消解和使用的过程。
了解完引用的消解之后,接下来,我们再讲一个很有意思的场景:左值和右值。
## 语义分析场景:左值和右值
在开发编译器或解释器的过程中你一定会遇到左值和右值的问题。比如在playscript的ASTEvaluate.java中我们在visitPrimary节点可以对变量求值。如果是下面语句中的a没有问题把a变量的值取出来就好了
```
a + 3;
```
可是如果针对的是赋值语句a在等号的左边怎么对a求值呢
```
a = 3;
```
假设a变量原来的值是4如果还是把它的值取出来那么成了3=4这就变得没有意义了。所以不能把a的值取出来而应该取出a的地址或者说a的引用然后用赋值操作把3这个值写到a的内存地址。**这时我们说取出来的是a的左值L-value。**
左值最早是在C语言中提出的通常出现在表达式的左边如赋值语句的左边。左值取的是变量的地址或者说变量的引用获得地址以后我们就可以把新值写进去了。
**与左值相对应的就是右值R-value**右值就是我们通常所说的值,不是地址。
在上面这两种情况下变量a在AST中都是对应同一个节点也就是primary节点。那这个节点求值时是该返回左值还是右值呢这要借助上下文来分析和处理。如果这个primary节点存在于下面这几种情况中那就需要取左值
- 赋值表达式的左边;
- 带有初始化的变量声明语句中的变量;
- 当给函数形参赋值的时候;
- 一元操作符: ++和–。
- 其他需要改变变量内容的操作。
在讨论primary节点在哪种情况下取左值时我们可以引出另一个问题**不是所有的表达式,都能生成一个合格的左值。**也就是说出现在赋值语句左边的必须是能够获得左值的表达式。比如一个变量是可以的一个类的属性也是可以的。但如果是一个常量或者2+3这样的表达式在赋值符号的左边那就不行。所以判断表达式能否生成一个合格的左值也是语义检查的一项工作。
借上节课讲过的S属性和I属性的概念我们把刚才说的两个情况总结成primay节点的两个属性你可以判断一下这两个属性是S属性还是I属性
- 属性1某primary节点求值时是否应该求左值
- 属性2某primary节点求值时能否求出左值
你可能发现了这跟我们类型检查有点儿相似一个是I属性一个是S属性两个一比对就能检查求左值的表达式是否合法。从这儿我们也能看出处理上下文相关的情况经常用属性计算的方法。接下来我们就谈谈如何做属性计算。
## 如何做属性计算
属性计算是做上下文分析或者说语义分析的一种算法。按照属性计算的视角我们之前所处理的各种语义分析问题都可以看做是对AST节点的某个属性进行计算。比如针对求左值场景中的primary节点它需要计算的属性包括
- 它的变量定义是哪个这就引用到定义该变量的Symbol
- 它的类型是什么?
- 它的作用域是什么?
- 这个节点求值时,是否该返回左值?能否正确地返回一个左值?
- 它的值是什么?
从属性计算的角度看对表达式求值或运行脚本只是去计算AST节点的Value属性Value这个属性能够计算其他属性当然也能计算。
属性计算需要用到属性文法。在词法、语法分析阶段,我们分别学习了正则文法和上下文无关文法,在语义分析阶段我们要了解的是**属性文法Attribute Grammar。**
属性文法的主要思路是计算机科学的重要开拓者高德纳Donald Knuth在[《The Genesis of Attribute Grammers》](https://www.cs.dartmouth.edu/~mckeeman/cs48/mxcom/doc/AttributeGrammarHistory.pdf)中提出的。它是在上下文无关文法的基础上做了一些增强,使之能够计算属性值。下面是上下文无关文法表达加法和乘法运算的例子:
```
add → add + mul
add → mul
mul → mul * primary
mul → primary
primary → &quot;(&quot; add &quot;)&quot;
primary → integer
```
然后看一看对value属性进行计算的属性文法
```
add1 → add1 + mul [ add1.value = add2.value + mul.value ]
add → mul [ add.value = mul.value ]
mul1 → mul2 * primary [ mul1.value = mul2.value * primary.value ]
mul → primary [ mul.value = primary.value ]
primary → &quot;(&quot; add &quot;)&quot; [ primary.value = add.value ]
primary → integer [ primary.value = strToInt(integer.str) ]
```
利用属性文法我们可以定义规则然后用工具自动实现对属性的计算。有同学曾经问“我们解析表达式2+3的时候得到一个AST但我怎么知道它运算的时候是做加法呢
因为我们可以在语法规则的基础上制定属性文法在解析语法的过程中或者形成AST之后我们就可以根据属性文法的规则做属性计算。比如在Antlr中你可以在语法规则文件中插入一些代码在语法分析的过程中执行你的代码完成一些必要的计算。
**总结一下属性计算的特点:它会基于语法规则,增加一些与语义处理有关的规则。**
所以我们也把这种语义规则的定义叫做语法制导的定义Syntax directed definitionSDD如果变成计算动作就叫做语法制导的翻译Syntax directed translationSDT
属性计算,可以伴随着语法分析的过程一起进行,也可以在做完语法分析以后再进行。这两个阶段不一定完全切分开。甚至,我们有时候会在语法分析的时候做一些属性计算,然后把计算结果反馈回语法分析的逻辑,帮助语法分析更好地执行(这是在工程实践中会运用到的一个技巧,我这里稍微做了一个延展,帮大家开阔一下思路,免得把知识学得太固化了)。
那么在解析语法的时候如何同时做属性计算呢我们知道解析语法的过程是逐步建立AST的过程。在这个过程中计算某个节点的属性所依赖的其他节点可能被创建出来了。比如在递归下降算法中当某个节点建立完毕以后它的所有子节点一定也建立完毕了所以S属性就可以计算出来了。同时因为语法解析是从左向右进行的它左边的兄弟节点也都建立起来了。
如果某个属性的计算除了可能依赖子节点以外只依赖左边的兄弟节点不依赖右边的这种属性就叫做L属性。它比S属性的范围更大一些包含了部分的I属性。由于我们常用的语法分析的算法都是从左向右进行的所以就很适合一边解析语法一边计算L属性。
比如C语言和Java语言进行类型分析都可以用L属性的计算来实现。因为这两门语言的类型要么是从下往上综合出来的属于S属性。要么是在做变量声明的时候由声明中的变量类型确定的类型节点在变量的左边。
```
2+3; //表达式类型是整型
float a; //a的类型是浮点型
```
那问题来了Go语言的类型声明是放在变量后面的这意味着类型节点一定是在右边的那就不符合L属性文法了
```
var a int = 10
```
没关系我们没必要在语法分析阶段把属性全都计算出来等到语法分析完毕后再对AST遍历一下就好了。这时所有节点都有了计算属性也就不是难事了。
在我们的playscript语言里就采取了这种策略实际上为了让算法更清晰我把语义分析过程拆成了好几个任务对AST做了多次遍历。
**第1遍类型和作用域解析TypeAndScopeScanner.java。**
把自定义类、函数和和作用域的树都分析出来。这么做的好处是你可以使用在前声明在后。比如你声明一个Mammal对象而Mammal类的定义是在后面才出现的在定义一个类的时候对于类的成员也会出现使用在声明之前的情况把类型解析先扫描一遍就能方便地支持这个特性。
在写属性计算的算法时计算的顺序可能是个最重要的问题。因为某属性的计算可能要依赖别的节点的属性先计算完。我们讨论的S属性、I属性和L属性都是在考虑计算顺序。像使用在前声明在后这种情况就更要特殊处理了。
**第2遍类型的消解TypeResolver.java。**
把所有出现引用到类型的地方都消解掉比如变量声明、函数参数声明、类的继承等等。做完消解以后我们针对Mammal m;这样语句就明确的知道了m的类型。这实际上是对I属性的类型的计算。
**第3遍引用的消解和S属性的类型的推导RefResolver.java。**
这个时候,我们对所有的变量、函数调用,都会跟它的定义关联起来,并且完成了所有的类型计算。
**第4遍做类型检查TypeChecker.java。**
比如当赋值语句左右两边的类型不兼容的时候,就可以报错。
**第5遍做一些语义合法性的检查SematicValidator.java。**
比如break只能出现在循环语句中如果某个函数声明了返回值就一定要有return语句等等。
语义分析的结果保存在AnnotatedTree.java类里意思是被标注了属性的语法树。注意这些属性在数据结构上并不一定是AST节点的属性我们可以借助Map等数据结构存储只是在概念上这些属性还是标注在树节点上的。
AnnotatedTree类的结构如下
```
public class AnnotatedTree {
// AST
protected ParseTree ast = null;
// 解析出来的所有类型,包括类和函数
protected List&lt;Type&gt; types = new LinkedList&lt;Type&gt;();
// AST节点对应的Symbol
protected Map&lt;ParserRuleContext, Symbol&gt; symbolOfNode = new HashMap&lt;ParserRuleContext, Symbol&gt;();
// AST节点对应的Scope如for、函数调用会启动新的Scope
protected Map&lt;ParserRuleContext, Scope&gt; node2Scope = new HashMap&lt;ParserRuleContext, Scope&gt;();
// 每个节点推断出来的类型
protected Map&lt;ParserRuleContext, Type&gt; typeOfNode = new HashMap&lt;ParserRuleContext, Type&gt;();
// 命名空间,作用域的根节点
NameSpace nameSpace = null;
...
}
```
我建议你看看这些语义分析的代码,了解一下如何保证语义分析的全面性。
## 课程小结
本节课我带你继续了解了语义分析的相关知识:
- 语义分析的本质是对上下文相关情况的处理,能做词法分析和语法分析所做不到的事情。
- 了解引用消解,左值和右值的场景,可以增加对语义分析的直观理解。
- 掌握属性计算和属性文法,可以使我们用更加形式化、更清晰的算法来完成语义分析的任务。
在我看来语义分析这个阶段十分重要。因为词法和语法都有很固定的套路甚至都可以工具化的实现。但语言设计的核心在于语义特别是要让语义适合所解决的问题。比如SQL语言针对的是数据库的操作那就去充分满足这个目标就好了。我们在前端技术的应用篇中也会复盘讨论这个问题不断实现认知的迭代升级。
如果想做一个自己领域的DSL学习了这几讲语义分析的内容之后你会更好地做语义特性的设计与取舍也会对如何完成语义分析有清晰的思路。
## 一课一思
基于你熟悉的语言来说说你觉得在语义分析阶段还有哪些上下文处理工作要做需要计算出哪些属性它们是I属性还是S属性起到什么作用这个思考练习很有意思欢迎在留言区分享你的发现。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本节课相关的示例代码放在文末,供你参考。
- playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- PlayScript.g4语法规则 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/PlayScript.g4)
- TypeAndScopeScanner.java类型和作用域扫描 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeAndScopeScanner.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeAndScopeScanner.java)
- TypeResolver.java消解变量声明中引用的类型 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeResolver.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeResolver.java)
- RefResolver.java变量和函数应用的消解及S属性的类型推断 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/RefResolver.java)
- TypeChecker.java类型检查 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeChecker.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/TypeChecker.java)

View File

@@ -0,0 +1,301 @@
<audio id="audio" title="13 | 继承和多态:面向对象运行期的动态特性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/91/22097f5f1f9ea54bbc55a3c573e1ff91.mp3"></audio>
面向对象是一个比较大的话题。在“[09 | 面向对象:实现数据和方法的封装](https://time.geekbang.org/column/article/130422)”中,我们了解了面向对象的封装特性,也探讨了对象成员的作用域和生存期特征等内容。本节课,我们再来了解一下面向对象的另外两个重要特征:**继承和多态。**
你也许会问,为什么没有在封装特性之后,马上讲继承和多态呢?那是因为继承和多态涉及的语义分析阶段的知识点比较多,特别是它对类型系统提出了新的概念和挑战,所以我们先掌握语义分析,再了解这部分内容,才是最好的选择。
继承和多态对类型系统提出的新概念,就是子类型。我们之前接触的类型往往是并列关系,你是整型,我是字符串型,都是平等的。而现在,一个类型可以是另一个类型的子类型,比如我是一只羊,又属于哺乳动物。这会导致我们在编译期无法准确计算出所有的类型,从而无法对方法和属性的调用做完全正确的消解(或者说绑定)。这部分工作要留到运行期去做,也因此,面向对象编程会具备非常好的优势,因为它会导致多态性。这个特性会让面向对象语言在处理某些类型的问题时,更加优雅。
而我们要想深刻理解面向对象的特征,就必须了解子类型的原理和运行期的机制。所以,接下来,我们从类型体系的角度理解继承和多态,然后看看在编译期需要做哪些语义分析,再考察继承和多态的运行期特征。
## 从类型体系的角度理解继承和多态
**继承的意思是一个类的子类,自动具备了父类的属性和方法,除非被父类声明为私有的。**比如一个类是哺乳动物它有体重weight的属性还会做叫(speak)的操作。如果基于哺乳动物这个父类创建牛和羊两个子类,那么牛和羊就自动继承了哺乳动物的属性,有体重,还会叫。
所以继承的强大之处,就在于重用。也就是有些逻辑,如果在父类中实现,在子类中就不必重复实现。
**多态的意思是同一个类的不同子类,在调用同一个方法时会执行不同的动作。**这是因为每个子类都可以重载掉父类的某个方法,提供一个不同的实现。哺乳动物会“叫”,而牛和羊重载了这个方法,发出“哞~”和“咩~”的声音。这似乎很普通,但如果创建一个哺乳动物的数组,并在里面存了各种动物对象,遍历这个数组并调用每个对象“叫”的方法时,就会发出“哞~”“咩~”“喵~”等各种声音,这就有点儿意思了。
下面这段示例代码演示了继承和多态的特性a的speak()方法和b的speak()方法会分别打印出牛叫和羊叫,调用的是子类的方法,而不是父类的方法:
```
/**
mammal.play 演示面向对象编程:继承和多态。
*/
class Mammal{
int weight = 20;
boolean canSpeak(){
return true;
}
void speak(){
println(&quot;mammal speaking...&quot;);
}
}
class Cow extends Mammal{
void speak(){
println(&quot;moo~~ moo~~&quot;);
}
}
class Sheep extends Mammal{
void speak(){
println(&quot;mee~~ mee~~&quot;);
println(&quot;My weight is: &quot; + weight); //weight的作用域覆盖子类
}
}
//将子类的实例赋给父类的变量
Mammal a = Cow();
Mammal b = Sheep();
//canSpeak()方法是继承的
println(&quot;a.canSpeak() : &quot; + a.canSpeak());
println(&quot;b.canSpeak() : &quot; + b.canSpeak());
//下面两个的叫声会不同,在运行期动态绑定方法
a.speak(); //打印牛叫
b.speak(); //打印羊叫
```
所以,多态的强大之处,在于虽然每个子类不同,但我们仍然可以按照统一的方式使用它们,做到求同存异。**以前端工程师天天打交道的前端框架为例,这是最能体现面向对象编程优势的领域之一。**
前端界面往往会用到各种各样的小组件比如静态文本、可编辑文本、按钮等等。如果我们想刷新组件的显示没必要针对每种组件调用一个方法把所有组件的类型枚举一遍可以直接调用父类中统一定义的方法redraw(),非常简洁。即便将来添加新的前端组件,代码也不需要修改,程序也会更容易维护。
**总结一下:**面向对象编程时,我们可以给某个类创建不同的子类,实现一些个性化的功能;写程序时,我们可以站在抽象度更高的层次上,不去管具体的差异。
如果把上面的结论抽象成一般意义上的类型理论,就是**子类型subtype。**
子类型(或者动名词:子类型化),是对我们前面讲的类型体系的一个补充。
子类型的核心是提供了is-a的操作。也就是对某个类型所做的所有操作都可以用子类型替代。因为子类型 is a 父类型,也就是能够兼容父类型,比如一只牛是哺乳动物。
这意味着只要对哺乳动物可以做的操作,都可以对牛来做,这就是子类型的好处。它可以放宽对类型的检查,从而导致多态。你可以粗略地把面向对象的继承看做是子类型化的一个体现,它的结果就是能用子类代替父类,从而导致多态。
子类型有两种实现方式一种就是像Java和C++语言需要显式声明继承了什么类或者实现了什么接口。这种叫做名义子类型Nominal Subtyping
另一种是结构化子类型Structural Subtyping又叫鸭子类型Duck Type。也就是一个类不需要显式地说自己是什么类型只要它实现了某个类型的所有方法那就属于这个类型。鸭子类型是个直观的比喻如果我们定义鸭子的特征是能够呱呱叫那么只要能呱呱叫的就都是鸭子。
了解了继承和多态之后,我们看看在编译期如何对继承和多态的特性做语义分析。
## 如何对继承和多态的特性做语义分析
针对哺乳动物的例子,我们用前面语义分析的知识,看看如何在编译期针对继承和多态做语义分析,也算对语义分析的知识点进行应用和复盘。
首先从类型处理的角度出发我们要识别出新的类型Mammal、Cow和Sheep。之后就可以用它们声明变量了。
第二,我们要设置正确的作用域。
从作用域的角度来看一个类的属性或者说成员变量是可以规定能否被子类访问的。以Java为例除了声明为private的属性以外其他属性在子类中都是可见的。所以父类的属性的作用域可以说是以树状的形式覆盖到了各级子类
<img src="https://static001.geekbang.org/resource/image/c9/24/c94acfea0ea44dcff839b80c77d3e224.jpg" alt="">
第三,要对变量和函数做类型的引用消解。
也就是要分析出a和b这两个变量的类型。那么a和b的类型是什么呢是父类Mammal还是Cow或Sheep
注意代码里是用Mammal来声明这两个变量的。按照类型推导的算法a和b都是Mammal这是个I属性计算的过程。也就是说在编译期我们无法知道变量被赋值的对象确切是哪个子类型只知道声明变量时它们是哺乳动物类型至于是牛还是羊就不清楚了。
你可能会说“不对呀我在编译的时候能知道a和b的准确类型啊因为我看到了a是一个Cow对象而b是一个Sheep代码里有这两个对象的创建过程我可以推导出a和b的实际类型呀。”
没错语言的确有自动类型推导的特性但你忽略了限制条件。比如强类型机制要求变量的类型一旦确定在运行过程就不能再改所以要让a和b能够重新指向其他的对象并保持类型不变。从这个角度出发a和b的类型只能是父类Mammal。
所以说编译期无法知道变量的真实类型可能只知道它的父类型也就是知道它是一个哺乳动物但不知道它具体是牛还是羊。这会导致我们没法正确地给speak()方法做引用消解。正确的消解是要指向Cow和Sheep的speak方法而我们只能到运行期再解决这个问题。
所以接下来,我们就讨论一下如何在运行期实现方法的动态绑定。
## 如何在运行期实现方法的动态绑定
在运行期我们能知道a和b这两个变量具体指向的是哪个对象对象里是保存了真实类型信息的。具体来说在playscript中ClassObject的type属性会指向一个正确的Class这个类型信息是在创建对象的时候被正确赋值的
<img src="https://static001.geekbang.org/resource/image/c1/0d/c1a3070da8053b4a67065e58d2149f0d.jpg" alt="">
在调用类的属性和方法时,我们可以根据运行时获得的,确定的类型信息进行动态绑定。下面这段代码是从本级开始,逐级查找某个方法的实现,如果本级和父类都有这个方法,那么本级的就会覆盖掉父类的,**这样就实现了多态:**
```
protected Function getFunction(String name, List&lt;Type&gt; paramTypes){
//在本级查找这个这个方法
Function rtn = super.getFunction(name, paramTypes); //TODO 是否要检查visibility
//如果在本级找不到,那么递归的从父类中查找
if (rtn == null &amp;&amp; parentClass != null){
rtn = parentClass.getFunction(name,paramTypes);
}
return rtn;
}
```
如果当前类里面没有实现这个方法,它可以直接复用某一级的父类中的实现,**这实际上就是继承机制在运行期的原理。**
你看,只有了解运行期都发生了什么,才能知道继承和多态是怎么发生的吧。
这里延伸一下。我们刚刚谈到在运行时可以获取类型信息这种机制就叫做运行时类型信息Run Time Type Information, RTTI。C++、Java等都有这种机制比如Java的instanceof操作就能检测某个对象是不是某个类或者其子类的实例。
汇编语言是无类型的所以一般高级语言在编译成目标语言之后这些高层的语义就会丢失。如果要在运行期获取类型信息需要专门实现RTTI的功能这就要花费额外的存储开销和计算开销。就像在playscript中我们要在ClassObject中专门拿出一个字段来存type信息。
现在,我们已经了解如何在运行期获得类型信息,实现方法的动态绑定。接下来,我带你了解一下运行期的对象的逐级初始化机制。
## 继承情况下对象的实例化
在存在继承关系的情况下创建对象时不仅要初始化自己这一级的属性变量还要把各级父类的属性变量也都初始化。比如在实例化Cow的时候还要对Mammal的成员变量weight做初始化。
所以我们要修改playscript中对象实例化的代码从最顶层的祖先起对所有的祖先层层初始化
```
//从父类到子类层层执行缺省的初始化方法,即不带参数的初始化方法
protected ClassObject createAndInitClassObject(Class theClass) {
ClassObject obj = new ClassObject();
obj.type = theClass;
Stack&lt;Class&gt; ancestorChain = new Stack&lt;Class&gt;();
// 从上到下执行缺省的初始化方法
ancestorChain.push(theClass);
while (theClass.getParentClass() != null) {
ancestorChain.push(theClass.getParentClass());
theClass = theClass.getParentClass();
}
// 执行缺省的初始化方法
StackFrame frame = new StackFrame(obj);
pushStack(frame);
while (ancestorChain.size() &gt; 0) {
Class c = ancestorChain.pop();
defaultObjectInit(c, obj);
}
popStack();
return obj;
}
```
在逐级初始化的过程中我们要先执行缺省的成员变量初始化也就是变量声明时所带的初始化部分然后调用这一级的构造方法。如果不显式指定哪个构造方法就会执行不带参数的构造方法。不过有的时候子类会选择性地调用父类某一个构造方法就像Java可以在构造方法里通过super()来显式地调用父类某个具体构造方法。
## 如何实现this和super
现在我们已经了解了继承和多态在编译期和运行期的特性。接下来我们通过一个示例程序把本节课的所有知识复盘检验一下加深对它们的理解也加深对this和super机制的理解。
这个示例程序是用Java写的在Java语言中为面向对象编程专门提供了两个关键字this和super这两个关键字特别容易引起混乱。
比如在下面的ThisSuperTest.Java代码中Mammal和它的子类Cow都有speak()方法。如果我们要创建一个Cow对象会调用Mammal的构造方法Mammal(int weight)而在这个构造方法里调用的this.speak()方法是Mammal的还是Cow的呢
```
package play;
public class ThisSuperTest {
public static void main(String args[]){
//创建Cow对象的时候会在Mammal的构造方法里调用this.reportWeight(),这里会显示什么
Cow cow = new Cow();
System.out.println();
//这里调用,会显示什么
cow.speak();
}
}
class Mammal{
int weight;
Mammal(){
System.out.println(&quot;Mammal() called&quot;);
this.weight = 100;
}
Mammal(int weight){
this(); //调用自己的另一个构造函数
System.out.println(&quot;Mammal(int weight) called&quot;);
this.weight = weight;
//这里访问属性是自己的weight
System.out.println(&quot;this.weight in Mammal : &quot; + this.weight);
//这里的speak()调用的是谁,会显示什么数值
this.speak();
}
void speak(){
System.out.println(&quot;Mammal's weight is : &quot; + this.weight);
}
}
class Cow extends Mammal{
int weight = 300;
Cow(){
super(200); //调用父类的构造函数
}
void speak(){
System.out.println(&quot;Cow's weight is : &quot; + this.weight);
System.out.println(&quot;super.weight is : &quot; + super.weight);
}
}
```
运行结果如下:
```
Mammal() called
Mammal(int weight) called
this.weight in Mammal : 200
Cow's weight is : 0
super.weight is : 200
Cow's weight is : 300
super.weight is : 200
```
答案是Cow的speak()方法而不是Mammal的。怎么回事代码里不是调用的this.speak()吗怎么这个this不是Mammal却变成了它的子类Cow呢
其实在这段代码中this用在了三个地方
- this.weight 是访问自己的成员变量,因为成员变量的作用域是这个类本身,以及子类。
- this()是调用自己的另一个构造方法,因为这是构造方法,肯定是做自身的初始化。换句话说,构造方法不存在多态问题。
- this.speak()是调用一个普通的方法。这时多态仍会起作用。运行时会根据对象的实际类型来绑定到Cow的speak()方法上。
只不过在Mammal的构造方法中调用this.speak()时虽然访问的是Cow的speak()方法打印的是Cow中定义的weight成员变量但它的值却是0而不是成员变量声明时“int weight = 300;”的300。为什么呢
要想知道这个答案我们需要理解多层继承情况下对象的初始化过程。在Mammal的构造方法中调用speak()的时候Cow的初始化过程还没有开始呢所以“int weight = 300;”还没有执行Cow的weight属性还是缺省值0。
怎么样?一个小小的例子,却需要用到三个方面的知识:面向对象的成员变量的作用域、多态、对象初始化。**Java程序员可以拿这个例子跟同事讨论一下看看是不是很好玩。**
讨论完thissuper就比较简单了它的语义要比this简单不会出现歧义。super的调用也是分成三种情况
- super.weight。这是调用父类或更高的祖先的weight属性而不是Cow这一级的weight属性。不一定非是直接父类也可以是祖父类中的。根据变量作用域的覆盖关系只要是比Cow这一级高的就行。
- super(200)。这是调用父类的构造方法,必须是直接父类的。
- super.speak()。跟访问属性的逻辑一样是调用父类或更高的祖先的speak()方法。
## 课程小结
这节课我带你实现了面向对象中的另两个重要特性:继承和多态。在这节课中,我建议你掌握的重点内容是:
- 从类型的角度,面向对象的继承和多态是一种叫做子类型的现象,子类型能够放宽对类型的检查,从而支持多态。
- 在编译期,无法准确地完成对象方法和属性的消解,因为无法确切知道对象的子类型。
- 在运行期,我们能够获得对象的确切的子类型信息,从而绑定正确的方法和属性,实现继承和多态。另一个需要注意的运行期的特征,是对象的逐级初始化过程。
面向对象涉及了这么多精彩的知识点,拿它作为前端技术原理篇的最后一讲,是正确的选择。到目前为止,我们已经讲完了前端技术的原理篇,也如约拥有了一门具备丰富特性的脚本语言,甚至还支持面向对象编程、闭包、函数式编程这些很高级的特性。一般的应用项目所需要的语言特性,很难超过这个范围了。接下来的两节,我们就通过两个具体的应用案例,来检验一下学到的编译原理前端技术,看看它的威力!
## 一课一思
本节课我们深入讨论了面向对象的继承和多态特征。那么你所熟悉的框架,有没有充分利用继承和多态的特点实现一些很有威力的功能?或者,你有没有利用多态的特点,写过一些比较有用的类库或框架呢?欢迎在留言区分享你的经验。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
本节课的示例代码我放在了文末,供你参考。
- playscript-java项目目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/playscript-java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/playscript-java)
- ASTEvaluator.java解释器请找一下运行期方法和属性动态绑定以及对象实例逐级初始化的代码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ASTEvaluator.java)
- ThisSuperTest.java测试Java的this和super特性[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/oop/ThisSuperTest.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/oop/ThisSuperTest.java)
- this-and-super.play (playscript的this和super特性)[码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/this-and-super.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/this-and-super.play)