编译器中的Parser,语法分析器,以及抽象语法树AST

之前我们学习了词法分析器,接下来,依据词法分析器的输出,我们将进一步对代码进行处理。这里我们用到的是语法分析器,Parser。Parser的输出是一个抽象语法树(Abstract Syntax Tree,AST)。

Parser的构建采用的是两种方法的结合,Recursive Descent Parsing(递归下降解析)和Operator-Precedence Parsing(运算符优先解析)。后者仅用于二元表达式(Binary Expression),而其他情况使用前者。

递归下降解析是指一种自上而下的解析器,由一组相互递归的程序(或等价的非递归程序)构建而成,其中每个程序都实现了文法中的一个非终结符。因此,这些程序的结构密切反映了它所识别的文法结构。–wiki百科

运算符优先解析是一个自下而上的解析过程,它解释运算符先验的语法。很多计算器使用的是运算符有限解析。–wiki百科

由于parser的输出是AST,我们首先介绍一下AST。

抽象语法树,AST

一段程序的AST捕捉了这段程序的行为,从而可以为后续的编译过程(例如代码生成,code generation)提供便利。AST应该尽量紧密的模拟语言,对于语言中的每一个结构,我们都在AST中有对应的对象。在K语言中,我们有表达式expression,原型prototype和函数对象function object。

简单起见,我们将所有类的成员都设置为了public。先从表达式父类和它的子类开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 所有表达式节点的抽象类
class ExprAST
{
public:
virtual ~ExprAST() = default; // 析构函数
};

// 数字表达式
class NumberExprAST : public ExprAST
{
public:
double val; // 保存数字的值

// 构造方法,参数为数字的值,赋值给成员变量val
explicit NumberExprAST(double val) : val(val)
{}
};

// 变量表达式
class VariableExprAST : public ExprAST
{
public:
std::string name; // 保存变量的名字

// 构造方法,参数为变量的名字
explicit VariableExprAST(std::string name) : name(std::move(name))
{}
};

// 二元表达式
class BinaryExprAST : public ExprAST
{
public:
char op; // 保存操作符
// 左右操作数,unique_ptr是一种智能指针,指向的堆内存无法同其它unique_ptr共享
// 也就是说,每个unique_ptr指针都独自拥有对其所指堆内存空间的所有权
std::unique_ptr<ExprAST> lhs, rhs;

BinaryExprAST(char op,
std::unique_ptr<ExprAST> lhs,
std::unique_ptr<ExprAST> rhs)
: op(op), lhs(std::move(lhs)), rhs(std::move(rhs))
{}
};

// 方法调用表达式
class CallExprAST : public ExprAST
{
public:
std::string callee; // 调用者
std::vector<std::unique_ptr<ExprAST>> args; // 所有参数

CallExprAST(std::string callee, std::vector<std::unique_ptr<ExprAST>> args)
: callee(std::move(callee)), args(std::move(args))
{}
};

// 注释
class CommentAST: public ExprAST
{
public:
std::string content;

explicit CommentAST(std::string content): content(std::move(content)) {}
};

上面这些都十分简单,就是表达式类型对应的类,类中有相应的成员变量和构造函数。

除了条件控制表达式外,我们所有的表达式都进行了定义。缺少了条件控制表达式,这个语言到现在还不是图灵完备的。之后我们会将其加上,接下来我们将介绍一个函数的接口和函数自己。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 方法的接口,Prototype,可以捕捉方法的名称,和所有参数名称,从而也可以得知参数的数量
class PrototypeAST
{
public:
std::string name; // 名称
std::vector<std::string> args; // 参数名称

PrototypeAST(std::string name, std::vector<std::string> args)
: name(std::move(name)), args(std::move(args)) {}

// nodiscard声明一个不应该舍弃返回值的方法或类
[[nodiscard]] const std::string& get_name() const
{
return name;
}

// 打印信息
std::string get_info()
{
std::string s = "name: " + this->name + ", args:";
for (const std::string& arg: args)
s.append(" " + arg);
return s;
}
};

// 方法类
class FunctionAST
{
public:
std::unique_ptr<PrototypeAST> proto;
std::unique_ptr<ExprAST> body;

FunctionAST(std::unique_ptr<PrototypeAST> proto, std::unique_ptr<ExprAST> body)
: proto(std::move(proto)), body(std::move(body)) {}
};

在目前的语言定义中,由于我们的变量统一为双精度浮点值,我们无需指定每个参数的类型,并且函数的类型由它的参数的个数而决定。到这里,我们定义了所有的AST节点类,接下来我们就需要解析代码为一个个AST节点。

