脚本语言编译前端原理笔记

一、编译器的前端技术

1. 编译原理中的“前端(Front End)”指的是编译器对程序代码的分析和理解过程。它通常只跟语言的语法有关,跟目标机器无关。而与之对应的“后端(Back End)”则是生成目标代码的过程,跟目标机器有关。编译过程如下所示:

可以看到,编译器的“前端”技术分为词法分析、语法分析和语义分析三个部分。通常,编译器的第一项工作叫做词法分析(Lexical Analysis)。就像阅读文章一样,文章是由一个个单词组成的,程序处理也一样,只不过这里不叫单词而是叫做“词法记号”即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,例如:

(1)识别age这样的标识符。它以字母开头,后面可以是字母或数字,直到遇到第一个既不是字母又不是数字的字符时结束。

(2)识别>=这样的操作符。当扫描到一个>字符时就要注意,它可能是一个GT(Greater Than,大于)操作符。但由于GE(Greater Equal,大于等于)也是以>开头的,所以再往下再看一位,如果是=,那么这个Token就是GE,否则就是GT。

(3)识别45这样的数字字面量。当扫描到一个数字字符时,就开始把它看做数字,直到遇到非数字的字符。

这些规则可以用词法分析器的生成工具来生成,比如Lex。这些规则用“正则文法(Regular Grammar)”表达,符合正则文法的表达式称为“正则表达式”。生成工具可以读入正则表达式,生成一种叫“有限自动机(Finite-state Automaton,FSA,or Finite Automaton)”的算法,来完成具体的词法分析工作。

有限自动机是有限个状态的自动机器,比如词法分析器分析整个程序的字符串,当遇到不同的字符时,会驱使它迁移到不同的状态,例如词法分析程序在扫描age时,处于“标识符”状态,等它遇到一个>符号就切换到“比较操作符”的状态。词法分析过程,就是这样一个个状态迁移的过程,如下图所示:

2. 编译器下一个阶段的工作是语法分析词法分析是识别一个个的单词,而语法分析就是在词法分析的基础上识别出程序的语法结构,这个结构是一个树状结构,是计算机容易理解和执行的。以自然语言为例。自然语言有定义良好的语法结构,比如,“我喜欢又聪明又勇敢的你”这个句子包含了“主、谓、宾”三个部分。主语是“我”,谓语是“喜欢”,宾语部分是“又聪明又勇敢的你”,其中宾语部分又可以拆成两部分,“又聪明又勇敢”是定语部分,用来修饰“你”。定语部分又可以分成“聪明”和“勇敢”两个最小的单位。

这样拆下来,会构造一棵树,里面的每个子树都有一定的结构,而这个结构要符合语法。比如,汉语是用“主谓宾”的结构,日语是用“主宾谓”的结构,如下图所示:

程序也有定义良好的语法结构,它的语法分析过程就是构造这么一棵树,这棵树叫做抽象语法树(Abstract Syntax Tree,AST)。树的每个节点(子树)是一个语法单元,这个单元的构成规则就叫“语法”。每个节点还可以有下级节点。接下来直观地看一下这棵树长什么样子,在Mac电脑上使用以下命令:

clang -cc1 -ast-dump hello.c

-ast-dump 参数使它输出 AST,而不是做常规的编译,输出的一部分结果如下所示:

如果输入一个可以计算的表达式,例如“2+3*5”,会得到一棵类似下图的AST:

形成AST以后的好处就是计算机很容易去处理。比如针对表达式形成的这棵树,从根节点遍历整棵树就可以获得表达式的值。如果再把循环语句、判断语句、赋值语句等节点加到AST上,并解释执行它,那么实际上就实现了一个脚本语言,而执行脚本语言的过程,就是遍历AST的过程

那么怎样写程序构造AST呢?一种直观的构造思路是自上而下进行分析。首先构造根节点代表整个程序,之后向下扫描Token串,构建它的子节点。当它看到一个int类型的Token时,知道这儿遇到了一个变量声明语句,于是建立一个“变量声明”节点;接着遇到age,建立一个子节点,这是第一个变量;之后遇到=,意味着这个变量有初始化值,那么建立一个初始化的子节点;最后,遇到“字面量”,其值是45。这样,一棵子树就扫描完毕了。程序退回到根节点,开始构建根节点的第二个子节点。这样递归地扫描,直到构建起一棵完整的树,如下图所示:

这个算法就是常用的递归下降算法(Recursive Descent Parsing)。递归下降算法是一种自顶向下的算法,与之对应的,还有自底向上的算法,这个算法会先将最下面的叶子节点识别出来,然后再组装上一级节点。

3. 上面讲完了词法分析、语法分析,编译器接下来做的工作是语义分析。说白了,语义分析就是要让计算机理解程序的真实意图,把一些模棱两可的地方消除掉。语义分析没有自然语言处理那么复杂,因为计算机语言的语义一般可以表达为一些规则,只要检查是否符合这些规则就行了。比如:

(1)某个表达式的计算结果是什么数据类型?如果有数据类型不匹配的情况,是否要做自动转换?

(2)如果在一个代码块的内部和外部有相同名称的变量,在执行的时候到底用哪个?就像“我喜欢又聪明又勇敢的你”中的“你”,到底指的是谁,需要明确。

(3)在同一个作用域内,不允许有两个名称相同的变量,这是唯一性检查。不能刚声明一个变量a,紧接着又声明同样名称的一个变量a。

语义分析工作的某些成果,会作为属性标注在抽象语法树上,比如在age这个标识符节点和45这个字面量节点上,都会标识它的数据类型是int型的。在这个树上还可以标记很多属性,有些属性是在之前的两个阶段就被标注上了,比如所处的源代码行号,这一行的第几个字符,这样在编译程序报错时,就可以比较清楚地了解出错的位置。做了这些属性标注以后,编译器在后面就可以依据这些信息生成目标代码了,这在编译技术的后端部分会提到。

因此总结一下,词法分析是把程序分割成一个个Token的过程,可以通过构造有限自动机来实现。语法分析是把程序的结构识别出来,并形成一棵便于由计算机处理的抽象语法树,可以用递归下降的算法来实现。语义分析是消除语义模糊,生成一些属性信息,让计算机能够依据这些信息生成目标代码

二、正则文法和有限自动机

4. 上面提到词法分析的工作是将一个长长的字符串识别出一个个的单词,这一个个单词就是Token。而且词法分析的工作是一边读取一边识别字符串的,不是把字符串都读到内存再识别。那么字符串是一连串的字符形成的,怎么把它断开成一个个的Token呢?分割的依据是什么呢?其实手工打造词法分析器的过程,就是写出正则表达式,画出有限自动机的图形,然后根据图形直观地写出解析代码的过程。先来描述一下标识符、比较操作符和数字字面量这三种Token的词法规则:

(1)标识符:第一个字符必须是字母,后面的字符可以是字母或数字。

(2)比较操作符:如>和>=。

(3)数字字面量:全部由数字构成。

就是依据这样的规则来构造有限自动机的。这样词法分析程序在遇到age、>=和45时,会分别识别成标识符、比较操作符和数字字面量。而有限自动机是下面这种样子:

每次定下一个token后会返回初始状态1。上图中的圆圈有单线的也有双线的。双线的意思是这个状态已经是一个合法的 Token 了,单线的意思是这个状态还是临时状态。按照这5种状态迁移过程,很容易编成程序,先从状态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,只要再扩展这个有限自动机,增加里面的状态和迁移路线,就可以逐步实现一个完整的词法分析器了。

