> 文章列表 > 编译技术-错误处理

编译技术-错误处理

编译技术-错误处理

错误处理

一、总论

​ 错误处理和解析生成中间代码的过程是类似的,都可以看做是一个语法制导翻译的过程。对于遍历出的节点,触发不同的动作符号,这些动作符号在错误处理中用于检测错误,而在生成中间代码生成中,这些动作符号用于生成中间代码。

​ 错误处理会遍历语法生成树,CST 的每个节点都实现了 check 方法用于检查有没有错误。


二、错误的分类

​ 并不是所有的错误都是语义错误,常见的语义错误有“变量重命名,变量未定义“等,这些错误是可以通过词法分析和语法分析的,不会造成语法分析的错误。但是有些错误,是没有办法通过词法分析和语法分析的,有如下这些类

编号 类型 原因
a 非法字符串符号 无法通过词法分析
i 缺少分号 无法通过语法分析
j 缺少右小括号 无法通过语法分析
k 缺少右中括号 无法通过语法分析

​ 对于这样的错误,必须要做的就是当发生这些错误的时候,让词法分析和语法分析正常进行,必须坚持到进行错误处理的步骤。然后有两种思路,一种是在进行词法分析和语法分析的时候就抛出异常并收集,然后在进行错误处理的时候再抛出一些异常,将异常汇总,但是这种方法比较零散。我采用的是在词法分析和语法分析的时候“包容并记录”这种错误,然后在错误处理的时候一并发现。

​ 具体的实现思路就是,对于 a 类错误,并不在词法分析过程中检查双引号间有什么,而是直接存储在 token 中,当 TokenNode 进行 check 的时候,再对其中包含的字符串进行检查。同理,对于三类符号的缺失,可以考虑增强语法分析的功能,使其能够自动补全缺失的符号,然后产生相应的 TokenNode ,但是给这些补全的 TokenNode 打上缺失标记 miss。最终在 TokenNodecheck 中一并检出,即下面的逻辑:

/* 用于缺失的时候产生具体的 error* 包括三种缺失错误* 字符串错误* 名字错误本来就不行,这是因为名字错误包括未定义和重复定义两种,没有办法确定要使用哪种*/
@Override
public void check(SymbolTable symbolTable)
{// 检测缺失情况if (token.isSameType(SyntaxType.SEMICN)){if (miss){errors.add(new PansyException(ErrorType.MISS_SEMICOLON, token.getLine()));}}else if (token.isSameType(SyntaxType.RBRACK)){if (miss){errors.add(new PansyException(ErrorType.MISS_RBRACK, token.getLine()));}}else if (token.isSameType(SyntaxType.RPARENT)){if (miss){errors.add(new PansyException(ErrorType.MISS_RPARENT, token.getLine()));}}// 检测是否是合法字符串else if (token.isSameType(SyntaxType.STRCON)){String content = token.getContent();// 不算两个双引号for (int i = 1; i < content.length() - 1; ++i){int ascii = content.charAt(i);if (ascii == 32 || ascii == 33 || ascii >= 40 && ascii <= 126){if (ascii == 92 && content.charAt(i + 1) != 'n'){errors.add(new PansyException(ILLEGAL_SYMBOL, token.getLine()));return;}}else if (ascii == 37){if (content.charAt(i + 1) != 'd'){errors.add(new PansyException(ILLEGAL_SYMBOL, token.getLine()));return;}}else{errors.add(new PansyException(ILLEGAL_SYMBOL, token.getLine()));return;}}}
}

三、缺失符号处理

​ 正如上文所言,导致语法分析进行不下去的错误一定需要被“包容”,否则就坚持不到错误处理流程了。这种包容设置还有一种“补充”的意味在里面。但是最难的其实是缺失符号对于正常语法解析的影响,缺失符号会让原本就很困难的语法分析变得更加困难,这是因为语法分析本就需要依靠符号确定解析的分支,缺少符号会让这个过程变得模糊不清,这也是想 i, j, k 这类错误并不太多的原因,因为一旦多了,就会导致解析困难。

