跳转至

Week 8 - Semantic Part 2 - Type Checking

进行语义分析

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

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

我们先完成最基本的变量定义与变量查询。相关语法如下:

compSt : '{' defList '}';

defList: def
        | defList def;

def : specifier decList SEMI;

decList : dec
        | dec COMMA decList;

dec : varDec
    | varDec ASSIGN exp;

specifier : TYPE; 

varDec : ID
       | varDec LB INT RB;

TYPE : 'int' | 'float' | 'char';

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

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

例如,下面的语法定义了一个新的作用域:

//一个例子:{int x; int b; int c[2];}
compSt : '{' defList '}';

因此在 enter 方法中创建新作用域,在 exit 方法中恢复原有作用域:

@Override
public void enterCompSt(SplParser.CompStContext ctx) {
    Scope scope = new Scope(this.currentScope);
    this.currentScope = scope;
}


@Override
public void exitCompSt(SplParser.CompStContext ctx) {
    this.currentScope = this.currentScope.getEnclosingScope();
}

有了作用域的概念后,接下来需要在变量定义语法中向符号表插入新符号,包括变量类型和名称。语法规则:

def : specifier decList SEMI;

包含了所有必要信息,因此我们可以从它入手,通过解析子规则完成工作。首先获取变量类型:

Type type = new PrimitiveType(ctx.specifier().TYPE().getText());

我们来仔细看一看这一句:

ctx.specifier().TYPE().getText()
  • ctx 是函数参数,包含当前规则的全部上下文信息

  • ctx.specifier() 获取子规则 specifier 的上下文

  • TYPE() 获取词法符号

  • getText() 获取符号对应的文本内容(可能为 intfloatchar

这样就可以构建变量类型。

接着获取变量名称。语法规则如下:

varDec : ID
       | varDec LB INT RB;

这里有两个分支,可能定义数组。比如 x[1][2] 对应的语法树如下:

我们如果想要知道这个变量的名称,必须解析到最下面一层的 varDec 规则。那么,怎么判断什么时候才真正到达最底层呢?这里有一个很好的办法:如果不是最后一层的 varDec,它的 ID 词法符号是不存在的。

⚠️ 需要说明一点:对于一个有备选分支 (Alternative) 的语法规则,它的所有备选分支中的词法符号和子规则都会成为其上下文参数的方法,但在实际解析时,只有走到的分支对应的方法和词法符号才会有内容,其它没走到的分支对应的方法和词法符号都为 null

因此,我们可以构建一个辅助函数来获取变量名称:

private String getVarName(MyGrammarParser.VarDecContext ctx) {
    if (ctx.ID() != null) {
        return ctx.ID().getText();
    } else {
        // varDec LB INT RB
        return getVarName(ctx.varDec());
    }
}

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

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

    private Type getVarType(Type baseType, SplParser.VarDecContext ctx) {
        // 如果是基本类型直接返回 
        if (ctx.ID() != null) {
            return baseType;
        }
        // 1. 收集维度(外→内)
        List<Integer> dims = new ArrayList<>();
        SplParser.VarDecContext p = ctx;
        while (p.ID() == null) {
            // 注意这里会获取维度的顺序是由外到内的
            dims.add(Integer.parseInt(p.INT().getText()));
            p = p.varDec();
        }

        // 2. 从外到内依次包裹
        // 注意这里t每次循环会变成什么样子
        // 初始时t是baseType (int)
        // 第一次循环后t是array(int, 2)
        // 第二次循环后t是array(array(int, 2), 1)
        Type t = baseType;
        for (int d : dims) {
            t = new ArrayType(t, d);
        }

        // 返回完整类型
        return t;
    }

该函数的逻辑如下:

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

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

  • 外层是一个长度为 1 的数组,其元素类型是“长度为 2 的 int 数组”;
  • 内层是一个长度为 2 的数组,元素类型为 int

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

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

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

特别提醒 :在语法树中,ID 与数组维度的结合顺序与实际声明顺序是相反的 。例如,int x[1][2] 在语法树中 ID 是先绑定维度 1,再绑定维度 2。因此,在使用递归等方法解析数组定义时,务必注意这种顺序差异,避免将数组维度颠倒,导致类型构建错误。


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

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

Type baseType = new PrimitiveType(ctx.specifier().TYPE().getText());
SplParser.DecListContext decCtxList = ctx.decList();
while (decCtxList != null) {
    String name = getVarName(decCtxList.dec().varDec());
    Type type = getVarType(baseType, decCtxList.dec().varDec());
    VariableSymbol variableSymbol = new VariableSymbol(name, type, this.currentScope);
    VariableSymbol result = this.currentScope.defineVariableSymbol(variableSymbol);
    // 如果该变量名已经被定义define函数会返回null
    if (result == null) {
        System.out.println("不能重复定义参数:" + name);
    }
    decCtxList = decCtxList.decList();
}

好了,现在我们已经通过一个例子完成了变量的定义,并在定义时检查了是否重复定义。相信你已经明白如何通过符号表和 Listener 来进行符号检查。接下来的工作可以自己尝试完成。如果你希望在打印错误信息时加入更多细节,例如行号,可以通过调用 ctx.getStart().getLine() 来获取。

练习

我们正在实现一个简单语言的语义分析器。该语言要求:在同一个作用域内,不能重复定义同名变量(即禁止变量遮蔽)。

你已经有一个 Scope 类来管理作用域和符号。现在,你需要修改 define 方法,使其在定义变量时自动检查是否已在当前作用域中存在同名变量。如果存在,则抛出异常,阻止定义。

这个练习将让你实践一个典型的语义检查任务:重复定义检查。

语言设定

语言支持 int 和 String 两种类型。 作用域是嵌套的,但不允许在子作用域中定义与父作用域同名的变量(这是语义规则)。 我们只检查当前作用域内的重复定义(这是最基础的检查)。

给定代码

// 表示变量类型
interface Type {}
class IntType implements Type { public String toString() { return "int"; } }
class StringType implements Type { public String toString() { return "string"; } }

// 表示一个变量符号
class VariableSymbol {
String name;
Type type;

public VariableSymbol(String name, Type type) {
this.name = name;
this.type = type;
}

@Override
public String toString() {
return name + ": " + type;
}
}

// 表示一个作用域
class Scope {
java.util.Map<String, VariableSymbol> symbols = new java.util.HashMap<>();
Scope parent; // 父作用域

public Scope(Scope parent) {
this.parent = parent;
}

// TODO: 在当前作用域中定义一个变量
// 要求:
// 1. 如果当前作用域已存在同名变量,抛出 IllegalArgumentException
// 2. 否则,将变量加入当前作用域的符号表
public void define(VariableSymbol var) {
// 请在此处补全代码,实现重复定义检查
}

// 提供一个简单的查找方法(无需修改)
public VariableSymbol lookup(String name) {
VariableSymbol sym = symbols.get(name);
if (sym != null) return sym;
if (parent != null) return parent.lookup(name);
return null;
}
}

测试代码

public class TestSemanticCheck {
public static void main(String[] args) {
Scope global = new Scope(null);

// 正常定义
global.define(new VariableSymbol("x", new IntType()));
System.out.println("Defined: x: int");

global.define(new VariableSymbol("y", new StringType()));
System.out.println("Defined: y: string");

// 尝试重复定义 x
try {
global.define(new VariableSymbol("x", new StringType()));
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}

// 创建子作用域
Scope local = new Scope(global);

// 子作用域可以定义 x(因为是不同作用域)
local.define(new VariableSymbol("x", new IntType()));
System.out.println("Defined in local: x: int");

// 在子作用域内不能重复定义 x
try {
local.define(new VariableSymbol("x", new StringType()));
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
}

预期输出

Defined: x: int
Defined: y: string
Error: Cannot redefine variable 'x' in the same scope
Defined in local: x: int
Error: Cannot redefine variable 'x' in the same scope