5. 上面的例子涉及了4种Token,用正则表达式表达是下面的样子:

Id :        [a-zA-Z_] ([a-zA-Z_] | [0-9])*
IntLiteral: [0-9]+
GT :        '>'
GE :        '>='

先来解释一下这几个规则中用到的一些符号:

比如解析“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”为例,把有限自动机修改成下面的样子,借此解决关键字和标识符的冲突:

相应的代码也修改一下,前面的第一段代码要改成:

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  

6. 解析完“int age = 40”之后,再按照上面的方法增加一些规则,这样就能处理算术表达式,例如“2+3*5”。 增加的词法规则如下:

Plus :  '+'
Minus : '-'
Star :  '*' 
Slash : '/'

然后再修改一下有限自动机和代码,就能解析“2+3*5”了,会得到下面的输出:

IntLiteral  2
Plus        +  
IntLiteral  3  
Star        *  
IntLiteral  5  

可以看到,要实现一个词法分析器,首先需要写出每个词法的正则表达式,并画出有限自动机,之后只要用代码表示这种状态迁移过程就可以了

三、语法分析

7. 语法分析的结果是生成AST。算法分为自顶向下和自底向上算法,其中递归下降算法是一种常见的自顶向下算法。例如“int age = 45”这个语句,下面是一个语法分析算法的示意图:

首先把变量声明语句的规则,用形式化的方法表达一下。它的左边是一个非终结符(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。

把解析变量声明语句和表达式的算法分别写成函数。在语法分析的时候,调用这些函数跟后面的Token串做模式匹配,匹配上了就返回一个AST节点,否则就返回null。如果中间发现跟语法规则不符,就报编译错误。在这个过程中,上级文法嵌套下级文法,上级的算法调用下级的算法,表现在生成AST中,上级算法生成上级节点,下级算法生成下级节点,这就是“下降”的含义

分析上面的伪代码和程序语句,可以看到程序结构基本上是跟文法规则同构的,这就是递归下降算法(Recursive Descent Parsing)的优点,非常直观。接着继续运行这个示例程序,输出AST:

Programm Calculator
    IntDeclaration age
        AssignmentExp =
            IntLiteral 45

8. 解析完变量声明语句,理解了“下降”的含义之后,来看看如何用上下文无关文法(Context-free Grammar,CFG描述算术表达式。解析算术表达式时会遇到更复杂的情况,这正则文法不够用,我们必用上下文无关文法来表达。算术表达式要包含加减法和乘除法两种运算,加法和乘法运算有不同的优先级,把规则分成两级:第一级是加法规则,第二级是乘法规则。把乘法规则作为加法规则的子规则,这样在解析形成AST时,乘法节点就一定是加法节点的子节点,从而被优先计算。如下所示:

additiveExpression
    :   multiplicativeExpression
    |   additiveExpression Plus multiplicativeExpression
    ;

multiplicativeExpression
    :   IntLiteral
    |   multiplicativeExpression Star IntLiteral
    ;

可以通过文法的嵌套实现对运算优先级的支持。这样在解析“2 + 3 * 5”这个算术表达式时会形成类似下面的AST:

如果要计算表达式的值,只需要对根节点求值就可以了。为了完成对根节点的求值,需要对下级节点递归求值,所以先完成“3 * 5 = 15”,然后再计算“2 + 15 = 17”,这样在解析算术表达式时,便能拿加法规则去匹配。在加法规则中会嵌套地匹配乘法规则,通过文法的嵌套实现了计算的优先级。

应该注意的是,加法规则中还递归地又引用了加法规则,通过这种递归的定义能展开、形成所有各种可能的算术表达式。比如“2+3*5”的推导过程如下:

-->additiveExpression + multiplicativeExpression
-->multiplicativeExpression + multiplicativeExpression
-->IntLiteral + multiplicativeExpression
-->IntLiteral + multiplicativeExpression * IntLiteral 
-->IntLiteral + IntLiteral * IntLiteral

这种文法已经没有办法改写成正则文法了,它比正则文法的表达能力更强,叫做“上下文无关文法”。正则文法是上下文无关文法的一个子集,它们的区别就是上下文无关文法允许递归调用,而正则文法不允许。上下文无关的意思是,无论在任何情况下文法的推导规则都是一样的,比如在变量声明语句中,可能要用到一个算术表达式来做变量初始化,而在其他地方可能也会用到算术表达式,不管在什么地方算术表达式的语法都一样,都允许用加法和乘法,计算优先级也不变。

上下文相关情况需要处理的情景也是有的,但那不是语法分析阶段负责的,而是放在语义分析阶段来处理的。

9. 上面的算法只算是用到了“下降”,没有涉及“递归”,现在就来看看如何用递归的算法翻译递归的文法。先按照前面说的把文法直观地翻译成算法,但是出现了无穷多次调用的情况,来看个例子:

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”的解析过程:

(1)首先调用乘法表达式匹配函数multiplicative()成功,返回了一个字面量节点2。

(2)接着看看右边是否能递归地匹配加法表达式。

(3)匹配的结果,真的返回了一个加法表达式“3+4”,这个变成了第二个子节点。错误就出在这里,这样的匹配顺序“3+4”一定会成为子节点,在求值时被优先计算。

所以,前面的方法其实并没有完美地解决左递归,因为它改变了加法运算的结合性规则。后面会提到方法既解决左递归问题,又不产生计算顺序的错误。

10. 其实要实现一个表达式计算,只需要基于AST做求值运算。这个计算过程比较简单,只需要对这棵树做深度优先遍历就好了。深度优先遍历也是一个递归算法。以上文中“2 + 3 * 5”的AST为例看一下:

(1)对表达式的求值,等价于对AST根节点求值。

(2)首先求左边子节点,算出是2。

(3)接着对右边子节点求值,这时候需要递归计算下一层。计算完以后返回是15(3*5)。

(4)把左右节点相加,计算出根节点的值17。

还是以“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

11. 在二元表达式的语法规则中,如果产生式的第一个元素是它自身,那么程序就会无限地递归下去,这种情况就叫做左递归。比如加法表达式的产生式“加法表达式 + 乘法表达式”,就是左递归的。语法规则是由上下文无关文法表示的,而上下文无关文法是由一组替换规则(又叫产生式)组成的,比如算术表达式的文法规则可以表达成下面这种形式:

add -> mul | add + mul
mul -> pri | mul * pri
pri -> Id | Num | (add)

按照上面的产生式,add可以替换成mul或者add + mul,这样的替换过程又叫做“推导”。以“2+3*5”和“2+3+4”这两个算术表达式为例,它们的推导过程分别如下图所示:

可以清楚地看到这两个表达式是怎样生成的。而分析过程中形成的这棵树,其实就是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写法是等价的,因为一个项多次重复,就等价于通过递归来推导。从这里还可以得到一个推论:上下文无关文法包含了正则文法,比正则文法能做更多的事情。

12. 来看看如何用语法规则来保证表达式的优先级。上面由加法规则推导到乘法规则,这种方式保证了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的写法书写的。

13. 再来讨论一下结合性这个问题。上面写过的“2+3+4”这个算术表达式的解析文法是错误的,先计算了“3+4”然后才和“2”相加,计算顺序从右到左,正确的应该是从左往右才对。什么是结合性呢?同样优先级的运算符是从左到右计算还是从右到左计算叫做结合性。常见的加减乘除等算术运算是左结合的,“.”符号也是左结合的。那有没有右结合的例子呢?赋值运算就是典型的右结合的例子,比如“x = y = 10”。

再来回顾一下上面“2+3+4”计算顺序出错的原因。用之前错误的右递归文法解析这个表达式形成的简化版本的AST如下:

根据这个AST做计算会出现计算顺序的错误。不过如果将递归项写在左边,就不会出现这种结合性的错误。于是得出一个规律:对于左结合的运算符,递归项要放在左边;而右结合的运算符,递归项放在右边。所以能看到,在写加法表达式的规则时是这样写的:

add -> mul | add + mul

14. 那么大多数二元运算都是左结合的,岂不是都要面临左递归问题?可以通过改写左递归的文法,解决这个问题。前面提到递归下降算法不能处理左递归,但并不是所有的算法都不能处理左递归,对于另外一些算法左递归是没问题的,比如LR算法。要消除左递归,以加法表达式规则为例,原来的文法是“add -> add + mul | mul”,现在改写成:

add -> mul add'
add' -> + mul add' | ε

文法中,ε(读作 epsilon)是空集的意思。接下来用刚刚改写的规则再次推导一下“2+3+4”这个表达式,得到了下图中左边的结果:

左边的分析树是推导后的结果。问题是由于 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

这样,就把左递归问题解决了。

15. 首先,一门SQL这样的脚本语言是要支持语句的,比如变量声明语句、赋值语句等等。单独一个表达式,也可以视为语句,叫做“表达式语句”。在终端里输入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 ')';

这样,就把想实现的语法特性,都用语法规则表达出来了。

16. 上面实现的公式计算器只支持了数字字面量的运算,如果能在表达式中用上变量会更有用,比如能够执行下面两句:

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;
}

上面代码的逻辑是:

(1)既然想要匹配一个赋值语句,那么首先应该看看第一个Token是不是标识符,如果不是那么就返回null,匹配失败。

(2)如果第一个Token确实是标识符,就把它消耗掉,接着看后面跟着的是不是等号。

(3)如果不是等号,那证明这个不是一个赋值语句,可能是一个表达式什么的,那么就要回退刚才消耗掉的Token,就像什么都没有发生过一样,并且返回null。回退的时候调用的方法就是unread()。

(4)如果后面跟着的确实是等号,那么在继续看后面是不是一个表达式,表达式后面跟着的是不是分号。如果不是,就报错。

这样就完成了对赋值语句的解析。

17. 在上面设计语法规则的过程中,其实有一个陷阱,这个陷阱更好地理解递归下降算法的一个特点:回溯。理解这个特点能更清晰地理解递归下降算法的执行过程,从而再去想办法优化它。考虑一下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");
}

