271 lines
18 KiB
Markdown
271 lines
18 KiB
Markdown
# 12 | 语义分析(下):如何做上下文相关情况的处理?
|
||
|
||
我们知道,词法分析和语法分析阶段,进行的处理都是上下文无关的。可仅凭上下文无关的处理,是不能完成一门强大的语言的。比如先声明变量,再用变量,这是典型的上下文相关的情况,我们肯定不能用上下文无关文法表达这种情况,所以语法分析阶段处理不了这个问题,只能在语义分析阶段处理。**语义分析的本质,就是针对上下文相关的情况做处理。**
|
||
|
||
我们之前讲到的作用域,是一种上下文相关的情况,因为如果作用域不同,能使用的变量也是不同的。类型系统也是一种上下文相关的情况,类型推导和类型检查都要基于上下文中相关的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)
|
||
|