You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

18 KiB

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》中提出的。它是在上下文无关文法的基础上做了一些增强,使之能够计算属性值。下面是上下文无关文法表达加法和乘法运算的例子:

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 definitionSDD如果变成计算动作就叫做语法制导的翻译Syntax directed translationSDT

属性计算,可以伴随着语法分析的过程一起进行,也可以在做完语法分析以后再进行。这两个阶段不一定完全切分开。甚至,我们有时候会在语法分析的时候做一些属性计算,然后把计算结果反馈回语法分析的逻辑,帮助语法分析更好地执行(这是在工程实践中会运用到的一个技巧,我这里稍微做了一个延展,帮大家开阔一下思路,免得把知识学得太固化了)。

那么在解析语法的时候如何同时做属性计算呢我们知道解析语法的过程是逐步建立AST的过程。在这个过程中计算某个节点的属性所依赖的其他节点可能被创建出来了。比如在递归下降算法中当某个节点建立完毕以后它的所有子节点一定也建立完毕了所以S属性就可以计算出来了。同时因为语法解析是从左向右进行的它左边的兄弟节点也都建立起来了。

如果某个属性的计算除了可能依赖子节点以外只依赖左边的兄弟节点不依赖右边的这种属性就叫做L属性。它比S属性的范围更大一些包含了部分的I属性。由于我们常用的语法分析的算法都是从左向右进行的所以就很适合一边解析语法一边计算L属性。

比如C语言和Java语言进行类型分析都可以用L属性的计算来实现。因为这两门语言的类型要么是从下往上综合出来的属于S属性。要么是在做变量声明的时候由声明中的变量类型确定的类型节点在变量的左边。

2+3;      //表达式类型是整型
float a;  //a的类型是浮点型

那问题来了Go语言的类型声明是放在变量后面的这意味着类型节点一定是在右边的那就不符合L属性文法了

var a int = 10 

没关系我们没必要在语法分析阶段把属性全都计算出来等到语法分析完毕后再对AST遍历一下就好了。这时所有节点都有了计算属性也就不是难事了。

在我们的playscript语言里就采取了这种策略实际上为了让算法更清晰我把语义分析过程拆成了好几个任务对AST做了多次遍历。

第1遍类型和作用域解析TypeAndScopeScanner.java

把自定义类、函数和和作用域的树都分析出来。这么做的好处是你可以使用在前声明在后。比如你声明一个Mammal对象而Mammal类的定义是在后面才出现的在定义一个类的时候对于类的成员也会出现使用在声明之前的情况把类型解析先扫描一遍就能方便地支持这个特性。

在写属性计算的算法时计算的顺序可能是个最重要的问题。因为某属性的计算可能要依赖别的节点的属性先计算完。我们讨论的S属性、I属性和L属性都是在考虑计算顺序。像使用在前声明在后这种情况就更要特殊处理了。

第2遍类型的消解TypeResolver.java

把所有出现引用到类型的地方都消解掉比如变量声明、函数参数声明、类的继承等等。做完消解以后我们针对Mammal m;这样语句就明确的知道了m的类型。这实际上是对I属性的类型的计算。

第3遍引用的消解和S属性的类型的推导RefResolver.java

这个时候,我们对所有的变量、函数调用,都会跟它的定义关联起来,并且完成了所有的类型计算。

第4遍做类型检查TypeChecker.java

比如当赋值语句左右两边的类型不兼容的时候,就可以报错。

第5遍做一些语义合法性的检查SematicValidator.java

比如break只能出现在循环语句中如果某个函数声明了返回值就一定要有return语句等等。

语义分析的结果保存在AnnotatedTree.java类里意思是被标注了属性的语法树。注意这些属性在数据结构上并不一定是AST节点的属性我们可以借助Map等数据结构存储只是在概念上这些属性还是标注在树节点上的。

AnnotatedTree类的结构如下

public class AnnotatedTree {
    // AST
    protected ParseTree ast = null;

    // 解析出来的所有类型,包括类和函数
    protected List<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项目目录 码云 GitHub
  • PlayScript.g4语法规则 码云 GitHub
  • TypeAndScopeScanner.java类型和作用域扫描 码云 GitHub
  • TypeResolver.java消解变量声明中引用的类型 码云 GitHub
  • RefResolver.java变量和函数应用的消解及S属性的类型推断 码云 GitHub
  • TypeChecker.java类型检查 码云 GitHub