当在算法中匹配不成功的时候,前面说的是应该回溯,应该再去尝试其他可能性,为什么在这里报错了呢?其实这两种方法最后的结果是一样的。提示语法错误的时候,是说知道已经没有其他可能的匹配选项了,不需要浪费时间去回溯。就比如在上面语法中,等号后面必然跟表达式,否则就一定是语法错误,即使在这里不报语法错误,等试探完其他所有选项后,还是需要报语法错误。所以说,提前报语法错误,实际上是写算法时的一种优化

18. 脚本语言一般都会提供一个命令行窗口来输入一条条的语句,马上解释执行它并得到输出结果,比如Node.js、Python、SQL等都提供了这样的界面。这个输入、执行、打印的循环过程就叫做REPL(Read-Eval-Print Loop)。在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 SimpleScript,就可以在终端里尝试各种语句了。如果是正确的语句,系统马上会反馈回结果。如果是错误的语句,REPL还能反馈回错误信息,并且能够继续处理下面的语句。

四、编译器前端工具:Antlr

19. 上面的例子为了方便理解都是简化了的,在实际应用中,一个完善的编译程序还要在词法方面以及语法方面实现很多其他工作,如下图所示:

Antlr是一个开源工具,支持根据规则文件生成词法分析器和语法分析器,它自身是用Java实现的,能支持更广泛的目标语言包括Java、C#、JavaScript、Python、Go、C++、Swift。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词法分析的结果,如下所示:

从结果中看到,词法分析器把每个Token都识别了,还记录了它们在代码中的位置、文本值、类别。上面这些都是Token的属性。以第二行[@1, 4:6=‘age’,< Id >,1:4]为例,其中@1是Token的流水编号,表明这是1号Token;4:6是Token在字符流中的开始和结束位置;age是文本值,Id是其Token类别;最后的 1:4 表示这个 Token 在源代码中位于第 1 行、第 4 列。

20. 现在已经让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。

在前面练习的规则文件中,把>=、>、<都归类为关系运算符,算作同一类Token,而+、*等都单独作为另一类Token。那么哪些可以归并成一类,哪些又是需要单独列出的呢?其实这主要取决于语法的需要,也就是在语法规则文件里,是否可以出现在同一条规则里。它们在语法层面上没有区别,只是在语义层面上有区别。比如,加法和减法虽然是不同的运算,但它们可以同时出现在同一条语法规则中,它们在运算时的特性完全一致,包括优先级和结合性,乘法和除法可以同时出现在乘法规则中。

把加号和减号合并成一类,把乘号和除号合并成一类是可以的。把这4个运算符每个都单独作为一类,也是可以的。但是,不能把加号和乘号作为同一类,因为它们在算术运算中的优先级不同,肯定出现在不同的语法规则中。前面做词法分析时遇到过一个问题,分析了词法冲突的问题,即标识符和关键字的规则是有重叠的。

Antlr是怎么解决这个问题的呢?很简单,它引入了优先级的概念。在Antlr的规则文件中,越是前面声明的规则优先级越高。所以,把关键字的规则放在ID的规则前面,算法在执行时会首先检查是否为关键字,然后才会检查是否为ID,也就是标识符。