​ 在三种缺失中,以缺少右中括号最为好处理,中括号意味着维度信息,只要有左中括号,那么右中括号一定是可以补上的,基本上无脑就可以了。

​ 但是对于缺失分号和小括号,有可能造成解析分支的判断不当。所以需要进行回退处理(如果不回退的话就要改写文法,而且文法改的毫无语义意义)。

​ 对于缺失分号,有无法区分赋值语句和表达式的问题。

// 缺失前
i + 1;
a = b + c;// 缺失后
i + 1
a = b + c;

​ 这就意味着没法通过前瞻(即“在分号前有一个等于号“)来确定是否是赋值语句。所以只能通过“尝试解析-判断 LAST”的方法进行。

​ 对于 return 语句,可能也有这种现象,虽然应该被语义约束了

// 缺失前
return;
a;// 缺失后
return 
a;

​ 对于 Callee 语句,也是这样(应该也被约束了),这里应该是同时缺失了小括号和分号

// 缺失前
f();
a;// 缺失后
f(
a;

四、动作属性

​ 因为是语法制导翻译,所以肯定会涉及动作属性,包括继承属性和综合属性,按照理论来讲,可以将这两个东西分别打包成两个类,然后将继承属性当做动作的参数,将综合属性当做动作的返回值,这样是最正统的写法。但是考虑到我设计的时候没有想到,所以我将其全部处理成了全局变量,具体的方法是将其都作为语法树节点基类的保护静态属性,然后这样所有的语法树节点就都可以共享这些变量了。

​ 对于错误处理,一共有以下变量

/* 用来存储语义分析中产生的错误*/
protecttatic final ArrayList<PansyException> errors = Checker.errors;
/* 用来存储检测日志*/
protected static final ArrayList<String> checkLog = Checker.checkLog;
/* 当前函数信息*/
protected static FuncInfo curFuncInfo = null;
/* 是否在循环中*/
protected static int inLoop = 0;

​ 还有一个符号表,我当成参数了。

public void check(SymbolTable symbolTable)

五、错误检测的可重入性

​ 可能是由于设计的问题,我会对同一个错误进行多次检测,比如说在 AssignStmtNodeLValNode 中都检测了符号是否存在,只不过 AssignStmtNode 还检查了是否更改了常量的值(大概率是设计问题)。

​ 但是没有关系,这样依然是可以保证正确性的,只要我们在输出前需要利用一个 TreeSet 进行去重和排序,然后再输出。

​ 这个涉及一个很好玩的事情,就是对于任何一棵语法子树,从它的根节点调用 check 方法,无论调用多少次,其生成的结果都是一样的。


六、符号表

​ 错误处理同样需要符号表,构建一个栈式符号表即可。可以解决的问题有

编号 内容
b 名字重定义
c 未定义的名字

​ 我在实现符号表的时候,没有将函数符号表和变量符号表分开处理,是因为担心有变量和函数重名(其实就是本质就不应该分开)。符号表的表项称为 SymInfo ,有两个子类,分别是 VarInfoFuncInfoVarInfo 又分为“常量,变量,形参”三种类型。

​ 对于 VarInfo ,需要记录维度信息,因为错误处理对于维度信息的要求较低,所以利用 CSTNode 去记录维度信息

类型 dim1 dim2 CheckDataType
单变量 ZERO ZERO INT
一维数组 ConstExp ZERO DIM1
二维数组 ConstExp ConstExp DIM2
一维指针 INF ZERO DIM1
二维指针 INF ConstExp DIM2

​ 可以看到记录的信息相当的“粗糙”。

​ 实现这两个错误的检测都是很容易的,就不赘述了。


七、类型

​ 错误处理中涉及的类型问题只有函数的实参和形参不匹配的问题,也就是编号 e。类型不匹配只涉及一维和二维和单变量之间的不匹配,并不涉及具体的维度信息,也就是说:

void f(int a[][2])
{}int main()
{int b[1][5] = {{1, 3, 5, 7, 8, 9}};f(b)return 0;
}
这种是不会报错的。根据这种语义约束,涉及类型有这样 4 种即可:
public enum CheckDataType
{// 无返回值VOID,// 单变量INT,// 一维指针,比如说 int a[2] 中的 a,就是 DIM1, int a[] 也是 DIM1(本质是传入 a)DIM1,// 二维指针,比如说 int a[][2] 中的 a,就是 DIM2DIM2
}

​ 而其实涉及的运算,只有中括号造成的指针升维运算而已。也就是下面的过程

/* 进行指针运算,获得左值真正的类型信息* @param rawType 符号表示的类型信息* @return 实际类型信息*/
private CheckDataType calcDataType(CheckDataType rawType)
{int calDim = exps.size();int rawDim;if (rawType.equals(CheckDataType.INT)){rawDim = 0;}else if (rawType.equals(CheckDataType.DIM1)){rawDim = 1;}else if (rawType.equals(CheckDataType.DIM2)){rawDim = 2;}else{rawDim = -1;}int trueDim = rawDim - calDim;if (trueDim == 0){return CheckDataType.INT;}else if (trueDim == 1){return CheckDataType.DIM1;}else if (trueDim == 2){return CheckDataType.DIM2;}else{return CheckDataType.VOID;}
}

​ 比如说原本的 a[1][2] 这个数组,在符号表存着的时候,符号表指示他是一个 DIM2 类型,当我们真的使用他的时候,一般是使用 a[0][0] ,那么就是进行了两次升维运算,变成了 INT ,如果将 a 用于传参,那么它就是一个 DIM2 型,而 a[0] 则是 DIM1 型。

​ 对于一个表达式,比如说 1 + 3 + 2 ,他是 INT 型的,究其原因,是 1, 2, 3 都是 INT 型,这就启发我们,计算一个 Exp 的类型,其实要根据它的孩子节点的类型判断,只需要一步步递归,就可以最终溯源到 UnaryExp,然后就会发现最底层只有 3 个东西 Number, LVal, Callee。这三种的类型如下所示

底层节点 类型
Number INT
LVal 查表运算后获得
callee 查表查返回值

​ 对于结合,我采用了一个更加让人自我满足的方式,就是选择所有孩子的类型中最大的作为当前节点的类型,所谓“最大”,就是 DIM2 > DIM1 > INT > VOID 。这是因为这更符合 C 语言特性,比如说 a + 1 ,如果 a 是一个指针,那么 a + 1 也是一个指针,但是指针和指针运算、INTVOID 运算,就没有那么多道理可讲了,所以只能说是自我安慰。这么做不会导致错误,是因为我们也不允许不同类型的元素做算术。


八、Return

​ 与 return 有关的错误有两类,这两类看似相似,但是处理方式却不同。如下所示

编号 类型
f 无返回值的函数存在不匹配的 return 语句
g 有返回值的函数缺少 return 语句,只需要考虑函数最后一句即可

​ 通过记录当前正在处理哪个函数 curFuncInfo,是很容易在进行 ReturnNode 的时候完成对于 f 类错误的检验的,但是对于 g 类错误,却只能在 FuncDefNode 中处理,因为不能在 ReturnNode 中处理,ReturnNode 并不知道自己是函数的最后一条指令,所以没有办法在这里处理。


九、循环

​ 只需要用一个 int 来对当前的循环深度进行计数,如果循环深度为 0 的时候发生了 breakcontinue 就会报错。即如下操作

@Override
public void check(SymbolTable symbolTable)
{addCheckLog();inLoop++;for (CSTNode child : children){child.check(symbolTable);}inLoop--;
}