跳转至

Week 8 - Semantic Part 2 - Type Checking

语义分析-解析ArrayType

到目前为止,我们已经掌握了进行语义动作所需的全部前置知识。现在可以开始实际操作。在这里,我们仅做简单示例,手把手带你完成几个基本的语义分析任务,并演示如何使用符号表,剩余部分需要你自行练习。

这里我们使用 Listener 模式进行语义分析。当然,你也可以使用 Visitor 来完成相同的工作(例如你已有的 Lab6Visitor ),我们鼓励你进行尝试。

本文所有规则名、token 名均以当前 Splc.g4 为准。

本次实验的源代码下载地址:

https://github.com/zhuym1219/CS323-Compilers-2026F-Labs/tree/lab8

如果你之前已经下载过Lab6,可以执行下面的命令更新:

git fetch
git checkout lab8

先看与本节相关的文法(已支持数组):

globalDef
    : specifier Identifier LPAREN funcArgs RPAREN LBRACE statement* RBRACE   // 函数定义
    | specifier varDec SEMI                                               // 全局变量/数组
    ;

varDec
    : Identifier
    | varDec LBRACK NUMBER RBRACK
    ;

specifier
    : INT | CHAR | VOID
    ;

statement
    : LBRACE statement* RBRACE                        # CombineStmt   // 复合语句(新作用域)
    | specifier varDec (ASSIGN expression)? SEMI      # LocalVariableStmt  // 局部变量/数组
    | RETURN expression SEMI                          # ReturnStmt
    | expression SEMI                                 # ExpressionStmt
    ;

注意:SPLC 一条变量声明语句 只声明一个变量 。

我们先完成最基本的变量定义与变量查询。

由于我们使用 Listener 来进行语义检查,首先创建一个新类 SyntaxListener,继承自 SplcBaseListener。然后给这个类定义两个成员变量:

Scope globalScope = new Scope(null);
Scope currentScope = globalScope;
  • globalScope 表示根作用域;
  • currentScope 表示当前所在的作用域,在遍历语法树时会动态改变。

1. 进入/退出作用域

# CombineStmt 复合语句(一对花括号)定义了一个新作用域,例如:

{ 
        int x; 
        int b; 
        int c[2]; 
}

对应文法:

statement : LBRACE statement* RBRACE # CombineStmt ;

Listener设计方式: 因为该备选带了标签,Listener 会生成 enterCombineStmt / exitCombineStmt。在 enter 中创建新作用域,在 exit 中恢复外层作用域:

@Override
    public void enterCombineStmt(SplcParser.CombineStmtContext ctx) {
        this.currentScope = new Scope(this.currentScope);
    }

    @Override
    public void exitCombineStmt(SplcParser.CombineStmtContext ctx) {
        this.currentScope = this.currentScope.getParent();
    }

Visitor设计方式: Visitor 针对大括号内部所有的statement并不是自动调用,而是手动调用方式,因此要这样设计:

@Override
    public Void visitCombineStmt(SplcParser.CombineStmtContext ctx) {
        this.currentScope = new Scope(currentScope);
        for (int i = 0; i < ctx.statement().size(); i++) {
            visit(ctx.statement(i));
        }
        this.currentScope = this.currentScope.getParent();
        return null;
    }

2. 获取变量基本类型

SPLC 的类型不是一个 TYPE token,而是三个独立的关键字备选:

specifier : INT | CHAR | VOID ;

对于带备选分支的规则,只有走到的分支对应的 token 方法才有内容,其余为 null。因此用一个辅助方法把它映射成 PrimitiveType:

这里,对于数组元素的 基本数据类型 ,依旧是 INT | CHAR 这两种类型之一。后面第4部分,会进一步介绍数组元素的类型该如何获取。

private Type resolveType(SplcParser.SpecifierContext ctx) {
    if (ctx.INT() != null) {
        return PrimitiveType.INT;
    } else if (ctx.CHAR() != null) {
        return PrimitiveType.CHAR;
    } else {
        return PrimitiveType.VOID;
    }
}

3. 获取变量名称

数组声明符文法为:

varDec
    : Identifier
    | varDec LBRACK NUMBER RBRACK
    ;

它有两个分支,可能定义数组。比如 x[1][2][3] 对应的语法树最外层是 varDec [3],其次是varDec [2] , 最内层才是 Identifier [1]。

要拿到变量名,必须递归到最底层的 varDec。判断方法:如果当前层存在 Identifier,说明已经到达最底层;否则继续向下取 varDec()。

private String getVarName(SplcParser.VarDecContext ctx) {
    if (ctx.Identifier() != null) {
        return ctx.Identifier().getText();
    } else {
        // varDec LBRACK NUMBER RBRACK
        return getVarName(ctx.varDec());
    }
}