21. 接下来,试着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 PlayScript.g4
javac antlrtest/*.java

然后测试一下生成的语法分析器:

grun antlrtest.PlayScript expression -gui

这个命令的意思是:测试PlayScript这个类的expression方法,也就是解析表达式的方法,结果用图形化界面显示。在控制台界面中输入下面的内容:

age + 10 * 2  + 10
^D

其中^D是Ctrl+D,相当于在终端输入一个EOF字符,即文件结束符号(Windows要使用^Z)。当然也可以提前把这些语句放到文件中,把文件名作为命令参数。之后语法分析器会分析这些语法,并弹出一个窗口来显示AST:

有了Antlr的支持,可以把主要的精力放在编写词法和语法规则上,提升了工作效率,比如SparkSQL的SQL解析用的也是Antlr4。

22. 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对于赋值表达式做了<assoc=right>的属性标注,说明赋值表达式是右结合的。如果不标注就是左结合的。通过上面那个简化的算法,AST被成功简化,不再有加法节点、乘法节点等各种不同的节点,而是统一为表达式expression节点。那如果都是同样的表达式节点,怎么在解析器里把它们区分开呢?怎么知道哪个节点是做加法运算或乘法运算呢?

很简单,可以查找一下当前节点有没有某个运算符Token。比如如果出现了或者运算的Token(“||”),就是做逻辑或运算,而且语法里面的bop=、postfix=、prefix=这些属性,作为某些运算符Token的别名,也会成为表达式节点的属性。通过查询这些属性的值,可以很快确定当前运算的类型。

23. 到目前为止,彻底完成了表达式的语法工作,可以放心地在脚本语言里使用各种表达式,把精力放在完善各类语句(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语句等等。表达式后面加一个分号也是一种语句,叫做表达式语句。从语法分析的难度来看,上面这些语句的语法比表达式的语法简单的多,左递归、优先级和结合性的问题这里都没有出现。在C和Java等语言中,if语句的语法规则是这样的:

statement : 
          ...
          | IF parExpression statement (ELSE statement)? 
          ...
          ;
parExpression : '(' expression ')';

这里用了IF和ELSE这两个关键字,也复用了已经定义好的语句规则和表达式规则。语句规则和表达式规则一旦设计完毕,就可以被其他语法规则复用。但是if语句也有让人不省心的地方,比如会涉及到二义性文法问题,所以接下来就借if语句,分析一下二义性文法这个现象,例如嵌套if语句和悬挂else的情况,比如下面这段代码:

if (a > b)
if (c > d)
doSomething();
else
doOtherthings();

上面的代码中故意取消了代码的缩进,那么能否看出else是跟哪个if配对的呢?一旦语法规则写得不够好,就很可能形成二义性,也就是用同一个语法规则可以推导出两个不同的句子,或者说生成两个不同的AST,这种文法叫做二义性文法,比如下面这种写法:

stmt -> if expr stmt
      | if expr stmt else stmt
      | other

按照这个语法规则,先采用第一条产生式推导或先采用第二条产生式推导,会得到不同的AST。左边的这棵AST中else跟第二个if配对;右边的这棵AST中,else跟第一个if配对,如下图所示:

大多数高级语言在解析这个示例代码时都会产生左边的AST,即else跟最邻近的if配对。那么有没有办法把语法写成没有二义性的呢?如下所示:

stmt -> fullyMatchedStmt | partlyMatchedStmt
fullyMatchedStmt -> if expr fullyMatchedStmt else fullyMatchedStmt
                   | other
partlyMatchedStmt -> if expr stmt
                   | if expr fullyMatchedStmt else partlyMatchedStmt

按照上面的语法规则,只有唯一的推导方式,也只能生成唯一的AST,如下所示:

其中,解析第一个if语句时只能应用partlyMatchedStmt规则,解析第二个 if else语句时,只能适用fullyMatchedStmt规则。因此,可以通过改写语法规则来解决二义性文法问题。再说回前面给Antlr定义的语法,那个语法似乎并不复杂,怎么就能确保不出现二义性问题呢?因为Antlr解析语法时用到的是LL算法。LL算法是一个深度优先的算法,所以在解析到第一个statement时,就会建立下一级的if节点,在下一级节点里会把else子句解析掉。如果Antlr不用LL算法,就会产生二义性。因此文法要经常和解析算法配合

接着再针对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如下:

熟悉了for语句的语法之后,提一下语句块(block),在if语句和for语句中会用到它,语句块的语法构成如下:

block
    : '{' blockStatements '}'
    ;

blockStatements
    : blockStatement*
    ;

blockStatement
    : variableDeclarators ';'     //变量声明
    | statement
    | functionDeclaration         //函数声明
    | classDeclaration            //类声明
    ;

24. 现在已经拥有了一个相当不错的语法体系,除了要放到后面提的函数、类有关的语法之外,已经几乎完成了playscript所有的语法设计工作。接下来再升级一下脚本解释器,让它能够支持更多的语法,同时通过使用Visitor模式让代码结构更加完善。

在上面纯手工编写的脚本语言解释器里,用了一个evaluate()方法自上而下地遍历了整棵树。随着要处理的语法越来越多,这个方法的代码量会越来越大,不便于维护。而访问者模式针对每一种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 >指的是访问每个节点时返回数据的类型。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语句中各个部分的执行规则,比如:

(1)forInit部分只能执行一次;

(2)每次循环都要执行一次forControl,看看是否继续循环;

(3)接着执行for语句中的语句体;

(4)最后执行 forUpdate 部分,通常是“i++”这样的语句。

五、作用域和生存期:实现块作用域和函数

25. 上面已经用Antlr重构了脚本解释器,有了工具的帮助可以实现更高级的功能,比如函数功能、面向对象功能。在这个过程中还要克服一些挑战,比如:

(1)如果要实现函数功能,要升级变量管理机制;

(2)引入作用域机制,来保证变量的引用指向正确的变量定义;

(3)提升变量存储机制,不能只把变量和它的值简单扔到一个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

因此可以得出这样的规律:

(1)变量的作用域有大有小,外部变量在函数内可以访问,而函数中的本地变量只有本地才可以访问。

(2)变量的作用域从声明以后开始。

(3)在函数里,可以声明跟外部变量相同名称的变量,这个时候就覆盖了外部变量

下面这张图直观地显示了示例代码中各个变量的作用域:

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语言的块作用域是不同的,它不允许块作用域里的变量覆盖外部变量。那么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,语法上是允许的,但每次用到的其实是同一个变量。从上面的例子来看,看上去差不多的语法内部机理却不同,这种不同其实是语义差别的一个例子。现在开始提到的很多内容都已经属于语义的范畴了,对作用域的分析就是语义分析的任务之一

26. 了解了什么是作用域之后,再理解一下跟它紧密相关的生存期(Extent)。它是变量可以访问的时间段,也就是从分配内存给它到收回它的内存之间的时间。在前面几个例子程序中,变量的生存期跟作用域是一致的,出了作用域生存期也就结束了,变量所占用的内存也就被释放了。这是本地变量的标准特征,这些本地变量是用栈来管理的。但也有一些情况,变量的生存期跟语法上的作用域不一致,比如在堆中申请的内存,退出作用域以后仍然会存在

例如下面这段C语言的示例代码:

/*
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);
}

其中fun函数返回了一个整数的指针。出了函数以后本地变量b就消失了,这个指针所占用的内存(&b)就收回了,其中*b是取b的地址,这个地址是指向栈里的一小块空间,因为b是栈里申请的。在这个栈里的小空间里保存了一个地址,指向在堆里申请的内存。这块内存也就是用来实际保存数值2的空间,并没有被收回,必须手动使用free()函数来收回。

类似的情况在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采用的是自动内存管理机制,也就是GC技术。作用域和生存期是计算机语言更加基础的概念,因为它们对应到了运行时的内存管理基本机制。虽然各门语言设计上的特性是不同的,但在运行期的机制都很相似,比如都会用到栈和堆来做内存管理

27. 理解了作用域和生存期的原理之后,先来设计一下作用域机制,然后再模拟实现一个栈。在之前的PlayScript脚本实现中,处理变量赋值时简单地把变量存在一个哈希表里,用变量名去引用,就像下面这样:

public class SimpleScript {
    private HashMap<String, Integer> variables = new HashMap<String, Integer>();
    ...
}

但如果变量存在多个作用域,这样做就不行了。这时就要设计一个数据结构区分不同变量的作用域。分析前面的代码,可以看到作用域是一个树状结构,比如Scope.c的作用域如下所示:

面向对象语言不太相同,它不是一棵树是一片树林,每个类对应一棵树,所以它也没有全局变量。在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的代码,随着代码的执行,各个变量的生存期表现如下:

(1)进入程序,全局变量逐一生效;

(2)进入main函数,main函数里的变量顺序生效;

(3)进入fun函数,fun函数里的变量顺序生效;

(4)退出fun函数,fun函数里的变量失效;

(5)进入if语句块,if语句块里的变量顺序生效;

(6)退出if语句块,if语句块里的变量失效;

(7)退出main函数,main函数里的变量失效;

(8)退出程序,全局变量失效。

通过下面这张图,能直观地看到运行过程中栈的变化:

代码执行时进入和退出一个个作用域的过程,可以用栈来实现。每进入一个作用域,就往栈里压入一个数据结构,这个数据结构叫做栈桢(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;
}

28. 进一步的,可以实现对函数的支持。先来看一下与函数有关的语法,如下所示:

//函数声明
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;
    
    ...
}

在调用函数时,实际上做了三步工作:

(1)建立一个栈桢;

(2)计算所有参数的值,并放入栈桢;

(3)执行函数声明中的函数体。

相关代码如下所示:

//函数声明的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();

对作用域的分析是语义分析的一项工作。Antlr 能够完成很多词法分析和语法分析的工作,但语义分析工作需要用户自己做。

六、面向对象:实现数据和方法的封装

29. 面向对象重要的特点就是封装。也就是说,对象可以把数据和对数据的操作封装在一起,构成一个不可分割的整体,尽可能地隐藏内部的细节,只保留一些接口与外部发生联系。 在对象的外部只能通过这些接口与对象进行交互,无需知道对象内部的细节。这样能降低系统的耦合,实现内部机制的隐藏,不用担心对外界的影响。接下来从语义角度,利用类型、作用域、生存期的概念剖析面向对象的封装特性。

类型处理是语义分析时的重要工作。早期的计算机语言只支持一些基础的数据类型,比如各种整型和浮点型,像字符串这种编程时离不开的类型,往往是在基础数据类型上封装和抽象出来的。从作用域来说,作为一种类型它通常在整个程序的范围内都是可见的,可以用它声明变量。一些像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
    ;

来简单地讲一下这个语法规则:

(1)类声明以class关键字开头,有一个标识符是类型名称,后面跟着类的主体。

(2)类的主体里要声明类的成员。在简化情况下,可以只关注类的属性和方法两种成员。这里故意把类的方法也叫做function而不是method,是想把对象方法和函数做一些统一的设计。

(3)函数声明现在的角色是类的方法。

(4)类的成员变量的声明和普通变量声明在语法上没什么区别。

用上面语法写出来的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,识别出所有自定义的类型,以便在其他地方引用这些类型来声明变量。因为类型的声明可以在代码中的任何位置,所以最好用单独的一次遍历来识别和记录类型。接着在声明变量时,就可以引用这个类型了。语义分析的另一个工作,就是做变量类型的消解。当声明“Bird bird = Bird();”时,需要知道Bird对象的定义在哪里,以便正确地访问它的成员。

在做语义分析时,要把类型的定义保存在一个数据结构中,来实现一下:

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里面原来就能保存各种成员,现在可以直接复用,用来保存类的属性和方法,画成类图如下:

图里有几个类,比如Symbol、Variable、Scope、Function和BlockScope,它们是上面符号体系的主要成员。在做词法分析时会解析出很多标识符,这些标识符出现在不同的语法规则里,包括变量声明、表达式,以及作为类名、方法名等出现。在语义分析阶段,要把这些标识符一一识别出来,例如是一个本地变量或者是一个方法名等。

变量、类和函数的名称都叫做符号,比如示例程序中的Mammal、Bird、mammal、bird、name、speed等。编译过程中的一项重要工作就是建立符号表,它帮助进一步地编译或执行程序,而符号表就用上面几个类来保存信息,在符号表里保存它的名称、类型、作用域等信息。对于类和函数也有相应的地方来保存类变量、方法、参数、返回值等信息。

30. 解析完这些语义信息以后,来看运行期如何执行具有面向对象特征的程序,比如如何实例化一个对象?如何在内存里管理对象的数据?以及如何访问对象的属性和方法?首先通过构造方法来创建对象。在下面语法中没有用new这个关键字来表示对象的创建,而是省略掉了直接调用一个跟类名称相同的函数,这是这里的独特设计,示例代码如下:

Mammal mammal = Mammal("dog"); //playscript特别的构造方法,不需要new关键字
Bird bird = Bird();            //采用缺省构造方法

但在语义检查的时候,在当前作用域中是肯定找不到这样一个函数的,因为类的初始化方法是在类的内部定义的,只要检查一下Mammal和Bird是不是一个类名就可以了。再进一步,Mammal类中确实有个构造方法Mammal(),而Bird类中其实没有一个显式定义的构造方法,这里借鉴了Java的初始化机制,就是提供缺省初始化方法,在缺省初始化方法里,会执行对象成员声明时所做的初始化工作。所以上面的代码里调用Bird(),实际上就是调用了这个缺省的初始化方法。无论有没有显式声明的构造方法,声明对象成员变量时的初始化部分一定会执行。对于Bird类,实际上就会执行上面的“int speed = 50;”这个语句。

在做语义分析时,下面的代码能够检测出某个函数调用其实是类的构造方法,或者是缺省构造方法:

// 看看是不是类的构建函数,用相同的名称查找一个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); // 这次函数调用是返回一个对象
}

当然,类的构造方法跟普通函数还是有所不同的,例如不允许构造方法定义返回值,因为它的返回值一定是这个类的一个实例对象

31. 对象做了缺省初始化以后,再去调用显式定义的构造方法,这样才能完善整个对象实例化的过程。不过,可以把普通本地变量的数据保存在栈里,那如何保存对象的数据呢?其实,也可以把对象的数据像其他数据一样,保存在栈里,如下图所示:

C语言的结构体struct和C++语言的对象,都可以保存在栈里。保存在栈里的对象是直接声明并实例化的,而不是用new关键字来创建的。如果用new关键字来创建,实际上是在堆里申请了一块内存,并赋值给一个指针变量,如下图所示:

当对象保存在堆里时,可以有多个变量都引用同一个对象,比如图中的变量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的垃圾收集机制回收。

32. 最后是访问对象的属性和方法。一般用点操作符来访问对象的属性和方法,如下所示:

mammal.speak();                          //访问对象方法
println("mammal.name = " + mammal.name); //访问对象的属性
属性和方法的引用也是一种表达式,语法定义如下:
expression
    : ...
    | expression bop='.'
      ( IDENTIFIER       //对象属性
      | functionCall     //对象方法
      )
     ...
     ;

这里点符号的操作是可以向Scala那样级联的,比如:

obj1.obj2.field1;
obj1.getObject2().field1;

另外,对象成员还可以设置可见性。也就是说有些成员只有对象内部才能用,有些可以由外部访问。这个怎么实现呢?这只是个语义问题,是在编译阶段做语义检查时,不允许私有的成员被外部访问,报编译错误就可以了,在其他方面并没有什么不同。

七、闭包

33. 在JavaScript中,用外层函数返回一个内层函数之后,这个内层函数能一直访问外层函数中的本地变量。按理说这个时候外层函数已经退出了,它里面的变量也该作废了。可闭包却非常执着,即使外层函数已经退出,但内层函数还可以继续访问外层函数中声明的变量。不过闭包很有用,对库的编写者来讲它能隐藏内部实现细节。来测试一下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

这个结果可以得出两点结论:

(1)内层的函数能访问自己的本地变量、外层函数的变量b和全局变量a。

(2)内层函数作为返回值赋值给其他变量以后,外层函数就结束了,但内层函数仍能访问原来外层函数的变量b,也能访问全局变量a。

站在外层函数的角度看,明明这个函数已经退出,变量b应该失效了,为什么还可以继续访问?但是如果站在inner这个函数的角度来看,声明inner函数时告诉它可以访问b,不能因为把inner函数赋值给了其他变量,inner函数里原本正确的语句就不能用了,如下图所示:

其实只要函数能作为值传来传去,就一定会产生作用域不匹配的情况,这样的内在矛盾是语言设计时就决定了的,因此闭包是为了让函数能够在这种情况下继续运行所提供的一个方案。这个方案有一些不错的特点,比如隐藏函数所使用的数据。

这里补充一下静态作用域(Static Scope),如果一门语言的作用域是静态作用域,那么符号之间的引用关系能够根据代码在编译时就确定清楚,在运行时不会变。某个函数是在哪声明的,就具有它所在位置的作用域。它能够访问哪些变量,那就跟这些变量绑定了,在运行时就一直能访问这些变量。看下面的代码,对于静态作用域而言,无论在哪里调用foo()函数,访问的变量i都是全局变量:

int i = 1;
void foo(){
  println(i); // 访问全局变量
}

foo();        // 访问全局变量

void bar(){
  int i = 2; 
  foo();      // 在这里调用foo(),访问的仍然是全局变量
}

目前的大多数语言都是采用静态作用域的。用Antlr自定义的playscript语言也是在编译时就形成一个Scope的树,变量的引用也是在编译时就做了消解不再改变,所以也是采用了静态作用域。反过来讲,如果在bar()里调用foo()时,foo()访问的是bar()函数中的本地变量i,那就说明这门语言使用的是动态作用域(Dynamic Scope),也就是说变量引用跟变量声明不是在编译时就绑定死了的。在运行时,它是在运行环境中动态地找一个相同名称的变量。在macOS或Linux中用的bash脚本语言,就是动态作用域的

静态作用域可以由程序代码决定,在编译时就能完全确定,所以又叫做词法作用域(Lexcical Scope)。不过这个词法跟做词法分析时说的词法不大一样,这里跟Lexical相对应的词汇可以认为是Runtime,一个是编译时,一个是运行时。用静态作用域的概念描述一下闭包,可以这样说:因为语言是静态作用域的,它能够访问的变量需要一直都能访问,为此需要把某些变量的生存期延长

34. 闭包的产生还有另一个条件,就是让函数成为一等公民,例如Scala这样的函数式语言。在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中声明一个函数型的变量

把上面描述函数类型的语法写成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正确地解析出来了:

目前只是设计完了语法,还要实现runtime运行期的功能,让函数真的能像数值一样传来传去,就像下面的测试代码,它把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()函数的定义”这样的错误。

35. 实现了一等公民函数的功能以后,进入最重要的一环:实现闭包功能。在这之前先设计好测试用例,先把前面提到的那个JavaScript例子用playscript的语法重写一遍,来测试闭包功能:

/**
 * closure.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如下图所示,可以直观地看一下外层函数和内层函数的关系:

现在测试用例准备好了,着手实现一下闭包的机制。前面提到,闭包的内在矛盾是运行时的环境和定义时的作用域之间的矛盾。那么把内部环境中需要的变量打包交给闭包函数,它就可以随时访问这些变量了。在AST上做一下图形化分析,看看给fun2这个变量赋值时发生了什么:

简单地描述一下给fun2赋值时的执行过程:

(1)先执行fun1()函数,内部的inner()函数作为返回值返回给调用者。这时程序能访问两层作用域,最近一层是fun1(),里面有变量b;外层还有一层,里面有全局变量a。这时是把环境变量打包的最后机会,否则退出fun1()函数以后,变量b就消失了。

(2)然后把内部函数连同打包好的环境变量的值,创建一个FunctionObject对象,作为fun1()的返回值,给到调用者。

(3)给fun2这个变量赋值。

(4)调用fun2()函数。函数执行时,有一个私有的闭包环境可以访问b的值,这个环境就是第二步所创建的FunctionObject对象。

这样最终实现了闭包的功能。在这个过程中,要提前记录下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)。函数式编程的一个典型特点就是高阶函数(High-order function)功能,高阶函数是这样一种函数,它能够接受其他函数作为自己的参数,javascript中数组的map方法与Scala中的map和foreach就是高阶函数。其实,闭包就是把函数在静态作用域中所访问的变量的生存期拉长,形成一份可以由这个函数单独访问的数据。正因为这些数据只能被闭包函数访问,所以也就具备了对信息进行封装、隐藏内部细节的特性

它和封装的概念有点像,封装即把数据和对数据的操作封在一起,就是面向对象编程的概念之一。一个闭包可以看做是一个对象。反过来看,一个对象也可以看做一个闭包。对象的属性也可以看做被方法所独占的环境变量,其生存期也必须保证能够被该方法一直正常的访问

八、语义分析

36. 在做语法分析时可以得到一棵语法树,而基于这棵树能做什么是语义的事情。比如,+号的含义是让两个数值相加,并且通常还能进行缺省的类型转换,所以如果要区分不同语言的差异,不能光看语言的语法。比如Java语言和JavaScript在代码块的语法上是一样的都用花括号,但在语义上是不同的,一个有块作用域一个没有。相比词法和语法的设计与处理,语义设计和分析要复杂很多。所以先来看类型系统这个方面。

类型系统是一门语言所有的类型的集合,操作这些类型的规则,以及类型之间如何相互作用的(比如一个类型能否转换成另一个类型)。如果要建立一个完善的类型系统,需要从两个方面出发:

(1)根据领域的需求,设计自己类型系统的特征。

(2)在编译器中支持类型检查、类型推导和类型转换。

先从设计类型系统特征的角度来看。在机器码这个层面,其实是分不出什么数据类型的,都是0101,那高级语言为什么要增加类型这种机制呢?类型是针对一组数值,以及在这组数值之上的一组操作。比如对于数字类型,可以对它进行加减乘除算术运算,对于字符串就不行。所以,类型是高级语言赋予的一种语义,有了类型这种机制,就相当于定了规矩,可以检查施加在数据上的操作是否合法。因此类型系统最大的好处,就是可以通过类型检查降低计算出错的概率

根据类型检查是在编译期还是在运行期进行的,可以把计算机语言分为两类:

(1)静态类型语言(几乎全部的类型检查是在编译期进行的)。

(2)动态类型语言(类型的检查是在运行期进行的)。

跟静态类型和动态类型概念相关联的,还有强类型和弱类型。强类型语言中,变量的类型一旦声明就不能改变,弱类型语言中,变量类型在运行期时可以改变。二者的本质区别是,强类型语言不允许违法操作,因为能够被检查出来,弱类型语言则从机制上就无法禁止违法操作,所以是不安全的。比如一个表达式a*b,如果a或b不是数值,那就没有意义了,弱类型语言可能就检查不出这类问题。因此,静态类型和动态类型说的是什么时候检查的问题,强类型和弱类型说的是就算检查也检查不出来,或者没法检查的问题,两者容易混淆。

这里的自定义playscript是静态类型和强类型的,所以几乎要做各种类型检查。既支持对象,也支持原生的基础数据类型,这两种类型的处理特点不一样。

37. 接下来说一说如何做类型检查、类型推导和类型转换。先来看一看如果编写一个编译器,在做类型分析时会遇到哪些问题。以下面这个表达式为例,在不同的情况下会有不同的运行结果:

a = b + 10

(1)如果b是一个浮点型,b+10的结果也是浮点型。如果b是字符串型的,有些语言也是允许执行+号运算的,实际结果是字符串的连接。这个分析过程就是类型推导(Type Inference)。

(2)当右边的值计算完赋值给a时,要检查左右两边的类型是否匹配。这个过程就是类型检查(Type Checking)。

(3)如果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中从下级的属性归纳、综合出本级的属性。更准确地说,是通过下级节点和自身来确定的,如下图所示:

与S属性相对应的是I属性(Inherited Attribute),也就是继承属性,即AST中某个节点的属性是由上级节点、兄弟节点和它自身来决定的,比如:

变量a的类型是int,这个很直观,因为变量声明语句中已经指出了a的类型,但这个类型不是从下级节点推导出来的,而是从兄弟节点推导出来的。在PlayScript.g4中,变量声明的相关语法如下:

variableDeclarators
    : typeType variableDeclarator (',' variableDeclarator)*
    ;

variableDeclarator
    : variableDeclaratorId ('=' variableInitializer)?
    ;

variableDeclaratorId
    : IDENTIFIER ('[' ']')*
    ;

typeType
    : (classOrInterfaceType| functionType | primitiveType) ('[' ']')*
;

把int a;这样一个简单的变量声明语句解析成AST,就形成了一棵有两个分支的树,如下所示:

这棵树的左枝可以从下向上推导类型,所以类型属性也就是S属性。而右枝则必须从根节点(也就是 variableDeclarators)往下继承类型属性,所以对a 这个节点来说,它的类型属性是I属性。很多现代语言会支持自动类型推导,例如Go语言就有两种声明变量的方式:

var a int = 10  //第一种
a := 10         //第二种

第一种方式,a的类型是显式声明的;第二种方式,a的类型是由右边的表达式推导出来的。从生成AST 中,能看到它们都是经历了从下到上的综合,再从上到下继承的过程:

38. 说完了类型推导,再看看类型检查。类型检查主要出现在几个场景中:

(1)赋值语句(检查赋值操作左边和右边的类型是否匹配)。

(2)变量声明语句(因为变量声明语句中也会有初始化部分,所以也需要类型匹配)。

(3)函数传参(调用函数的时候,传入的参数要符合形参的要求)。

(4)函数返回值(从函数中返回一个值的时候,要符合函数返回值的规定)。

类型检查还有一个特点:以赋值语句为例,左边的类型是I属性,是从声明中得到的;右边的类型是S属性,是自下而上综合出来的。当左右两边的类型相遇之后,就要检查二者是否匹配,被赋值的变量要满足左边的类型要求。如果匹自然没有问题,如果不完全匹配也不一定马上报错,而是要看看是否能进行类型转换。比如,一般的语言在处理整型和浮点型的混合运算时,都能进行自动的转换。像JavaScript和SQL甚至能够在算术运算时,自动将字符串转换成数字。在MySQL里运行下面的语句,会得到3,它自动将’2’转换成了数字:

select 1 + '2';

这个过程其实是有风险的,这就像在强类型语言中开了一个后门,绕过或部分绕过了编译器的类型检查功能。把父类转成子类的场景中,编译器顶多能检查这两个类之间是否有继承关系,如果连继承关系都没有,这当然能检查出错误,制止这种转换。但一个基类的子类可能是很多的,具体这个转换对不对,只有到运行期才能检查出错误来。

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

函数的引用消解比变量的引用消解还要更复杂一些。它不仅要比对函数名称,还要比较参数和返回值(可以叫函数原型,或者叫函数的类型)。前面在把函数提升为一等公民时,提到函数类型(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++语言

而做引用消解可能会产生几个结果:

(1)解析出了准确的引用关系。

(2)重复定义(在声明新符号时,发现这个符号已经被定义过了)。

(3)引用失败(找不到某个符号的定义)。

(4)如果两个不同的命名空间中都有相同名称的符号,编程者需要明确指定。

40. 在开发编译器或解释器的过程中,一定会遇到左值和右值的问题。比如在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节点存在于下面这几种情况中,那就需要取左值:

(1)赋值表达式的左边;

(2)带有初始化的变量声明语句中的变量;

(3)当给函数形参赋值的时候;

(4)一元操作符:++和–。

(5)其他需要改变变量内容的操作。

当然不是所有的表达式,都能生成一个合格的左值。也就是说出现在赋值语句左边的,必须是能够获得左值的表达式。比如一个变量、一个类的属性是可以的。但如果是一个常量,或者2+3这样的表达式在赋值符号的左边,那就不行。所以,判断表达式能否生成一个合格的左值也是语义检查的一项工作

40. 处理上下文相关的情况,经常用属性计算的方法。属性计算是做上下文分析,或者说语义分析的一种算法。按照属性计算的视角,之前所处理的各种语义分析问题,都可以看做是对AST节点的某个属性进行计算。比如针对求左值场景中的primary节点,它需要计算的属性包括:

(1)它的变量定义是哪个(这就引用到定义该变量的 Symbol)。

(2)它的类型是什么。

(3)它的作用域是什么。

(4)这个节点求值时,是否该返回左值、能否正确地返回一个左值。

(5)它的值是什么。

从属性计算的角度看,对表达式求值或运行脚本,只是去计算AST节点的Value属性。属性计算需要用到属性文法。在词法、语法分析阶段,分别提到了正则文法和上下文无关文法,在语义分析阶段要了解的是属性文法(Attribute Grammar)。它是在上下文无关文法的基础上做了一些增强,使之能够计算属性值。下面是上下文无关文法表达加法和乘法运算的例子:

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之后,就可以根据属性文法的规则做属性计算。因此属性计算的特点是:它会基于语法规则,增加一些与语义处理有关的规则。所以,也把这种语义规则的定义叫做语法制导的定义(Syntax directed definition,SDD),如果变成计算动作,就叫做语法制导的翻译(Syntax directed translation,SDT)。

属性计算可以伴随着语法分析的过程一起进行,也可以在做完语法分析以后再进行。这两个阶段不一定完全切分开,甚至有时候会在语法分析时做一些属性计算,然后把计算结果反馈回语法分析的逻辑,帮助语法分析更好地执行。那么在解析语法时,如何同时做属性计算呢?解析语法的过程是逐步建立AST的过程。在这个过程中,计算某个节点属性所依赖的其他节点可能被创建出来了。比如在递归下降算法中,当某个节点建立完毕以后,它的所有子节点一定也建立完毕了,所以S属性就可以计算出来了。同时,因为语法解析是从左向右进行的,它左边的兄弟节点也都建立起来了。

如果某个属性的计算除了可能依赖子节点以外,只依赖左边的兄弟节点,不依赖右边的,这种属性就叫做L属性。它比S属性的范围更大一些,包含了部分的I属性。由于常用的语法分析算法都是从左向右进行的,所以就很适合一边解析语法,一边计算L属性。比如C和Java语言进行类型分析,都可以用L属性的计算来实现。因为这两门语言的类型要么是从下往上综合出来的,属于S属性。要么是在做变量声明时,由声明中的变量类型确定的,类型节点在变量的左边,如下所示:

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

41. 但Go和Scala的类型声明是放在变量后面的,这意味着类型节点一定是在右边的,那就不符合L属性文法了:

var a int = 10

但没关系,没必要在语法分析阶段把属性全都计算出来,等到语法分析完毕后再对AST遍历一下就好了。这时所有节点都有了,计算属性也就不难了。在上面自定义的playscript语言里就采取了这种策略。为了让算法更清晰,playscript把语义分析过程拆成了好几个任务,对AST做了多次遍历:

(1)第1遍:类型和作用域解析。把自定义类、函数和和作用域的树都分析出来。这么做的好处是,可以使用在前声明在后。比如声明一个Mammal对象,而Mammal类的定义是在后面才出现的;在定义一个类时,对于类的成员也会出现使用在声明之前的情况,把类型解析先扫描一遍,就能方便地支持这个特性。在写属性计算算法时,计算的顺序可能是最重要的问题。因为某属性的计算可能要依赖别的节点的属性先计算完。上面讨论的S属性、I属性和L属性,都是在考虑计算顺序。像使用在前声明在后这种情况,就更要特殊处理了。

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

(3)第 3 遍:引用的消解和S属性的类型推导。这个时候对所有的变量、函数调用,都会跟它的定义关联起来,并且完成了所有的类型计算。

(4)第 4 遍:做类型检查。比如当赋值语句左右两边的类型不兼容时,就可以报错。

(5)第 5 遍:做一些语义合法性的检查。比如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;  

    ...  
}

因此,语义分析的本质是对上下文相关情况的处理。了解引用消解、左值和右值的场景,可以增加对语义分析的直观理解。掌握属性计算和属性文法,可以使用更加形式化、更清晰的算法来完成语义分析的任务。语义分析这个阶段十分重要。因为词法和语法都有很固定的套路,甚至都可以工具化的实现。但语言设计的核心在于语义,特别是要让语义适合所解决的问题。各编程语言之间的本质区别也在于语义方面,词法差异最小,语法次之,语义差异最大。

九、继承和多态:面向对象运行期的动态特性

42. 继承和多态对类型系统提出的新概念,就是子类型。前面接触的类型往往是并列关系,一个是整型另一个是字符串型,都是平等的。而现在一个类型可以是另一个类型的子类型,比如一只羊又属于哺乳动物。这会导致在编译期无法准确计算出所有的类型,从而无法对方法和属性的调用做完全正确的消解(或者说绑定)。这部分工作要留到运行期去做,也因此面向对象编程会具备非常好的优势,因为它会导致多态性。

要想深刻理解面向对象的特征,就必须了解子类型的原理和运行期的机制。接下来从类型体系的角度理解继承和多态,然后看看在编译期需要做哪些语义分析,再看看继承和多态的运行期特征。继承的意思是一个类的子类,自动具备了父类的属性和方法,除非被父类声明为私有的。所以继承的强大之处,就在于重用

多态的意思是同一个类的不同子类,在调用同一个方法时会执行不同的动作。这是因为每个子类都可以重载掉父类的某个方法,提供一个不同的实现。下面这段代码演示了继承和多态的特性,a的speak()方法和bspeak() 方法会分别打印出牛叫和羊叫,调用的是子类的方法,而不是父类的方法:

/**
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();  //打印羊叫

所以多态的强大之处,在于虽然每个子类不同,但仍然可以按照统一的方式使用它们,做到求同存异。面向对象编程时,可以给某个类创建不同的子类,实现一些个性化的功能;写程序时可以站在抽象度更高的层次上,不去管具体的差异。如果把上面的结论抽象成一般意义上的类型理论,就是子类型(subtype)。它可以放宽对类型的检查,从而导致多态。可以粗略地把面向对象的继承看做是子类型化的一个体现,它的结果就是能用子类代替父类,从而导致多态。子类型有两种实现方式:

(1)一种就是像Java和C++需要显式声明继承了什么类,或者实现了什么接口。这种叫做名义子类型(Nominal Subtyping)。

(2)另一种是结构化子类型(Structural Subtyping),又叫鸭子类型(Duck Type)。也就是一个类不需要显式地说自己是什么类型,只要它实现了某个类型的所有方法,那就属于这个类型。

43. 了解了继承和多态之后,看看在编译期如何对继承和多态的特性做语义分析。首先从类型处理的角度出发,要识别出新的类型:Mammal、Cow和Sheep,之后就可以用它们声明变量了。第二要设置正确的作用域,从作用域的角度来看,一个类的属性(或者说成员变量),是可以规定能否被子类访问的。以Java为例,除了声明为private的属性以外,其他属性在子类中都是可见的。所以父类属性的作用域,可以说是以树状的形式覆盖到了各级子类:

第三要对变量和函数做类型的引用消解。也就是要分析出a和b这两个变量的类型。注意,前面代码里是用Mammal来声明这两个变量的。按照类型推导的算法,a和b都是Mammal,这是个I属性计算的过程。也就是说在编译期,无法知道变量被赋值的对象确切是哪个子类型,只知道声明变量时它们是哺乳动物类型,至于是牛还是羊,就不清楚了。

但是前面代码中a和b的确是以Cow和Sheep对象创建的呀,不能用类型推导吗?语言的确有自动类型推导的特性,但是有限制条件。比如,强类型机制要求变量的类型一旦确定,在运行过程就不能再改,所以要让a和b能够重新指向其他的对象,并保持类型不变。从这个角度出发,a和b的类型只能是父类Mammal。所以说,编译期无法知道变量的真实类型,可能只知道它的父类型。这会导致没法正确地将speak()方法做引用消解。正确的消解是要指向Cow和Sheep的speak方法,而这只能到运行期再解决这个问题。

所以接下来,就讨论一下如何在运行期实现方法的动态绑定。在运行期能知道a和b这两个变量具体指向的是哪个对象,对象里是保存了真实类型信息的。具体来说playscript中,ClassObject的type属性会指向一个正确的Class,这个类型信息是在创建对象的时候被正确赋值的,如下图所示:

在调用类的属性和方法时,可以根据运行时获得的、确定的类型信息进行动态绑定。下面这段代码是从本级开始,逐级查找某个方法的实现,如果本级和父类都有这个方法,那么本级的就会覆盖掉父类的,这样就实现了多态

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信息。

44. 现在已经了解如何在运行期获得类型信息,实现方法的动态绑定。接下来了解一下运行期对象的逐级初始化机制,即继承情况下对象的实例化。在存在继承关系的情况下,创建对象时不仅要初始化自己这一级的属性变量,还要把各级父类的属性变量也都初始化。比如在实例化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机制的理解。比如在下面的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用在了三个地方:

(1)this.weight是访问自己的成员变量,因为成员变量的作用域是这个类本身,以及子类。

(2)this()是调用自己的另一个构造方法,因为这是构造方法,肯定是做自身的初始化。换句话说,构造方法不存在多态问题

(3)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。

讨论完this,super就比较简单了,它的语义要比this简单不会出现歧义。super的调用也是分成三种情况:

(1)super.weight。这是调用父类或更高的祖先的weight属性,而不是Cow这一级的weight属性。不一定非是直接父类,也可以是祖父类中的。根据变量作用域的覆盖关系,只要是比Cow这一级高的就行。

(2)super(200)。这是调用父类的构造方法,必须是直接父类的

(3)super.speak()。跟访问属性的逻辑一样,是调用父类或更高的祖先的speak()方法。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值