mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-20 17:03:47 +08:00
mod
This commit is contained in:
178
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/01 | 理解代码:编译器的前端技术.md
Normal file
178
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/01 | 理解代码:编译器的前端技术.md
Normal 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 <stdio.h>
|
||||
int main(int argc, char* argv[]){
|
||||
int age = 45;
|
||||
if (age >= 17+8+20) {
|
||||
printf("Hello old man!\\n");
|
||||
}
|
||||
else{
|
||||
printf("Hello young man!\\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们会识别出if、else、int这样的关键字,main、printf、age这样的标识符,+、-、=这样的操作符号,还有花括号、圆括号、分号这样的符号,以及数字字面量、字符串字面量等。这些都是Token。
|
||||
|
||||
那么,如何写一个程序来识别Token呢?可以看到,英文内容中通常用空格和标点把单词分开,方便读者阅读和理解。但在计算机程序中,仅仅用空格和标点分割是不行的。比如“age >= 45”应该分成“age”“>=”和“45”这三个Token,但在代码里它们可以是连在一起的,中间不用非得有空格。
|
||||
|
||||
这和汉语有点儿像,汉语里每个词之间也是没有空格的。但我们会下意识地把句子里的词语正确地拆解出来。比如把“我学习编程”这个句子拆解成“我”“学习”“编程”,这个过程叫做“分词”。如果你要研发一款支持中文的全文检索引擎,需要有分词的功能。
|
||||
|
||||
其实,我们可以通过制定一些规则来区分每个不同的Token,我举了几个例子,你可以看一下。
|
||||
|
||||
<li>
|
||||
**识别age这样的标识符。**它以字母开头,后面可以是字母或数字,直到遇到第一个既不是字母又不是数字的字符时结束。
|
||||
</li>
|
||||
<li>
|
||||
**识别>=这样的操作符。** 当扫描到一个>字符的时候,就要注意,它可能是一个GT(Greater Than,大于)操作符。但由于GE(Greater Equal,大于等于)也是以>开头的,所以再往下再看一位,如果是=,那么这个Token就是GE,否则就是GT。
|
||||
</li>
|
||||
<li>
|
||||
**识别45这样的数字字面量。**当扫描到一个数字字符的时候,就开始把它看做数字,直到遇到非数字的字符。
|
||||
</li>
|
||||
|
||||
这些规则可以通过手写程序来实现。事实上,很多编译器的词法分析器都是手写实现的,例如GNU的C语言编译器。
|
||||
|
||||
如果嫌手写麻烦,或者你想花更多时间陪恋人或家人,也可以偷点儿懒,用词法分析器的生成工具来生成,比如Lex(或其GNU版本,Flex)。这些生成工具是基于一些规则来工作的,这些规则用“正则文法”表达,符合正则文法的表达式称为“正则表达式”。生成工具可以读入正则表达式,生成一种叫“有限自动机”的算法,来完成具体的词法分析工作。
|
||||
|
||||
不要被“正则文法(Regular Grammar)”和“有限自动机(Finite-state Automaton,FSA,or Finite Automaton)”吓到。正则文法是一种最普通、最常见的规则,写正则表达式的时候用的就是正则文法。我们前面描述的几个规则,都可以看成口语化的正则文法。
|
||||
|
||||
有限自动机是有限个状态的自动机器。我们可以拿抽水马桶举例,它分为两个状态:“注水”和“水满”。摁下冲马桶的按钮,它转到“注水”的状态,而浮球上升到一定高度,就会把注水阀门关闭,它转到“水满”状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/05/9f449fcc2781c222061b6e73c6bbec05.jpg" alt="">
|
||||
|
||||
词法分析器也是一样,它分析整个程序的字符串,当遇到不同的字符时,会驱使它迁移到不同的状态。例如,词法分析程序在扫描age的时候,处于“标识符”状态,等它遇到一个>符号,就切换到“比较操作符”的状态。词法分析过程,就是这样一个个状态迁移的过程。
|
||||
|
||||
<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 Tree,AST)。树的每个节点(子树)是一个语法单元,这个单元的构成规则就叫“语法”。每个节点还可以有下级节点。
|
||||
|
||||
层层嵌套的树状结构,是我们对计算机程序的直观理解。计算机语言总是一个结构套着另一个结构,大的程序套着子程序,子程序又可以包含子程序。
|
||||
|
||||
接下来,我们直观地看一下这棵树长什么样子。 我在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>
|
||||
|
||||
我想让你知道,上述编译过程其实跟你的实际工作息息相关。比如,词法分析就是你工作中使用正则表达式的过程。而语法分析在你解析文本文件、配置文件、模型定义文件,或者做自定义公式功能的时候都会用到。
|
||||
|
||||
我还想让你知道,编译技术并没有那么难,它的核心原理是很容易理解的。学习之后,你能很快上手,如果善用一些辅助生成工具会更省事。所以,我希望你通过学习这篇文章,已经破除了一些心理障碍,并跃跃欲试,想要动手做点儿什么了!
|
||||
|
||||
## 一课一思
|
||||
|
||||
你有没有觉得,刚开始学编译原理中的某些知识点时特别难,一旦学通了以后,就会发出类似的感慨:“啊!原来就是这么回事!”欢迎在留言区与我分享你的感慨时刻。另外,你是否尝试实现过一个编译器,还颇有一些心得?可以在留言区与大家一起交流。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
|
300
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/02 | 正则文法和有限自动机:纯手工打造词法分析器.md
Normal file
300
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/02 | 正则文法和有限自动机:纯手工打造词法分析器.md
Normal 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 >= 45
|
||||
- int age = 40
|
||||
- 2+3*5
|
||||
|
||||
它们分别是关系表达式、变量声明和初始化语句,以及算术表达式。
|
||||
|
||||
接下来,我们先来解析一下“age >= 45”这个关系表达式,这样你就能理解有限自动机的概念,知道它是做词法解析的核心机制了。
|
||||
|
||||
## 解析 age >= 45
|
||||
|
||||
在“[01 | 理解代码:编译器的前端技术](https://time.geekbang.org/column/article/118132)”里,我举了一个词法分析的例子,并且提出词法分析要用到有限自动机。当时,我画了这样一个示意图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/7e/6d78396e6426d0ad5c5230203d17da7e.jpg" alt="">
|
||||
|
||||
我们来描述一下标识符、比较操作符和数字字面量这三种Token的词法规则。
|
||||
|
||||
- **标识符:**第一个字符必须是字母,后面的字符可以是字母或数字。
|
||||
- **比较操作符:**>和>=(其他比较操作符暂时忽略)。
|
||||
- **数字字面量:**全部由数字构成(像带小数点的浮点数,暂时不管它)。
|
||||
|
||||
我们就是依据这样的规则,来构造有限自动机的。这样,词法分析程序在遇到age、>=和45时,会分别识别成标识符、比较操作符和数字字面量。不过上面的图只是一个简化的示意图,一个严格意义上的有限自动机是下面这种画法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/35/15da400d09ede2ce6ac60fa6d5342835.jpg" alt="">
|
||||
|
||||
我来解释一下上图的5种状态。
|
||||
|
||||
**1.初始状态:**刚开始启动词法分析的时候,程序所处的状态。
|
||||
|
||||
**2.标识符状态:**在初始状态时,当第一个字符是字母的时候,迁移到状态2。当后续字符是字母和数字时,保留在状态2。如果不是,就离开状态2,写下该Token,回到初始状态。
|
||||
|
||||
**3.大于操作符(GT):**在初始状态时,当第一个字符是>时,进入这个状态。它是比较操作符的一种情况。
|
||||
|
||||
**4.大于等于操作符(GE):**如果状态3的下一个字符是=,就进入状态4,变成>=。它也是比较操作符的一种情况。
|
||||
|
||||
**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 == '>') { //第一个字符是>
|
||||
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 >= 45”这样的程序语句。不过,你可以先根据我的讲解自己实现一下,然后再去参考这个示例程序。
|
||||
|
||||
示例程序的输出如下,其中第一列是Token的类型,第二列是Token的文本值:
|
||||
|
||||
```
|
||||
Identifier age
|
||||
GE >=
|
||||
IntLiteral 45
|
||||
|
||||
```
|
||||
|
||||
上面的例子虽然简单,但其实已经讲清楚了词法原理,**就是依据构造好的有限自动机,在不同的状态中迁移,从而解析出Token来。**你只要再扩展这个有限自动机,增加里面的状态和迁移路线,就可以逐步实现一个完整的词法分析器了。
|
||||
|
||||
## 初识正则表达式
|
||||
|
||||
但是,这里存在一个问题。我们在描述词法规则时用了自然语言。比如,在描述标识符的规则时,我们是这样表达的:
|
||||
|
||||
>
|
||||
第一个字符必须是字母,后面的字符可以是字母或数字。
|
||||
|
||||
|
||||
这样描述规则并不精确,我们需要换一种严谨的表达方式,这种方式就是**正则表达式。**
|
||||
|
||||
上面的例子涉及了4种Token,这4种Token用正则表达式表达,是下面的样子:
|
||||
|
||||
```
|
||||
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*
|
||||
IntLiteral: [0-9]+
|
||||
GT : '>'
|
||||
GE : '>='
|
||||
|
||||
```
|
||||
|
||||
我先来解释一下这几个规则中用到的一些符号:
|
||||
|
||||
<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。但这还没有结束,如果后续的字符还有其他的字母和数字,它又变成了普通的标识符。比如,我们可以声明一个intA(int和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)上,你可以看一下。
|
326
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/03 | 语法分析(一):纯手工打造公式计算器.md
Normal file
326
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/03 | 语法分析(一):纯手工打造公式计算器.md
Normal 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 Grammar,CFG)。</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 && 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 && token.getType() == TokenType.Assignment) {
|
||||
tokens.read(); //消耗掉等号
|
||||
SimpleASTNode child = additive(tokens); //匹配一个表达式
|
||||
if (child == null) {
|
||||
throw new Exception("invalide variable initialization, expecting an expression");
|
||||
}
|
||||
else{
|
||||
node.addChild(child);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Exception("variable name expected");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
直白地描述一下上面的算法:
|
||||
|
||||
>
|
||||
解析变量声明语句时,我先看第一个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” 的推导过程:
|
||||
|
||||
```
|
||||
-->additiveExpression + multiplicativeExpression
|
||||
-->multiplicativeExpression + multiplicativeExpression
|
||||
-->IntLiteral + multiplicativeExpression
|
||||
-->IntLiteral + multiplicativeExpression * IntLiteral
|
||||
-->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 && 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("invalid additive expression, expecting the right part.");
|
||||
}
|
||||
}
|
||||
}
|
||||
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。
|
||||
- 接着对右边子节点求值,这时候需要递归计算下一层。计算完了以后,返回是15(3*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)上,你可以看一下。
|
||||
|
||||
|
211
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/04 | 语法分析(二):解决二元表达式中的难点.md
Normal file
211
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/04 | 语法分析(二):解决二元表达式中的难点.md
Normal 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 -> mul | add + mul
|
||||
mul -> pri | mul * pri
|
||||
pri -> 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 -> mul (+ mul)*
|
||||
|
||||
```
|
||||
|
||||
其实这种写法跟标准的BNF写法是等价的,但是更简洁。为什么是等价的呢?因为一个项多次重复,就等价于通过递归来推导。从这里我们还可以得到一个推论:就是上下文无关文法包含了正则文法,比正则文法能做更多的事情。
|
||||
|
||||
## 确保正确的优先级
|
||||
|
||||
掌握了语法规则的写法之后,我们来看看如何用语法规则来保证表达式的优先级。刚刚,我们由加法规则推导到乘法规则,这种方式保证了AST中的乘法节点一定会在加法节点的下层,也就保证了乘法计算优先于加法计算。
|
||||
|
||||
听到这儿,你一定会想到,我们应该把关系运算(>、=、<)放在加法的上层,逻辑运算(and、or)放在关系运算的上层。的确如此,我们试着将它写出来:
|
||||
|
||||
```
|
||||
exp -> or | or = exp
|
||||
or -> and | or || and
|
||||
and -> equal | and && equal
|
||||
equal -> rel | equal == rel | equal != rel
|
||||
rel -> add | rel > add | rel < add | rel >= add | rel <= add
|
||||
add -> mul | add + mul | add - mul
|
||||
mul -> pri | mul * pri | mul / pri
|
||||
|
||||
```
|
||||
|
||||
这里表达的优先级从低到高是:赋值运算、逻辑运算(or)、逻辑运算(and)、相等比较(equal)、大小比较(rel)、加法运算(add)、乘法运算(mul)和基础表达式(pri)。
|
||||
|
||||
实际语言中还有更多不同的优先级,比如位运算等。而且优先级是能够改变的,比如我们通常会在语法里通过括号来改变计算的优先级。不过这怎么表达成语法规则呢?
|
||||
|
||||
其实,我们在最低层,也就是优先级最高的基础表达式(pri)这里,用括号把表达式包裹起来,递归地引用表达式就可以了。这样的话,只要在解析表达式的时候遇到括号,那么就知道这个是最优先的。这样的话就实现了优先级的改变:
|
||||
|
||||
```
|
||||
pri -> 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 -> mul | add + mul
|
||||
|
||||
```
|
||||
|
||||
这是我们犯错之后所学到的知识。那么问题来了,大多数二元运算都是左结合的,那岂不是都要面临左递归问题?不用担心,我们可以通过改写左递归的文法,解决这个问题。
|
||||
|
||||
## 消除左递归
|
||||
|
||||
我提到过左递归的情况,也指出递归下降算法不能处理左递归。这里我要补充一点,并不是所有的算法都不能处理左递归,对于另外一些算法,左递归是没有问题的,比如LR算法。
|
||||
|
||||
消除左递归,用一个标准的方法,就能够把左递归文法改写成非左递归的文法。以加法表达式规则为例,原来的文法是“add -> add + mul”,现在我们改写成:
|
||||
|
||||
```
|
||||
add -> mul add'
|
||||
add' -> + 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 -> 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 && (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写法。在后面的课程中我们会不断用到这个技能,还会用工具来生成语法分析器,我们提供给工具的就是书写良好的语法规则。
|
||||
|
||||
到目前为止,你已经闯过了语法分析中比较难的一关。再增加一些其他的语法,你就可以实现出一个简单的脚本语言了!
|
||||
|
||||
## 一课一思
|
||||
|
||||
本节课提到了语法的优先级、结合性。那么,你能否梳理一下你熟悉的语言的运算优先级?你能说出更多的左结合、右结合的例子吗?可以在留言区与大家一起交流。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
|
||||
|
288
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/05 | 语法分析(三):实现一门简单的脚本语言.md
Normal file
288
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/05 | 语法分析(三):实现一门简单的脚本语言.md
Normal 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<String, Integer> variables = new HashMap<String, Integer>();
|
||||
|
||||
```
|
||||
|
||||
我们简单地用了一个HashMap作为变量存储区。在变量声明语句和赋值语句里,都可以修改这个变量存储区中的数据,而获取变量值可以采用下面的代码:
|
||||
|
||||
```
|
||||
if (variables.containsKey(varName)) {
|
||||
Integer value = variables.get(varName); //获取变量值
|
||||
if (value != null) {
|
||||
result = value; //设置返回值
|
||||
} else { //有这个变量,没有值
|
||||
throw new Exception("variable " + varName + " has not been set any value");
|
||||
}
|
||||
}
|
||||
else{ //没有这个变量。
|
||||
throw new Exception("unknown variable: " + varName);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过这样的一个简单的存储机制,我们就能支持变量了。当然,这个存储机制可能过于简单了,我们后面讲到作用域的时候,这么简单的存储机制根本不够。不过目前我们先这么用着,以后再考虑改进它。
|
||||
|
||||
## 解析赋值语句
|
||||
|
||||
接下来,我们来解析赋值语句,例如“age = age + 10 * 2;”:
|
||||
|
||||
```
|
||||
private SimpleASTNode assignmentStatement(TokenReader tokens) throws Exception {
|
||||
SimpleASTNode node = null;
|
||||
Token token = tokens.peek(); //预读,看看下面是不是标识符
|
||||
if (token != null && token.getType() == TokenType.Identifier) {
|
||||
token = tokens.read(); //读入标识符
|
||||
node = new SimpleASTNode(ASTNodeType.AssignmentStmt, token.getText());
|
||||
token = tokens.peek(); //预读,看看下面是不是等号
|
||||
if (token != null && token.getType() == TokenType.Assignment) {
|
||||
tokens.read(); //取出等号
|
||||
SimpleASTNode child = additive(tokens);
|
||||
if (child == null) { //出错,等号右面没有一个合法的表达式
|
||||
throw new Exception("invalide assignment statement, expecting an expression");
|
||||
}
|
||||
else{
|
||||
node.addChild(child); //添加子节点
|
||||
token = tokens.peek(); //预读,看看后面是不是分号
|
||||
if (token != null && token.getType() == TokenType.SemiColon) {
|
||||
tokens.read(); //消耗掉这个分号
|
||||
|
||||
} else { //报错,缺少分号
|
||||
throw new Exception("invalid statement, expecting semicolon");
|
||||
}
|
||||
}
|
||||
}
|
||||
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 && 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("invalide assignment expression, expecting an additive expression");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可能会意识到一个问题,当我们在算法中匹配不成功的时候,我们前面说的是应该回溯呀,应该再去尝试其他可能性呀,为什么在这里报错了呢?换句话说,什么时候该回溯,什么时候该提示这里发生了语法错误呢?
|
||||
|
||||
其实这两种方法最后的结果是一样的。我们提示语法错误的时候,是说我们知道已经没有其他可能的匹配选项了,不需要浪费时间去回溯。就比如,在我们的语法中,等号后面必然跟表达式,否则就一定是语法错误。你在这里不报语法错误,等试探完其他所有选项后,还是需要报语法错误。所以说,提前报语法错误,实际上是我们写算法时的一种优化。
|
||||
|
||||
在写编译程序的时候,我们不仅仅要能够解析正确的语法,还要尽可能针对语法错误提供友好的提示,帮助用户迅速定位错误。错误定位越是准确、提示越是友好,我们就越喜欢它。
|
||||
|
||||
好了,到目前为止,已经能够能够处理几种不同的语句,如变量声明语句,赋值语句、表达式语句,那么我们把所有这些成果放到一起,来体会一下使用自己的脚本语言的乐趣吧!
|
||||
|
||||
我们需要一个交互式的界面来输入程序,并执行程序,这个交互式的界面就叫做**REPL。**
|
||||
|
||||
## 实现一个简单的REPL
|
||||
|
||||
脚本语言一般都会提供一个命令行窗口,让你输入一条一条的语句,马上解释执行它,并得到输出结果,比如Node.js、Python等都提供了这样的界面。**这个输入、执行、打印的循环过程就叫做REPL(Read-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 = "";
|
||||
System.out.print("\n>"); //提示符
|
||||
|
||||
while (true) { //无限循环
|
||||
try {
|
||||
String line = reader.readLine().trim(); //读入一行
|
||||
if (line.equals("exit();")) { //硬编码退出条件
|
||||
System.out.println("good bye!");
|
||||
break;
|
||||
}
|
||||
scriptText += line + "\n";
|
||||
if (line.endsWith(";")) { //如果没有遇到分号的话,会再读一行
|
||||
ASTNode tree = parser.parse(scriptText); //语法解析
|
||||
if (verbose) {
|
||||
parser.dumpAST(tree, "");
|
||||
}
|
||||
|
||||
script.evaluate(tree, ""); //对AST求值,并打印
|
||||
|
||||
System.out.print("\n>"); //显示一个提示符
|
||||
|
||||
scriptText = "";
|
||||
}
|
||||
|
||||
} catch (Exception e) { //如果发现语法错误,报错,然后可以继续执行
|
||||
System.out.println(e.getLocalizedMessage());
|
||||
System.out.print("\n>"); //提示符
|
||||
scriptText = "";
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行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)上都有,希望你能下载玩一玩。
|
304
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/06 | 编译器前端工具(一):用Antlr生成词法、语法分析器.md
Normal file
304
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/06 | 编译器前端工具(一):用Antlr生成词法、语法分析器.md
Normal 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: '"' .*? '"' ; //字符串字面量
|
||||
|
||||
//操作符
|
||||
AssignmentOP: '=' ;
|
||||
RelationalOP: '>'|'>='|'<' |'<=' ;
|
||||
Star: '*';
|
||||
Plus: '+';
|
||||
Sharp: '#';
|
||||
SemiColon: ';';
|
||||
Dot: '.';
|
||||
Comm: ',';
|
||||
LeftBracket : '[';
|
||||
RightBracket: ']';
|
||||
LeftBrace: '{';
|
||||
RightBrace: '}';
|
||||
LeftParen: '(';
|
||||
RightParen: ')';
|
||||
|
||||
//标识符
|
||||
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*;
|
||||
|
||||
//空白字符,抛弃
|
||||
Whitespace: [ \t]+ -> skip;
|
||||
Newline: ( '\r' '\n'?|'\n')-> skip;
|
||||
|
||||
```
|
||||
|
||||
你能很直观地看到,每个词法规则都是大写字母开头,这是Antlr对词法规则的约定。而语法规则是以小写字母开头的。其中,每个规则都是用我们已经了解的正则表达式编写的。
|
||||
|
||||
接下来,我们来编译词法规则,在终端中输入命令:
|
||||
|
||||
```
|
||||
antlr Hello.g4
|
||||
|
||||
```
|
||||
|
||||
这个命令是让Antlr编译规则文件,并生成Hello.java文件和其他两个辅助文件。你可以打开看一看文件里面的内容。接着,我用下面的命令编译Hello.java:
|
||||
|
||||
```
|
||||
javac *.java
|
||||
|
||||
```
|
||||
|
||||
结果会生成Hello.class文件,这就是我们生成的词法分析器。接下来,我们来写个脚本文件,让生成的词法分析器解析一下:
|
||||
|
||||
```
|
||||
int age = 45;
|
||||
if (age >= 17+8+20){
|
||||
printf("Hello old man!");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们将上面的脚本存成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’,< Id >,1:4]为例,其中@1是Token的流水编号,表明这是1号Token;4:6是Token在字符流中的开始和结束位置;age是文本值,Id是其Token类别;最后的1:4表示这个Token在源代码中位于第1行、第4列。
|
||||
|
||||
非常好,现在我们已经让Antlr顺利跑起来了!接下来,让词法规则更完善、更严密一些吧!**怎么做呢?当然是参考成熟的规则文件。**
|
||||
|
||||
从Antlr的一些示范性的规则文件中,我选了Java的作为参考。先看看我们之前写的字符串字面量的规则:
|
||||
|
||||
```
|
||||
StringLiteral: '"' .*? '"' ; //字符串字面量
|
||||
|
||||
```
|
||||
|
||||
我们的版本相当简化,就是在双引号可以包含任何字符。可这在实际中不大好用,因为连转义功能都没有提供。我们对于一些不可见的字符,比如回车,要提供转义功能,如“\n”。同时,如果字符串里本身有双引号的话,也要将它转义,如“\”。Unicode也要转义。最后,转义字符本身也需要转义,如“\\”。
|
||||
|
||||
下面这一段内容是Java语言中的字符串字面量的完整规则。你可以看一下文稿,这个规则就很细致了,把各种转义的情况都考虑进去了:
|
||||
|
||||
```
|
||||
STRING_LITERAL: '"' (~["\\\r\n] | EscapeSequence)* '"';
|
||||
|
||||
fragment EscapeSequence
|
||||
: '\\' [btnfr"'\\]
|
||||
| '\\' ([0-3]? [0-7])? [0-7]
|
||||
| '\\' 'u'+ HexDigit HexDigit HexDigit HexDigit
|
||||
;
|
||||
|
||||
fragment HexDigit
|
||||
: [0-9a-fA-F]
|
||||
;
|
||||
|
||||
```
|
||||
|
||||
在这个规则文件中,fragment指的是一个语法片段,是为了让规则定义更清晰。它本身并不生成Token,只有StringLiteral规则才会生成Token。
|
||||
|
||||
当然了,除了字符串字面量,数字字面量、标识符的规则也可以定义得更严密。不过,因为这些规则文件都很严密,写出来都很长,在这里我就不一一展开了。如果感兴趣,我推荐你在下载的规则文件中找到这些部分看一看。你还可以参考不同作者写的词法规则,体会一下他们的设计思路。和高手过招,会更快地提高你的水平。
|
||||
|
||||
我也拷贝了一些成熟的词法规则,编写了一个CommonLexer.g4的规则文件,这个词法规则是我们后面工作的基础,它基本上已经达到了专业、实用的程度。
|
||||
|
||||
在带你借鉴了成熟的规则文件之后,我想穿插性地讲解一下在词法规则中对Token归类的问题。在设计词法规则时,你经常会遇到这个问题,解决这个问题,词法规则会更加完善。
|
||||
|
||||
在前面练习的规则文件中,我们把>=、>、<都归类为关系运算符,算作同一类Token,而+、*等都单独作为另一类Token。那么,哪些可以归并成一类,哪些又是需要单独列出的呢?
|
||||
|
||||
其实,这主要取决于语法的需要。也就是在语法规则文件里,是否可以出现在同一条规则里。它们在语法层面上没有区别,只是在语义层面上有区别。比如,加法和减法虽然是不同的运算,但它们可以同时出现在同一条语法规则中,它们在运算时的特性完全一致,包括优先级和结合性,乘法和除法可以同时出现在乘法规则中。你把加号和减号合并成一类,把乘号和除号合并成一类是可以的。把这4个运算符每个都单独作为一类,也是可以的。但是,不能把加号和乘号作为同一类,因为它们在算术运算中的优先级不同,肯定出现在不同的语法规则中。
|
||||
|
||||
我们再来回顾一下在“[02 | 正则文法和有限自动机:纯手工打造词法分析器](https://time.geekbang.org/column/article/118378)”里做词法分析时遇到的一个问题。当时,我们分析了词法冲突的问题,即标识符和关键字的规则是有重叠的。Antlr是怎么解决这个问题的呢?很简单,它引入了优先级的概念。在Antlr的规则文件中,越是前面声明的规则,优先级越高。所以,我们把关键字的规则放在ID的规则前面。算法在执行的时候,会首先检查是否为关键字,然后才会检查是否为ID,也就是标识符。
|
||||
|
||||
这跟我们当时构造有限自动机做词法分析是一样的。那时,我们先判断是不是关键字,如果不是关键字,才识别为标识符。而在Antlr里,仅仅通过声明的顺序就解决了这个问题,省了很多事儿啊!
|
||||
|
||||
再说个有趣的题外话。之前国内有人提“中文编程语言”的概念,也就是语法中的关键字采用中文,比如“如果”“那么”等。他们似乎觉得这样更容易理解和掌握。我不太提倡这种想法,别的不说,用中文写关键字和变量名,需要输入更多的字符,有点儿麻烦。中国的英语教育很普及,用英语来写代码,其实就够了。
|
||||
|
||||
不过,你大可以试一下,让自己的词法规则支持中文关键字。比如,把“If”的规则改成同时支持英文的“if”,以及中文的“如果”:
|
||||
|
||||
```
|
||||
If: 'if' | '如果';
|
||||
|
||||
```
|
||||
|
||||
再把测试用的脚本hello.play中的“if”也改成“如果”,写成:
|
||||
|
||||
```
|
||||
如果 (age >= 17+8+20){
|
||||
|
||||
```
|
||||
|
||||
重新生成词法分析器并运行,你会发现输出中有这么一行:
|
||||
|
||||
```
|
||||
[@5,14:15='如果',<If>,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>
|
||||
|
||||
|
423
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/07 | 编译器前端工具(二):用Antlr重构脚本语言.md
Normal file
423
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/07 | 编译器前端工具(二):用Antlr重构脚本语言.md
Normal 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 ('<' '<' | '>' '>' '>' | '>' '>') expression
|
||||
| expression bop=('<=' | '>=' | '>' | '<') expression
|
||||
| expression bop=INSTANCEOF typeType
|
||||
| expression bop=('==' | '!=') expression
|
||||
| expression bop='&' expression
|
||||
| expression bop='^' expression
|
||||
| expression bop='|' expression
|
||||
| expression bop='&&' expression
|
||||
| expression bop='||' expression
|
||||
| expression bop='?' expression ':' expression
|
||||
| <assoc=right> expression
|
||||
bop=('=' | '+=' | '-=' | '*=' | '/=' | '&=' | '|=' | '^=' | '>>=' | '>>>=' | '<<=' | '%=')
|
||||
expression
|
||||
;
|
||||
|
||||
```
|
||||
|
||||
这个文件几乎包括了我们需要的所有的表达式规则,包括几乎没提到的点符号表达式、递增和递减表达式、数组表达式、位运算表达式规则等,已经很完善了。
|
||||
|
||||
那么它是怎样支持优先级的呢?原来,优先级是通过右侧不同产生式的顺序决定的。在标准的上下文无关文法中,产生式的顺序是无关的,但在具体的算法中,会按照确定的顺序来尝试各个产生式。
|
||||
|
||||
你不可能一会儿按这个顺序,一会儿按那个顺序。然而,同样的文法,按照不同的顺序来推导的时候,得到的AST可能是不同的。我们需要注意,这一点从文法理论的角度,是无法接受的,但从实践的角度,是可以接受的。比如LL文法和LR文法的概念,是指这个文法在LL算法或LR算法下是工作正常的。又比如我们之前做加法运算的那个文法,就是递归项放在右边的那个,在递归下降算法中会引起结合性的错误,但是如果用LR算法,就完全没有这个问题,生成的AST完全正确。
|
||||
|
||||
```
|
||||
additiveExpression
|
||||
: IntLiteral
|
||||
| IntLiteral Plus additiveExpression
|
||||
;
|
||||
|
||||
```
|
||||
|
||||
Antlr的这个语法实际上是把产生式的顺序赋予了额外的含义,用来表示优先级,提供给算法。所以,我们可以说这些文法是Antlr文法,因为是与Antlr的算法相匹配的。当然,这只是我起的一个名字,方便你理解,免得你产生困扰。
|
||||
|
||||
我们再来看看Antlr是如何依据这个语法规则实现结合性的。在语法文件中,Antlr对于赋值表达式做了<assoc=right>的属性标注,说明赋值表达式是右结合的。如果不标注,就是左结合的,交给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 > b)
|
||||
if (c > d)
|
||||
做一些事情;
|
||||
else
|
||||
做另外一些事情;
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,我故意取消了代码的缩进。那么,你能不能看出else是跟哪个if配对的呢?
|
||||
|
||||
一旦你语法规则写得不够好,就很可能形成二义性,也就是用同一个语法规则可以推导出两个不同的句子,或者说生成两个不同的AST。这种文法叫做二义性文法,比如下面这种写法:
|
||||
|
||||
```
|
||||
stmt -> 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 > b)
|
||||
if (c > d)
|
||||
做一些事情;
|
||||
else
|
||||
做另外一些事情;
|
||||
|
||||
```
|
||||
|
||||
那么,有没有办法把语法写成没有二义性的呢?当然有了。
|
||||
|
||||
```
|
||||
stmt -> fullyMatchedStmt | partlyMatchedStmt
|
||||
fullyMatchedStmt -> if expr fullyMatchedStmt else fullyMatchedStmt
|
||||
| other
|
||||
partlyMatchedStmt -> 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 < 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<T> extends ParseTreeVisitor<T> {...}
|
||||
|
||||
public class PlayScriptBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements PlayScriptVisitor<T> {...}
|
||||
|
||||
```
|
||||
|
||||
在PlayScriptBaseVisitor中,可以看到很多visitXXX()这样的方法,每一种AST节点都对应一个方法,例如:
|
||||
|
||||
```
|
||||
@Override public T visitPrimitiveType(PlayScriptParser.PrimitiveTypeContext ctx) {...}
|
||||
|
||||
```
|
||||
|
||||
其中泛型< T >指的是访问每个节点时返回的数据的类型。在我们手工编写的版本里,当时只处理整数,所以返回值一律用Integer,现在我们实现的版本要高级一点,AST节点可能返回各种类型的数据,比如:
|
||||
|
||||
- 浮点型运算的时候,会返回浮点数;
|
||||
- 字符类型运算的时候,会返回字符型数据;
|
||||
- 还可能是程序员自己设计的类型,如某个类的实例。
|
||||
|
||||
所以,我们就让Visitor统一返回Object类型好了,能够适用于各种情况。这样,我们的Visitor就是下面的样子(泛型采用了Object):
|
||||
|
||||
```
|
||||
public class MyVisitor extends PlayScriptBaseVisitor<Object>{
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,在visitExpression()方法中,我们可以编写各种表达式求值的代码,比如,加法和减法运算的代码如下:
|
||||
|
||||
```
|
||||
public Object visitExpression(ExpressionContext ctx) {
|
||||
Object rtn = null;
|
||||
//二元表达式
|
||||
if (ctx.bop != null && ctx.expression().size() >= 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)
|
||||
|
||||
|
534
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/08 | 作用域和生存期:实现块作用域和函数.md
Normal file
534
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/08 | 作用域和生存期:实现块作用域和函数.md
Normal 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 <stdio.h>
|
||||
|
||||
int a = 1;
|
||||
|
||||
void fun()
|
||||
{
|
||||
a = 2;
|
||||
//b = 3; //出错,不知道b是谁
|
||||
int a = 3; //允许声明一个同名的变量吗?
|
||||
int b = a; //这里的a是哪个?
|
||||
printf("in fun: a=%d b=%d \n", a, b);
|
||||
}
|
||||
|
||||
int b = 4; //b的作用域从这里开始
|
||||
|
||||
int main(int argc, char **argv){
|
||||
printf("main--1: a=%d b=%d \n", a, b);
|
||||
|
||||
fun();
|
||||
printf("main--2: a=%d b=%d \n", a, b);
|
||||
|
||||
//用本地变量覆盖全局变量
|
||||
int a = 5;
|
||||
int b = 5;
|
||||
printf("main--3: a=%d b=%d \n", a, b);
|
||||
|
||||
//测试块作用域
|
||||
if (a > 0){
|
||||
int b = 3; //允许在块里覆盖外面的变量
|
||||
printf("main--4: a=%d b=%d \n", a, b);
|
||||
}
|
||||
else{
|
||||
int b = 4; //跟if块里的b是两个不同的变量
|
||||
printf("main--5: a=%d b=%d \n", a, b);
|
||||
}
|
||||
|
||||
printf("main--6: a=%d b=%d \n", 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 > 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("1: a=%d b=%d", a, b);
|
||||
|
||||
if (a > 0) {
|
||||
a = 4;
|
||||
console.log("2: a=%d b=%d", a, b);
|
||||
var b = 3; //看似声明了一个新变量,其实还是引用的外部变量
|
||||
console.log("3: a=%d b=%d", a, b);
|
||||
}
|
||||
else {
|
||||
var b = 4;
|
||||
console.log("4: a=%d b=%d", a, b);
|
||||
}
|
||||
|
||||
console.log("5: a=%d b=%d", a, b);
|
||||
|
||||
for (var b = 0; b< 2; b++){ //这里是否能声明一个新变量,用于for循环?
|
||||
console.log("6-%d: a=%d b=%d",b, a, b);
|
||||
}
|
||||
|
||||
console.log("7: a=%d b=%d", 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就消失了,这个指针所占用的内存(&b)就收回了,其中&b是取b的地址,这个地址是指向栈里的一小块空间,因为b是栈里申请的。在这个栈里的小空间里保存了一个地址,指向在堆里申请的内存。这块内存,也就是用来实际保存数值2的空间,并没有被收回,我们必须手动使用free()函数来收回。
|
||||
|
||||
```
|
||||
/*
|
||||
extent.c
|
||||
测试生存期。
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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("after called fun: b=%lu *b=%d \n", (unsigned long)p, *p);
|
||||
|
||||
free(p);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
类似的情况在Java里也有。Java的对象实例缺省情况下是在堆中生成的。下面的示例代码中,从一个方法中返回了对象的引用,我们可以基于这个引用继续修改对象的内容,这证明这个对象的内存并没有被释放:
|
||||
|
||||
```
|
||||
/**
|
||||
* Extent2.java
|
||||
* 测试Java的生存期特性
|
||||
*/
|
||||
public class Extent2{
|
||||
|
||||
StringBuffer myMethod(){
|
||||
StringBuffer b = new StringBuffer(); //在堆中生成对象实例
|
||||
b.append("Hello ");
|
||||
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("World!"); //修改内存中的内容
|
||||
System.out.println(c);
|
||||
|
||||
//跟在myMethod()中打印的值相同
|
||||
System.out.println(System.identityHashCode(c));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因为Java对象所采用的内存超出了申请内存时所在的作用域,所以也就没有办法自动收回。所以Java采用的是自动内存管理机制,也就是垃圾回收技术。
|
||||
|
||||
那么为什么说作用域和生存期是计算机语言更加基础的概念呢?其实是因为它们对应到了运行时的内存管理的基本机制。虽然各门语言设计上的特性是不同的,但在运行期的机制都很相似,比如都会用到栈和堆来做内存管理。
|
||||
|
||||
好了,理解了作用域和生存期的原理之后,我们就来实现一下,先来设计一下作用域机制,然后再模拟实现一个栈。
|
||||
|
||||
## 实现作用域和栈
|
||||
|
||||
在之前的PlayScript脚本的实现中,处理变量赋值的时候,我们简单地把变量存在一个哈希表里,用变量名去引用,就像下面这样:
|
||||
|
||||
```
|
||||
public class SimpleScript {
|
||||
private HashMap<String, Integer> variables = new HashMap<String, Integer>();
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但如果变量存在多个作用域,这样做就不行了。这时,我们就要设计一个数据结构,区分不同变量的作用域。分析前面的代码,你可以看到作用域是一个树状的结构,比如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<Symbol> symbols = new LinkedList<Symbol>();
|
||||
}
|
||||
|
||||
//块作用域
|
||||
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<StackFrame> stack = new Stack<StackFrame>();
|
||||
|
||||
public class StackFrame {
|
||||
//该frame所对应的scope
|
||||
Scope scope = null;
|
||||
|
||||
//enclosingScope所对应的frame
|
||||
StackFrame parentFrame = null;
|
||||
|
||||
//实际存放变量的地方
|
||||
PlayObject object = null;
|
||||
}
|
||||
|
||||
public class PlayObject {
|
||||
//成员变量
|
||||
protected Map<Variable, Object> fields = new HashMap<Variable, Object>();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
目前,我们只是在概念上模仿栈桢,当我们用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 = "int age = 44; for(int i = 0;i<10;i++) { age = age + 2;} int i = 8;";
|
||||
|
||||
```
|
||||
|
||||
进一步的,我们可以实现对函数的支持。
|
||||
|
||||
## 实现函数功能
|
||||
|
||||
先来看一下与函数有关的语法:
|
||||
|
||||
```
|
||||
//函数声明
|
||||
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<Variable> parameters = new LinkedList<Variable>();
|
||||
|
||||
//返回值
|
||||
protected Type returnType = null;
|
||||
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在调用函数时,我们实际上做了三步工作:
|
||||
|
||||
- 建立一个栈桢;
|
||||
- 计算所有参数的值,并放入栈桢;
|
||||
- 执行函数声明中的函数体。
|
||||
|
||||
我把相关代码放在了下面,你可以看一下:
|
||||
|
||||
```
|
||||
//函数声明的AST节点
|
||||
FunctionDeclarationContext functionCode = (FunctionDeclarationContext) function.ctx;
|
||||
|
||||
//创建栈桢
|
||||
functionObject = new FunctionObject(function);
|
||||
StackFrame functionFrame = new StackFrame(functionObject);
|
||||
|
||||
// 计算实参的值
|
||||
List<Object> paramValues = new LinkedList<Object>();
|
||||
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 < 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 = "int b= 10; int myfunc(int a) {return a+b+3;} myfunc(2);";
|
||||
|
||||
```
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我带你实现了块作用域和函数,还跟你一起探究了计算机语言的两个底层概念:作用域和生存期。你要知道:
|
||||
|
||||
- 对作用域的分析是语义分析的一项工作。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)
|
||||
|
||||
|
309
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/09 | 面向对象:实现数据和方法的封装.md
Normal file
309
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/09 | 面向对象:实现数据和方法的封装.md
Normal 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 = "";
|
||||
|
||||
//构造方法
|
||||
Mammal(string str){
|
||||
name = str;
|
||||
}
|
||||
|
||||
//方法
|
||||
void speak(){
|
||||
println("mammal " + name +" speaking...");
|
||||
}
|
||||
}
|
||||
|
||||
Mammal mammal = Mammal("dog"); //playscript特别的构造方法,不需要new关键字
|
||||
mammal.speak(); //访问对象方法
|
||||
println("mammal.name = " + mammal.name); //访问对象的属性
|
||||
|
||||
//没有构造方法,创建的时候用缺省构造方法
|
||||
class Bird{
|
||||
int speed = 50; //在缺省构造方法里初始化
|
||||
|
||||
void fly(){
|
||||
println("bird flying...");
|
||||
}
|
||||
}
|
||||
|
||||
Bird bird = Bird(); //采用缺省构造方法
|
||||
println("bird.speed : " + bird.speed + "km/h");
|
||||
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<Symbol> symbols = new LinkedList<Symbol>(
|
||||
}
|
||||
|
||||
public interface Type {
|
||||
public String getName(); //类型名称
|
||||
|
||||
public Scope getEnclosingScope();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个设计中,我们看到Class就是一个Scope,Scope里面原来就能保存各种成员,现在可以直接复用,用来保存类的属性和方法,画成类图如下:
|
||||
|
||||
<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("dog"); //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("unknown class constructor: " + 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<Variable, Object> fields = new HashMap<Variable, Object>();
|
||||
|
||||
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("mammal.name = " + 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)
|
||||
|
||||
|
505
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/10 | 闭包: 理解了原理,它就不反直觉了.md
Normal file
505
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/10 | 闭包: 理解了原理,它就不反直觉了.md
Normal 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("outside: a=%d", a);
|
||||
|
||||
var fun2 = fun1(); // 生成闭包
|
||||
for (var i = 0; i< 2; i++){
|
||||
console.log("fun2: b=%d a=%d",fun2(), a); //通过fun2()来访问b
|
||||
}
|
||||
|
||||
var fun3 = fun1(); // 生成第二个闭包
|
||||
for (var i = 0; i< 2; i++){
|
||||
console.log("fun3: b=%d a=%d",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 = ["1","2","3"].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<Type> 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("in foo, a = " + a);
|
||||
return a;
|
||||
}
|
||||
|
||||
int bar (function int(int) fun){
|
||||
int b = fun(6);
|
||||
println("in bar, b = " + 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< 3; i++){
|
||||
println("b = " + fun2() + ", a = "+a);
|
||||
}
|
||||
|
||||
function int() fun3 = fun1();
|
||||
for (int i = 0; i< 3; i++){
|
||||
println("b = " + fun3() + ", a = "+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<Variable> calcClosureVariables(Function function){
|
||||
Set<Variable> refered = variablesReferedByScope(function);
|
||||
Set<Variable> 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("original list:");
|
||||
list.dump();
|
||||
|
||||
println();
|
||||
println("add 1 to each element:");
|
||||
LinkedList list2 = list.map(addOne);
|
||||
list2.dump();
|
||||
|
||||
println();
|
||||
println("square of each element:");
|
||||
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)
|
||||
|
||||
|
239
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/11 | 语义分析(上):如何建立一个完善的类型系统?.md
Normal file
239
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/11 | 语义分析(上):如何建立一个完善的类型系统?.md
Normal 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 &&
|
||||
type2 instanceof PrimitiveType){
|
||||
//类型“向上”对齐,比如一个int和一个float,取float
|
||||
type = PrimitiveType.getUpperType(type1,type2);
|
||||
}else{
|
||||
at.log("operand should be PrimitiveType for additive operation", 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)
|
||||
|
||||
|
270
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/12 | 语义分析(下):如何做上下文相关情况的处理?.md
Normal file
270
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/12 | 语义分析(下):如何做上下文相关情况的处理?.md
Normal 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 <stdio.h>
|
||||
|
||||
int a = 1;
|
||||
|
||||
void fun()
|
||||
{
|
||||
a = 2; //这是指全局变量a
|
||||
int a = 3; //声明一个本地变量
|
||||
int b = a; //这个a指的是本地变量
|
||||
printf("in func: a=%d b=%d \n", 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 → "(" add ")"
|
||||
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 → "(" add ")" [ primary.value = add.value ]
|
||||
primary → integer [ primary.value = strToInt(integer.str) ]
|
||||
|
||||
```
|
||||
|
||||
利用属性文法,我们可以定义规则,然后用工具自动实现对属性的计算。有同学曾经问:“我们解析表达式2+3的时候,得到一个AST,但我怎么知道它运算的时候是做加法呢?”
|
||||
|
||||
因为我们可以在语法规则的基础上制定属性文法,在解析语法的过程中或者形成AST之后,我们就可以根据属性文法的规则做属性计算。比如在Antlr中,你可以在语法规则文件中插入一些代码,在语法分析的过程中执行你的代码,完成一些必要的计算。
|
||||
|
||||
**总结一下属性计算的特点:它会基于语法规则,增加一些与语义处理有关的规则。**
|
||||
|
||||
所以,我们也把这种语义规则的定义叫做语法制导的定义(Syntax directed definition,SDD),如果变成计算动作,就叫做语法制导的翻译(Syntax directed translation,SDT)。
|
||||
|
||||
属性计算,可以伴随着语法分析的过程一起进行,也可以在做完语法分析以后再进行。这两个阶段不一定完全切分开。甚至,我们有时候会在语法分析的时候做一些属性计算,然后把计算结果反馈回语法分析的逻辑,帮助语法分析更好地执行(这是在工程实践中会运用到的一个技巧,我这里稍微做了一个延展,帮大家开阔一下思路,免得把知识学得太固化了)。
|
||||
|
||||
那么,在解析语法的时候,如何同时做属性计算呢?我们知道,解析语法的过程,是逐步建立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<Type> types = new LinkedList<Type>();
|
||||
|
||||
// AST节点对应的Symbol
|
||||
protected Map<ParserRuleContext, Symbol> symbolOfNode = new HashMap<ParserRuleContext, Symbol>();
|
||||
|
||||
// AST节点对应的Scope,如for、函数调用会启动新的Scope
|
||||
protected Map<ParserRuleContext, Scope> node2Scope = new HashMap<ParserRuleContext, Scope>();
|
||||
|
||||
// 每个节点推断出来的类型
|
||||
protected Map<ParserRuleContext, Type> typeOfNode = new HashMap<ParserRuleContext, Type>();
|
||||
|
||||
// 命名空间,作用域的根节点
|
||||
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)
|
301
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/13 | 继承和多态:面向对象运行期的动态特性.md
Normal file
301
极客时间专栏/编译原理之美/实现一门脚本语言 · 原理篇/13 | 继承和多态:面向对象运行期的动态特性.md
Normal 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("mammal speaking...");
|
||||
}
|
||||
}
|
||||
|
||||
class Cow extends Mammal{
|
||||
void speak(){
|
||||
println("moo~~ moo~~");
|
||||
}
|
||||
}
|
||||
|
||||
class Sheep extends Mammal{
|
||||
void speak(){
|
||||
println("mee~~ mee~~");
|
||||
println("My weight is: " + weight); //weight的作用域覆盖子类
|
||||
}
|
||||
}
|
||||
|
||||
//将子类的实例赋给父类的变量
|
||||
Mammal a = Cow();
|
||||
Mammal b = Sheep();
|
||||
|
||||
//canSpeak()方法是继承的
|
||||
println("a.canSpeak() : " + a.canSpeak());
|
||||
println("b.canSpeak() : " + 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<Type> paramTypes){
|
||||
//在本级查找这个这个方法
|
||||
Function rtn = super.getFunction(name, paramTypes); //TODO 是否要检查visibility
|
||||
|
||||
//如果在本级找不到,那么递归的从父类中查找
|
||||
if (rtn == null && 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<Class> ancestorChain = new Stack<Class>();
|
||||
|
||||
// 从上到下执行缺省的初始化方法
|
||||
ancestorChain.push(theClass);
|
||||
while (theClass.getParentClass() != null) {
|
||||
ancestorChain.push(theClass.getParentClass());
|
||||
theClass = theClass.getParentClass();
|
||||
}
|
||||
|
||||
// 执行缺省的初始化方法
|
||||
StackFrame frame = new StackFrame(obj);
|
||||
pushStack(frame);
|
||||
while (ancestorChain.size() > 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("Mammal() called");
|
||||
this.weight = 100;
|
||||
}
|
||||
|
||||
Mammal(int weight){
|
||||
this(); //调用自己的另一个构造函数
|
||||
System.out.println("Mammal(int weight) called");
|
||||
this.weight = weight;
|
||||
|
||||
//这里访问属性,是自己的weight
|
||||
System.out.println("this.weight in Mammal : " + this.weight);
|
||||
|
||||
//这里的speak()调用的是谁,会显示什么数值
|
||||
this.speak();
|
||||
}
|
||||
|
||||
void speak(){
|
||||
System.out.println("Mammal's weight is : " + this.weight);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Cow extends Mammal{
|
||||
int weight = 300;
|
||||
|
||||
Cow(){
|
||||
super(200); //调用父类的构造函数
|
||||
}
|
||||
|
||||
void speak(){
|
||||
System.out.println("Cow's weight is : " + this.weight);
|
||||
System.out.println("super.weight is : " + 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程序员可以拿这个例子跟同事讨论一下,看看是不是很好玩。**
|
||||
|
||||
讨论完this,super就比较简单了,它的语义要比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)
|
||||
|
||||
|
Reference in New Issue
Block a user