这里的逻辑就是:如果当前层没有解析到 Identifier,就一直向下递归解析,直到找到存在的 Identifier 为止。

4. 获取变量类型(数组层层包裹)

首先,ArrayType 内部定义需要有两个属性:

private final Type elementType;
private final int length;

到这里聪明的你肯定也看出来了,我们之前只解析 specifier 来获取变量类型的操作是不准确的,因为很有可能这个变量是一个数组类型。所以我们要一个对应的逻辑去解析真正的变量类型。

// varDec: int a[1][2][3] -> type: int[3][2][1]
private Type getVarType(Type baseType, SplcParser.VarDecContext ctx) {
    // If it is not an array type:return the baseType.
    if (ctx.Identifier() != null) {
        return baseType;
    }

    // 1. Collect dimensions downward from the root node of the syntax tree: 
    List<Integer> dims = new ArrayList<>();
    SplcParser.VarDecContext p = ctx;
  // The elements in dims are [3, 2, 1]
    while (p.Identifier() == null) {
        dims.add(Integer.parseInt(p.NUMBER().getText())); 
        p = p.varDec();
    }

    // 2. Wrap sequentially
    // Origninal: t = int
    // After first loop:  t = array(int, 3)
    // After second loop: t = array(array(int, 3), 2)
    // After third loop:  t = array(array(array(int, 3), 2), 1) (toString int[3][2][1])
    Type t = baseType;
    for (int d : dims) {
        t = new ArrayType(t, d);
    }
    return t;
}

该函数的逻辑如下:

首先,判断当前类型是否为基本参数类型。如果是,则直接返回该基本类型;否则,说明这是一个数组类型,我们需要收集其维度信息。

注意:维度信息的收集顺序是从外到内的 。例如,在 C 语言中,声明 int x[1][2][3] 实际表示的类型是 array(array(array(int, 3), 2), 1),其结构为:

  • 外层:长度为 1 的数组,元素类型是 int[3][2] ;

  • 次内层:长度为 2 的数组,元素类型是 int[3] ;

  • 最内层:长度为 3 的数组,元素类型是 int 。

后续我们需要根据收集到的维度信息逐层构建最终的数组类型。这个构建过程本质上是一个 层层包裹 的过程:

  1. 从最内层开始 :最内层的数组元素类型就是传入的 basetype,而它的长度对应的是 最外层的维度值 (即 dims 数组中的第一个元素)。将这两者结合,得到当前层的数组类型,并更新变量 t。
  2. 接着构建外层 :下一层的数组,其元素类型就是上一步构建出的 内层数组 ,而长度则取自 dims 数组中的下一个值(即从右往左数的第二个维度,例如 2)。

对于更高维的数组,解析原理完全相同:每一层的元素类型是上一层构建的结果,而长度则依次取自 dims 数组中的后续元素—— 这个顺序与数组声明中从右到左的维度顺序一致 。

特别提醒 :在语法树中,ID 与数组维度的结合顺序与实际声明顺序是相反的 。

  • 从 Identifier 沿父链向上看,维度依次是 1、2、3,与书写顺序一致;

  • 而 getVarType 从根向叶收集,得到的是 3、2、1,与书写顺序相反。

5. 登记变量并检查重复定义

现在,我们已经有了获取类型和参数名称的函数。接下来要做的就是解析每个变量,并把解析完成的变量放进符号表中。

需要注意的是,我们的语法支持一次性定义多个变量,所以必须对每个定义的变量都执行相同的操作。

5.1 全局变量

全局变量/数组定义位于 globalDef 中、且没有花括号(函数定义才有 LBRACE 与 RBEACE):

        @Override
    public void enterGlobalDef(SplcParser.GlobalDefContext ctx) {
        if (ctx.LBRACE() != null && ctx.RBRACE() != null) {
            visitFuncDef(ctx);
        } else {
            visitVariableDec(ctx);
        }
    }
    //Variable Declare:
    private void visitVariableDec(SplcParser.GlobalDefContext ctx) {
        Type baseType = resolveType(ctx.specifier());
        SplcParser.VarDecContext varDec = ctx.varDec();
        TerminalNode identifierNode = getVarIdentifier(varDec);
        String name = identifierNode.getText();
        Type type = getVarType(baseType, varDec);
        if (this.globalScope.lookupCurrentVariable(name) != null) {
            errors.add(SplcSemanticError.redefinition(identifierNode));
        }else{
            this.globalScope.defineVariable(name, type);
        }
    }

      private void visitFuncDef(SplcParser.GlobalDefContext ctx) {
        Type returnType = resolveType(ctx.specifier());
        String name = ctx.Identifier().getText();
        if (globalScope.lookupFunction(name) == null) {
            globalScope.defineFunction(name, returnType, new ArrayList<>());
            currentScope = globalScope.lookupFunction(name);//renew current scope
        } else {
            errors.add(SplcSemanticError.redeclaration(ctx.Identifier()));
            return;
        }
        //todo: to do check function parameters:

    }

