编译器实现之旅——第八章 实现语义分析器
作为编译器后端的第一站,我们首先来实现语义分析器。
1. 语义分析器概观
正如上一章所说,语义分析器主要用于对抽象语法树进行语义层面的进一步检查,并生成符号表。我们也为符号表给出了一个"记录任何你想额外记录下的东西的表"这样的说了等于没说的定义。那么,CMM编译器的语义分析器到底需要做什么?其符号表又需要保存什么呢?
事实上,出于简单考虑,CMM编译器的语义分析器并不做任何的语义检查,只负责生成符号表。为了明确符号表到底需要记录什么,我们需要回顾CMM语言的这几点功能:
- 支持赋值(这也包括了支持变量)
- 支持函数
- 支持数组
- 区分全局作用域与局部作用域
也就是说,我们需要通过某种数据结构,将变量名、函数名、数组大小、作用域信息等内容全都放进去。似乎很复杂?请接着往下看。
2. 符号表的数据结构
不难发现,变量名和函数名,其实都是抽象语法树中的某个记号字符串;而数组大小其实也是抽象语法树中的某个记号字符串,只不过我们这里需要将其转为整型;那么,作用域信息呢?很简单,我们只需要回答这样一个问题:谁产生了作用域?答案当然是函数。也就是说,对于每个函数,其都有一个作用域,此外,还有一个全局作用域。我们还能发现:所有的变量名都位于某个作用域中;而数组大小是数组变量的一个"附加属性"。
将上述分析进行整理,我们就得到了以下要点:
- 我们需要记录一个全局作用域,和若干函数作用域。且函数作用域由各个函数产生
- 在每个作用域中,我们需要分别记录若干变量名
- 每个变量可能具有数组大小这一"附加属性"
有了这几点思考后,让我们继续思考:
- 作用域该怎么记录?用函数名即可。而全局作用域名只需要使用一个"非法函数名"即可,我们不妨使用"__GLOBAL__"
- 变量名该怎么记录?同理,用变量名即可;此外,我们还需要为每个变量名从0开始按顺序编号。至于编号的作用,我们将在代码生成器的相关章节中找到答案
- 数组大小该怎么记录?显然,用一个整型即可
- 作用域是最大的符号表层次,每个作用域中都包含着若干变量名,而每个变量名都具有变量的编号和数组大小这两个属性;如果这个变量不是数组,那么数组大小设为0即可
一个数据结构在我们的脑海中渐渐浮现:哈希表。
3. 语义分析器的实现
没错,我们可以用哈希表来同时存放变量名、函数名、数组大小、作用域信息等内容。这样的哈希表是双层的:第一层是作用域,我们可以通过函数名或"__GLOBAL__"作为键进行访问;第二层是变量名,显然,我们可以通过某个变量名作为键进行访问;而哈希值是一对整型,第一个整型代表了变量的编号,而第二个整型代表了数组大小。
语义分析器的实现如下所示:
class __SemanticAnalyzer
{
// Friend
friend class Core;
public:
// Constructor
__SemanticAnalyzer(__AST *root = nullptr);
private:
// Attribute
__AST *__root;
// Semantic Analysis
unordered_map<string, unordered_map<string, pair<int, int>>> __semanticAnalysis() const;
};
__SemanticAnalyzer::__SemanticAnalyzer(__AST *root):
__root(root) {}
unordered_map<string, unordered_map<string, pair<int, int>>> __SemanticAnalyzer::__semanticAnalysis() const
{
/*
symbolTable: Function Name => Variable Name => (Variable Number, Array Size)
*/
unordered_map<string, unordered_map<string, pair<int, int>>> symbolTable
{
{"__GLOBAL__", {}}
};
int globalIdx = 0;
/*
__TokenType::__Program
|
|---- __Decl
|
|---- [__Decl]
.
.
.
*/
for (auto declNodePtr: __root->__subList)
{
/*
__VarDecl | __FuncDecl
*/
if (declNodePtr->__tokenType == __TokenType::__FuncDecl)
{
/*
__TokenType::__FuncDecl
|
|---- __Type
|
|---- __TokenType::__Id
|
|---- __ParamList | nullptr
|
|---- __LocalDecl
|
|---- __StmtList
*/
int varIdx = 0;
string funcName = declNodePtr->__subList[1]->__tokenStr;
symbolTable[funcName];
if (declNodePtr->__subList[2])
{
/*
__TokenType::__ParamList
|
|---- __Param
|
|---- [__Param]
.
.
.
*/
for (auto paramPtr: declNodePtr->__subList[2]->__subList)
{
/*
__TokenType::__Param
|
|---- __Type
|
|---- __TokenType::__Id
*/
string varName = paramPtr->__subList[1]->__tokenStr;
symbolTable[funcName][varName] = {varIdx++, 0};
}
}
/*
__TokenType::__LocalDecl
|
|---- [__VarDecl]
.
.
.
*/
for (auto varDeclPtr: declNodePtr->__subList[3]->__subList)
{
/*
__TokenType::__VarDecl
|
|---- __Type
|
|---- __TokenType::__Id
|
|---- [__TokenType::__Number]
*/
string varName = varDeclPtr->__subList[1]->__tokenStr;
int varSize = varDeclPtr->__subList.size() == 2 ? 0 : stoi(varDeclPtr->__subList[2]->__tokenStr);
symbolTable[funcName][varName] = {varIdx, varSize};
varIdx += varSize + 1;
}
}
else
{
/*
__TokenType::__VarDecl
|
|---- __Type
|
|---- __TokenType::__Id
|
|---- [__TokenType::__Number]
*/
string varName = declNodePtr->__subList[1]->__tokenStr;
int varSize = declNodePtr->__subList.size() == 2 ? 0 : stoi(declNodePtr->__subList[2]->__tokenStr);
symbolTable["__GLOBAL__"][varName] = {globalIdx, varSize};
globalIdx += varSize + 1;
}
}
return symbolTable;
}
语义分析器以抽象语法树的树根作为输入,并在整个抽象语法树中获知我们所需要的信息。在进入抽象语法树之前,我们首先创建了一个空的符号表,由于全局作用域一定存在,故我们立即为符号表添加上代表着全局作用域的键;我们还定义了一个整型globalIdx,以跟踪各个全局变量的编号。
现在的问题是:这些变量名、函数名、数组大小、作用域信息等内容到底在抽象语法树的哪儿?为了回答这个问题,我们就必须回顾CMM的相关语法规则及其所对应的语法树结构了。请看:
- 语法树的树根具有一系列的子节点,这些子节点通过Decl语法构造而来;而Decl又可以推导出"VarDecl | FuncDecl",也就是说,整个语法树上按顺序排列着变量的声明,或函数的声明
- 对于变量的声明,即VarDecl语法,首先,很显然:这些变量就是全局变量;而继续查看VarDecl语法的语法树结构我们发现:变量名应该出现在VarDecl节点的第二子节点上;而数组长度,如果有的话,应该出现在VarDecl节点的第三子节点上
- 对于函数的声明,即FuncDecl语法,我们可以发现:函数名应该出现在FuncDecl节点的第二子节点上;而变量名应该出现在两处:第一处是FuncDecl节点的第三子节点,即ParamList节点的各个子节点上(仅当FuncDecl节点的第三子节点不是void),这些子节点代表了函数的形参;而第二处则位于FuncDecl节点的第四子节点,即CompoundStmt节点的第一子节点,又即LocalDecl节点的各个子节点上,这些子节点代表了函数的局部变量。所有这些子节点的变量名,都位于各个子节点的第二子节点上;而数组长度,对于形参子节点而言没有,对于局部变量子节点而言,则位于各个子节点的第三子节点上(这段说明真的是太绕了)
我们还需要关注的一点是:变量该如何编号?通常情况下,变量从0开始编号,每次加1就行了。但由于CMM中的变量可能是一个数组,故在语义分析器的实现中,我们不仅在每次遇到一个新的变量名后将变量的编号加1,我们还在每次遇到数组时将编号额外的加上了数组的长度。这样做的意义是什么?我们将在代码生成器的相关章节看到答案。
最后,还有一个需要留意的地方,那就是:在FuncDecl节点的解析中,两处变量名的解析顺序是不能颠倒的。同样,这样做的意义我们也将在代码生成器的相关章节看到答案。
至此,语义分析器就已经实现完成了。通过语义分析器,我们得到了对于代码生成器而言非常重要的一个"信息中心"——符号表。接下来,就让我们来看看低级指令的执行器——虚拟机是怎么实现的吧。请看下一章:《实现虚拟机》。