语法解析器,Parser

简单的帮助函数

下面的cur_token和get_next_token提供了最简单的token缓冲区,cur_token是当前parser正在处理的token,get_next_token从lexer处读取下一个token,并将返回的结果赋值给cur_token。

1
2
3
4
5
static int cur_token;
static int get_next_token()
{
return cur_token = get_tok();
}

然后再定义一些简单的错误处理函数。

1
2
3
4
5
6
7
8
9
10
std::unique_ptr<ExprAST> log_error(const char* str)
{
std::fprintf(stderr, "LogError: %s\n", str);
return nullptr;
}
std::unique_ptr<PrototypeAST> log_error_p(const char* str)
{
log_error(str);
return nullptr;
}

Primary表达式

接下来就开始实现语法解析器,首先实现的是数字literal,当lexer返回tok_number时调用。

1
2
3
4
5
6
7
8
9
10
// numberexpr ::= number
static std::unique_ptr<ExprAST> parse_number_expr()
{
// make_unique是智能指针,创建对应类型的unique_ptr,无需delete
auto result = std::make_unique<NumberExprAST>(num_val);
// 将当前读取的位置移到当前的数字后
get_next_token();
// 将对应的ast类返回
return std::move(result);
}

然后是小括号的解析,小括号表达式是指用小括号将一个表达式包裹起来,当lexer返回的是前小括号(时调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// parenexpr ::= '(' expression ')'
static std::unique_ptr<ExprAST> parse_paren_expr()
{
// 将当前读取的位置移到前括号之后
get_next_token();
// 解析括号中的表达式,parse_expression()将会在下面进行定义
auto v = parse_expression();
// 如果括号中没有解析到任何表达式,则返回空指针
if (!v)
return nullptr;
// 表达式解析完后,应该有对应的后括号
// 如果未识别到后括号,则发生错误
if (cur_token != ')')
return log_error("Expect ')'");
// 识别到后括号,接着将当前读取位置移动到后括号之后
get_next_token();
// 返回括号中的表达式
return v;
}

值得注意的是,在函数的第二行,我们调用了parse_expression(),这个函数是用来解析一个表达式的,我们还没有定义。parse_expression()和parse_paren_expr()是递归调用的关系,他们相互调用。当前者遇到前小括号,则调用后者,后者在处理括号中的表达式时,会调用前者。此外,我们还可以发现,小括号并不会构建任何AST节点,parse_paren_expr()返回的也是括号中的表达式节点。小括号的作用是引导parser进行AST的构建,有时它会制定解析时的优先顺序,一旦括号中的表达式解析完成,就不需要小括号了。

下面对标识符进行解析,当lexer返回tok_identifier时进行调用,标识符可以分为变量的引用和函数的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// identifierexpr
// ::= identifier
// ::= identifier '(' expression* ')'
static std::unique_ptr<ExprAST> parse_identifier_expr()
{
// 当前标识符的名称
std::string id_name = identifier_str;
// 读取identifier后面的字符
get_next_token();

// 如果后面的字符不是前括号,则是第一种情况,即变量引用
if (cur_token != '(')
return std::make_unique<VariableExprAST>(id_name);

// 第二种情况,函数调用
// 读取前括号之后的token
get_next_token();
// 函数调用的参数
std::vector<std::unique_ptr<ExprAST>> args;
// 前括号之后的token不是后括号,表示中间有参数
if (cur_token != ')')
{
while (true)
{
// 解析expression,如果解析成功,则将参数加入到参数列表中
if (auto arg = parse_expression())
args.push_back(std::move(arg));
else
// 发生解析错误,返回空指针
return nullptr;

// 前面的parse_expression()会将当前的字符移动到参数之后的一个token
// 如果当前的token是后括号,那么跳出循环,参数解析完成
if (cur_token == ')')
break;

// 后面还有参数,参数之间需要用逗号隔开
if (cur_token != ',')
return log_error("Expect ',' or ')' in argument list");

// 读取逗号之后的下一个token,继续解析下一个参数
get_next_token();
}
}

// 参数解析完成,读取后括号之后的下一个token
get_next_token();

// 返回函数调用AST实例
return std::make_unique<CallExprAST>(id_name, std::move(args));
}

下面是注释。

1
2
3
4
5
static std::unique_ptr<CommentAST> parse_comment()
{
get_next_token();
return std::make_unique<CommentAST>(std::move(comment_line));
}

值得注意的是,这里,我们通过判断当前位置的下一个字符是否是前括号,来判断当前的标识符是变量引用还是函数调用。到这里,我们已经完成了所有的简单表达式的解析函数,我们接着用一个大的helper函数将他们都打包在一起。我们将这些表达式统称为”primary”表达式,然后实现以下的函数。

此外,注释是我个人添加进去的,它不算primary表达式,但是为了简单,我在下面将它的解析也打包进了函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// primary
// ::= identifierexpr
// ::= numberexpr
// ::= parenexpr
static std::unique_ptr<ExprAST> parse_primary()
{
if (cur_token == tok_identifier)
return parse_identifier_expr();
if (cur_token == tok_number)
return parse_number_expr();
if (cur_token == '(')
return parse_paren_expr();
if (cur_token == tok_comment)
return parse_comment();
std::string msg = "Unknown token when expecting an expression: ";
msg += std::to_string(cur_token);
msg += " ";
msg += char(cur_token);
return log_error(msg.c_str());
}

二进制表达式

接下来是二元表达式的解析了,这里明显会更难,因为二元表达式经常有歧义。例如a+b*c,我们可以先计算加法,再进行乘法,也可以换过来,但是根据运算符的优先级,先计算乘法是正确的。解决这一问题的方法有很多,其中最优雅的方式是用运算符优先解析方法(Operator-Precedence Parsing),该方法通过运算符的优先级来指导递归的进程,首先我们需要先定义所有可能的运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义每个二元运算符到其优先级的映射
// 数字越高优先级越大
static std::map<char, int> bin_op_precedence = {
{'<', 10},
{'+', 20},
{'-', 20},
{'*', 40}
};

// 获取当前token的优先级,如果当前token不是定义好的运算符,则返回-1
static int get_tok_precedence()
{
// 当前token不是ASCII码,那么肯定不是运算符
if (!isascii(cur_token))
return -1;

// 从map中获取优先级
int tok_prec = bin_op_precedence[char(cur_token)];
// 如果map中不存在该建,表明不是运算符
if (tok_prec <= 0)
return -1;
return tok_prec;
}

我们现在可以开始解析二元表达式,解析的基本想法是将表达式按运算符拆解开来。例如,”a+b+(c+d)ef+g”,我们首先将他们看做是一系列被运算符隔开的primary表达式。因此,解析器首先解析最前面的a,然后会看到一系列pair,即[+, b] [+, (c+d)] [, e] [, f]和[+, g]。注意到,由于小括号是primary表达式,我们无需考虑括号内的+号与其他运算符的优先级关系,首先,一个表达式是一个可能后面跟着一系列[binop, primaryexpr] pair的primary表达式。

1
2
3
4
5
6
7
8
9
10
11
static std::unique_ptr<ExprAST> parse_bin_op_rhs(int expr_prec, std::unique_ptr<ExprAST> lhs);
// expression
// ::= primary binoprhs
static std::unique_ptr<ExprAST> parse_expression()
{
auto lhs = parse_primary();
if (!lhs)
return nullptr;

return parse_bin_op_rhs(0, std::move(lhs));
}

parse_bin_op_rhs是用来解析[binop, primaryexpr] pair的函数,它的参数是一个优先级,和一个指向目前为止已经解析部分的表达式的指针。值得注意的是,”x”也是一个有效的表达式,即后面没有任何pair,因此binoprhs可以为空,在这种情况下,函数直接返回表达式。在上面的”a+b+(c+d)ef+g”的表达式中,上面的parse_expression函数会先解析a,然后把当前的读取位置移到”+”上,然后调用这个函数,传入的参数是0和指向a表达式的指针。

传入该函数的优先级expr_prec表示该函数中,允许解析的运算符的最低优先级。例如,如果当前的pair是[+, b],而此时传入的优先级参数是40,那么这个函数将不会解析这个pair,因为+的优先级是20,下面的函数中,我们将使用”a+b+(c+d)ef+g”作为说明是的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static std::unique_ptr<ExprAST> parse_bin_op_rhs(int expr_prec, std::unique_ptr<ExprAST> lhs)
{
// 当前读取的位置为第一个表达式(a)后一个位置(+)
while (true)
{
// 获得当前操作符+的优先级,20
int tok_prec = get_tok_precedence();

// 如果优先级小于传入的参数,则直接返回之前的表达式指针
// 第一次调用时,传入的参数是0,那么只要当前token是操作符,那么优先级就大于0
// 只有在当前token不是有效的操作符时才会直接返回
// 这里还负责判断pair是否解析完成,如果pair序列解析完成,那么就会遇到分号等token
// 他们并不是有效的操作符,因此会在这里将已经解析的表达式,也就是整个表达式返回
if (tok_prec < expr_prec)
return lhs;

// bin_op保存当前的操作符,+
int bin_op = cur_token;
// 读取下一个token,b
get_next_token();

// 解析下一个primary,下一个primary expr是b
auto rhs = parse_primary();
// 如果解析失败,那么返回空指针
if (!rhs)
return nullptr;

// 这时由于解析下一个primary token时,把读取位置移到了下一个primary expr之后一个位置,即b后面的+号
// 获取下一个操作符的优先级,20
int next_prec = get_tok_precedence();
// 如果下一个操作符优先级大于当前的,那么优先解析后面的表达式
if (tok_prec < next_prec)
{
// 优先解析后面的表达式,最小优先级比当前优先级高一,已经解析的表达式仅为下一个expr,即b
rhs = parse_bin_op_rhs(tok_prec + 1, std::move(rhs));
// 解析失败
if (!rhs)
return nullptr;
}

// 如果不大于,或后面的解析完成,那么把左右结合
// 将左右用当前的操作符结合成一个AST表达式节点
lhs = std::make_unique<BinaryExprAST>(bin_op, std::move(lhs), std::move(rhs));
}
}

下面我模拟了一下整个过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*
* a+b+(c+d)*e*f+g
*
* loop 0: expr_prec: 0, lhs -> a
* cur_token: +, token_prec: 20, !20<0, 继续往下,不进入if, bin_op: +
* cur_token: b, 解析 b, rhs -> b
* cur_token: +, next_prec: 20
* !tok_prec < next_prec, 不进入if
* lhs -> BinaryExprAST(+, a, b), a+b成为一个完整的BinaryExprAST节点,父节点是+,左右节点是a和b
* loop 1: expr_prec: 0, lhs -> (a+b)
* cur_token: +, token_prec: 20, !20<0, go ahead, bin_op: +
* cur_token: (, parse (c+d), rhs -> (c+d), 省略解析c+d的过程,和loop 0相同,解析完d,遇到后括号,不是操作数,返回c+d的AST节点
* cur_token: *, next_prec: 40
* tok_prec < next_prec, 进入if, 首先递归调用,参数是21和(c+d)
* - loop 0: expr_prec: 21, lhs -> (c+d)
* cur_token: *, token_prec: 40, !40<21, 继续往下,不进入if, bin_op: *
* cur_token: e, 解析e, rhs -> e
* cur_token: *, next_prec: 40
* !tok_prec < next_prec
* lhs -> BinaryExprAST(*, (c+d), e), (c+d)*e构成一个完整的AST节点,父节点是*,左子树是(c+d),右子树是e
* - loop 1: expr_prec: 21, lhs -> (c+d)*e
* cur_token: *, token_prec: 40, !40<21,继续往下,不进入if,bin_op: *
* cur_token: f, 解析f, rhs -> f
* cur_token: +, next_prec: 20
* !tok_prec < next_prec
* lhs -> BinaryExprAST(*, (c+d)*e, f),(c+d)*e*f成为一个AST节点,父节点是*,左子树是(c+d)*e,右子树是f
* - loop 2: expr_prec: 21, lhs -> (c+d)*e*f
* cur_token: +, token_prec: 20, 20<21, 进入if,返回lhs: (c+d)*e*f
* rhs: (c+d)*e*f, if结束,这时bin_op是b后面的+,lhs是(a+b)
* lhs -> BinaryExprAST(+, (a+b), (c+d)*e*f), (a+b) + (c+d)*e*f构成一个完整的AST节点,父节点是+,左子树是(a+b),右子树是(c+d)*e*f
* loop 2: expr_prec: 0, lhs -> a+b+(c+d)*e*f
* 略,最后将+g也组合在了后面,解析为a+b+(c+d)*e*f+g
* 跟节点是f后面的+,左子树是(a+b)+(c+d)*e*f, 右子树是g
*/

函数定义

下面是函数定义的解析,由于我们只有双精度浮点数,我们不需要给变量指定类型,参数之间用空格隔开就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// prototype
// ::= id '(' id* ')'
static std::unique_ptr<PrototypeAST> parse_prototype()
{
// prototype应该以一个标识符开始,作为函数名
if (cur_token != tok_identifier)
return log_error_p("Expect function name in prototype");

std::string fn_name = identifier_str;
// 读取函数名后面一个token
get_next_token();

// 应该是前小括号开头
if (cur_token != '(')
return log_error_p("Expect '(' in prototype");

// 解析参数,参数无需指定类型,参数间用空格隔开
std::vector<std::string> arg_names;
while (get_next_token() == tok_identifier)
arg_names.push_back(identifier_str);

// 参数结束后,应该是后括号
if (cur_token != ')')
log_error_p("Expect ')' in prototype");

// 移动到后括号后面一个token
get_next_token();

// 返回prototype的AST
return std::make_unique<PrototypeAST>(fn_name, std::move(arg_names));
}

有了prototype,function definition就很简单了,就是prototype加上function body。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// definition
// ::= 'def' prototype expression
static std::unique_ptr<FunctionAST> parse_definition()
{
// 读取def之后的token
get_next_token();

// 解析prototype
auto proto = parse_prototype();
if (!proto)
return nullptr;

// 解析函数体,这里目前只支持一个expression
if (auto e = parse_expression())
return std::make_unique<FunctionAST>(std::move(proto), std::move(e));
return nullptr;
}

顶层top-level解析

最后,我们还将让用户输入任意的顶层表达式,并对它们进行实时评估,我们将通过为它们定义匿名的nullary(零参数)函数来处理这个问题。我的理解就是从文件的角度,我们要将从文件视角看下去的元素进行解析,而不是函数角度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// toplevelexpr ::= expression
static std::unique_ptr<FunctionAST> parse_top_level_expr()
{
// 首先解析一个表达式
if (auto e = parse_expression())
{
// 然后构建一个匿名的,零参数的prototype
auto proto = std::make_unique<PrototypeAST>("", std::vector<std::string>());
// 用上面的prototype构建一个函数,函数体为上面解析到的表达式
return std::make_unique<FunctionAST>(std::move(proto), std::move(e));
}
return nullptr;
}

// extern表达式,即extern+prototype
// external ::= 'extern' prototype
static std::unique_ptr<PrototypeAST> parse_extern()
{
// 读取extern之后的token
get_next_token();
// 解析prototype
return parse_prototype();
}

// top-level comment
static std::unique_ptr<CommentAST> parse_top_level_comment()
{
get_next_token();
return std::make_unique<CommentAST>(std::move(comment_line));
}

下面是从文件角度来进行解析的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static void handle_definition()
{
unique_ptr<FunctionAST> fun = parse_definition();
if (fun)
{
std::fprintf(stderr, "Parsed a function definition, %s\n", fun->proto->get_info().c_str());
}
else
{
// 跳过
std::fprintf(stderr, "Error when handling function definition, skip\n");
get_next_token();
}
}

static void handle_extern()
{
unique_ptr<PrototypeAST> ext = parse_extern();
if (parse_extern())
{
std::fprintf(stderr, "Parsed a extern, %s\n", ext->get_info().c_str());
}
}

static void handle_top_level_expression()
{
// 将top-level的表达式解析为一个匿名函数
if (parse_top_level_expr())
{
std::fprintf(stderr, "Parsed a top-level expr.\n");
}
else
{
std::fprintf(stderr, "Error when handling top-level expression, skip\n");
get_next_token();
}

}

static void handle_top_level_comment()
{
unique_ptr<CommentAST> comment = parse_top_level_comment();
if (comment)
std::fprintf(stderr, "Parsed a comment, content: %s\n", comment->content.c_str());
else
{
std::fprintf(stderr, "Error when handling top-level comment, skip\n");
get_next_token();
}
}

简单的driver

我们写一个简单的driver,判断lexer返回的token类型,调用相对应的处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/// top ::= definition | external | expression | ';'
int main_loop()
{
while (true)
{
fprintf(stderr, "ready> ");
if (cur_token == tok_eof)
return 0;
if (cur_token == ';')
{
get_next_token();
}
else if (cur_token == tok_def)
{
handle_definition();
}
else if (cur_token == tok_extern)
{
handle_extern();
}
else if (cur_token == tok_comment)
{
handle_top_level_comment();
}
else
{
handle_top_level_expression();
}
}
}

这样我们就可以通过每次输入一个表达式,得到解析器解析的结果。

1
2
ready> def foo(x y z) x + y * z
Parsed a function definition, name: foo, args: x y z

总结

我们到现在为止可以分析出代码中的各种成分,并能判断代码是否是语法正确的。下面,我们将介绍如何从AST生成LLVM IR。


编译器中的Parser,语法分析器,以及抽象语法树AST
http://nougatca.github.io/2023/01/04/llvm-parser/
作者
Changan NIU
发布于
2023年1月4日
许可协议