5.2 局部变量

对应g4文件:

globalDef
    : specifier Identifier LPAREN funcArgs RPAREN LBRACE statement* RBRACE      // 函数定义
    | specifier varDec SEMI                                                  // 全局变量定义
    ;
statement
    : LBRACE statement* RBRACE                                      # CombineStmt
    | specifier varDec (ASSIGN expression)? SEMI                    # LocalVariableStmt
    | RETURN expression SEMI                                        # ReturnStmt
    | expression SEMI                                               # ExpressionStmt
    ;

SPLC 的局部变量声明在带标签的 # LocalVariableStmt 中,且一条语句只有一个声明符):

    @Override
    public void enterLocalVariableStmt(SplcParser.LocalVariableStmtContext ctx) {
        Type baseType = resolveType(ctx.specifier());
        SplcParser.VarDecContext varDec = ctx.varDec();

        TerminalNode varIdentifier = getVarIdentifier(varDec);
        String name = varIdentifier.getText();
        Type type = getVarType(baseType, varDec);
        // defineVariable(name, type),if redeclaration of name, return null
        VariableSymbol result = this.currentScope.defineVariable(name, type);

        if (result == null) {
            errors.add(SplcSemanticError.redeclaration(varIdentifier));
            return;
        }
        // todo: Check type compatible of left and right.
        // int a = 'c';  // ok
        // int a[1][2] = 1; // error
    }

Listener 与Visitor 访问局部变量的区别

在Listener的框架中,enterLocalVariableStmt 这类方法是自动调用的,但是在 Visitor 的框架中,他们并非自动调用,需要我们手动调用。 因此,在下面的方法结束前,

private void visitFuncDef(SplcParser.GlobalDefContext ctx){}

需要手动加上这个for循环

for (int i = 0; i < ctx.statement().size(); i++) {
            visit(ctx.statement(i));
}

小结

通过上面的例子,我们完成了:

  • 进入/退出作用域(CombineStmt,函数体则在 globalDef 中处理);
  • 用 resolveType 把 INT/CHAR/VOID 关键字映射成基本类型;
  • 用 getVarName 递归取到数组变量名;
  • 用 getVarType 按语法树维度层层包裹出 ArrayType;
  • 调用 Scope.defineVariable 登记变量,并在返回 null 时报告重复定义。

好了,现在我们已经通过一个例子完成了变量的定义,并在定义时检查了是否重复定义。相信你已经明白如何通过符号表和 Listener 来进行符号检查。接下来的工作可以自己尝试完成。

练习

在前一阶段符号表的基础上,以及基于本次实验上述介绍的内容,继续完成下列语义分析功能:

解读数组类型:能够解析 varDec 中的多维数组声明,构造正确的 ArrayType,并按规定格式输出。

**解读花括号语句 # CombineStmt ** :每一对 { ... } 构成一个新作用域,支持变量遮蔽(shadowing)。

类型匹配检查 :数组类型不能被整体赋值(不能用一个标量值直接赋给一个未带下标的数组变量)。

错误的输出格式与错误种类沿用上一章练习。

Part A:

由于本次Lab的介绍基于Listener,请先实现 SyntaxListener.java

Part B:

在实现 SyntaxListener.java 的基础上,完成 SyntaxVisitor.java

测试用例:

测试1: 正确定义数组

int a;
int b;
int c[2];
int d[1][2][3];

输出:

Variables:
a: int
b: int
c: int[2]
d: int[3][2][1]

Functions:

测试2: 变量名重复

int a;
char a;
int c[2];
char c[1][2][3];

输出:

2:5: error: Redefinition of 'a'
4:5: error: Redefinition of 'c'

测试3: {}语句块的正确代码

int x;
int main(){
    int a = 5;
    int b = 6;
    {
        int x = 7;
        a = 5;
        {
            b = 2;
            x = 8;
        }
        x = 1;
    }
    x = 10;
}

输出:

Variables:
x: int

Functions:
main: int()

测试4: 数组类型赋值不匹配错误

int x;
int main(){
    int a = 5;
    int b = 6;
    int c[3][2][1];
    char d[1][2];
    {
        int x = 7;
        a = 5;
        {
            b = 2;
            x = 8;
        }
        x = 'c';
        c = 1;
        d = 'h';
        int z = 'd';
        char k = 1;
    }
    x = 10;
}

输出:

15:8: error: InCompatible type of c. expect: 'int[1][2][3]' but actual: 'int'
16:8: error: InCompatible type of d. expect: 'char[2][1]' but actual: 'char'