编译原理
从 parser 走向 AST:语义动作如何在归约时构建抽象语法树、AST 节点设计、Visitor 模式与多 pass 遍历框架,以及把 parser 与 frontend 其余阶段解耦。
Chapter 4 讨论的是:
可以把这一章看成是:
从“parser 只负责识别”走向“parser 负责构造后续阶段可用的数据结构”。
一个 parser 的最基本任务是:
但是编译器真正需要的通常更多,例如:
所以:
parsing 只是第一步;semantic actions 让 parser 在识别结构的同时,把结构转换成更有用的结果。
Semantic action 就是附着在 parsing 过程中的计算或副作用。
它可以是:
对产生式
可以理解为:当 parser 识别出右部 B C D 时,执行某段动作,构造出左部 A 对应的语义结果。
也就是说,除了“这个短语存在”,我们还关心:
在 recursive-descent parser 中:
因此,递归下降里最自然的问题就是:
这个 non-terminal 的 parsing function 应该返回什么?
对于每个 terminal / non-terminal,我们都可以关联一个“语义值类型”。
例如:
NUM 可以携带 intID 可以携带 stringE 既可以返回:
int(如果我们想直接求值)A_exp(如果我们想构造 AST)这个“类型”来自编译器的实现语言,例如 C 里的 int、指针、结构体等。
这一章继续使用表达式 grammar:
S -> E $
E -> T E'
E' -> + T E'
| - T E'
| ε
T -> F T'
T' -> * F T'
| / F T'
| ε
F -> id
| num
| (E)这个 grammar 已经消除了左递归,因此适合 predictive / recursive-descent parsing。
如果我们的目标是 evaluate expression while parsing,那么 F() 可以直接返回一个整数值:
enum token {EOF, ID, NUM, PLUS, MINUS, TIMES, DIV, LPAREN, RPAREN};
union tokenval {
string id;
int num;
};
enum token tok;
union tokenval tokval;
// assume a lookup table mapping identifiers to integers
int lookup(string id);
int F(void) {
switch (tok) {
case ID: {
int v = lookup(tokval.id);
advance();
return v;
}
case NUM: {
int v = tokval.num;
advance();
return v;
}
case LPAREN: {
eat(LPAREN);
int v = E();
eat(RPAREN);
return v;
}
default:
error("expected ID, NUM, or '('");
return 0;
}
}这里的关键点是:
F 不只是“识别一个 factor”例如:
id -> 通过 lookup 取变量值num -> 直接返回数字(E) -> 返回括号内表达式的值在 recursive descent 中,semantic action 不一定非要靠返回值。
它还可以通过 side effect 完成任务。
例如:
S -> id := num对应的 semantic action 可以是:
num 的值写入变量 id所以递归下降中常见的两种风格是:
很多时候二者会同时出现。
一个非常重要的问题是:
原始表达式 grammar 往往是左递归的,例如:
T -> T * F | F但为了写 recursive-descent parser,我们通常会把它改写成:
T -> F T'
T' -> * F T'
| / F T'
| ε这样虽然 grammar 适合 top-down parsing 了,但语义动作不能简单照搬。
正确做法是:
把已经计算好的“左操作数”当参数传给
T'。
例如:
int T(void) {
switch (tok) {
case ID:
case NUM:
case LPAREN:
return Tprime(F());
default:
error("expected ID, NUM, or '('");
return 0;
}
}
int Tprime(int acc) {
switch (tok) {
case TIMES:
eat(TIMES);
return Tprime(acc * F());
case DIV:
eat(DIV);
return Tprime(acc / F());
case PLUS:
case MINUS:
case RPAREN:
case EOF:
return acc;
default:
error("bad token in T'");
return acc;
}
}这里:
acc 表示已经处理好的左侧结果* F,就把 acc * F() 继续传下去所以:
在改写 grammar 之后,semantic action 往往也要同步改写。
在 Yacc / Bison 风格的 parser 中,semantic action 写在产生式后面的 { ... } 里:
%union { int num; string id; }
%token <num> INT
%token <id> ID
%type <num> exp
%left UMINUS
%%
exp : INT { $$ = $1; }
| exp PLUS exp { $$ = $1 + $3; }
| exp MINUS exp { $$ = $1 - $3; }
| exp TIMES exp { $$ = $1 * $3; }
| MINUS exp %prec UMINUS { $$ = -$2; }
;这里几个符号非常重要:
{ ... }:semantic action$i:右部第 i 个符号的语义值$$:左部 non-terminal 的语义值%union:声明语义值可能的多种类型<...>:为某个 terminal / non-terminal 指定它使用 %union 中的哪个分量$$、$1、$2、$3 的含义例如:
exp : exp PLUS exp { $$ = $1 + $3; }含义是:
$1:左边那个 exp 的值$2:PLUS 的值(通常不需要)$3:右边那个 exp 的值$$:归约完成后,这个新的 exp 的值也就是说:
semantic action 明确地描述了“如何由子节点的语义值构造父节点的语义值”。
Yacc 生成的 LR parser 不只维护一个 state stack,还会维护一个与之平行的:
当 parser 做一次 reduction:
A -> Y1 Y2 ... Yk它会:
k 个 RHS 符号的值$1 ... $k{ ... } 中的 semantic action$$k 个值$$ 压回栈中,作为新产生的 A 的语义值所以 $i 的来源其实非常直接:
它们就是归约时栈顶那几个符号携带的语义值。
考虑:
exp : exp PLUS exp { $$ = $1 + $3; }如果当前栈顶对应的是:
exp 的值为 1+exp 的值为 6那么 reduction 时会做:
$$ = $1 + $3
val = 1 + 6然后:
<exp2, 6><+, NULL><exp1, 1><exp, 7>这正是“把子表达式的值归约成父表达式的值”。
在 bottom-up / LR parsing 中,reduction 的发生顺序是确定的。
因此,associated semantic actions 的执行顺序也是确定的:
这点很重要,因为它说明:
LR parser 中的 semantic action 执行顺序不是随意的,而是由 reduction 顺序严格决定的。
| 方式 | 语义动作通常写在哪里 | 典型数据流 |
|---|---|---|
| Recursive Descent | 写在 parsing function 里面 | 返回值 / side effect |
| Yacc / LR | 写在 grammar rule 后面 | $1, $2, ... -> $$ |
但它们本质上都在做同一件事:
当某个语法短语被识别出来时,计算它对应的语义结果。
理论上,完全可以把整个编译器都写进 Yacc 的 semantic action 里。
但是这样通常 不利于工程实现,主要原因有:
例如:
void foo() { bar(); }
void bar() { ... }如果所有事情都在 parsing 时立刻做掉,就会遇到:
foo 里已经调用了 barbar 还没有被 parse 到这说明:
“parse 到哪里就必须立刻做完所有语义工作”并不是一个很好的架构。
更好的做法是:
从技术上说,一个 parse tree:
这种树忠实反映了 grammar 的具体形式,因此也叫:
具体语法树通常不适合直接给后续 phase 使用,原因包括:
(、)所以 concrete parse tree 虽然“忠实”,但并不“好用”。
Abstract syntax 的目标是给 parser 和后续 phase 之间提供一个更干净的接口。
它的核心思想是:
例如,具体语法可能写成:
E -> E + T
| T
T -> T * F
| F
F -> n
| (E)而对应的抽象语法可以写得更直接:
E -> n
| E + E
| E * E注意:
对表达式:
2 + 3 * 4抽象语法树会表示为:
+
/ \
2 *
/ \
3 4这棵树说明的是:
* 比 + 优先级高3 * 4 是一个整体2 做加法但它 没有做语义解释,例如:
所以:
AST 表达的是“程序结构”,而不是“程序已经执行完的结果”。
AST 相比 concrete parse tree 的优势可以概括为:
如果编译器后续要操作 AST,就必须把 AST 表示成程序里的数据结构。
一种经典表示方法是:
typedefenum + union 区分不同 kind 的节点例如表达式 AST:
typedef struct A_exp_ *A_exp;
struct A_exp_ {
enum {A_numExp, A_plusExp, A_timesExp} kind;
union {
int num;
struct { A_exp left; A_exp right; } plus;
struct { A_exp left; A_exp right; } times;
} u;
};
A_exp A_NumExp(int num);
A_exp A_PlusExp(A_exp left, A_exp right);
A_exp A_TimesExp(A_exp left, A_exp right);这里:
kind 告诉我们这个节点是什么类型u 里存放对应类型的具体数据A_NumExp / A_PlusExp / A_TimesExp 是构造函数例如构造加法节点:
A_exp A_PlusExp(A_exp left, A_exp right) {
A_exp e = checked_malloc(sizeof(*e));
e->kind = A_plusExp;
e->u.plus.left = left;
e->u.plus.right = right;
return e;
}这个函数做的事就是:
这和“返回一个 int 值”完全不同:
2 + 3 * 4 的 AST
A_exp e1 = A_NumExp(2);
A_exp e2 = A_NumExp(3);
A_exp e3 = A_NumExp(4);
A_exp e4 = A_TimesExp(e2, e3);
A_exp e5 = A_PlusExp(e1, e4);要点是:
TimesExpPlusExp 上也就是说:
我们先反映语法结构,再谈后续解释。
所以这里的结果不是数字 14,而是一棵树。
无论是 recursive descent 还是 Yacc-generated parser,都可以在 parsing concrete syntax 的同时构造 AST。
例如在 Yacc 中:
%left PLUS
%left TIMES
%%
exp : NUM { $$ = A_NumExp($1); }
| exp PLUS exp { $$ = A_PlusExp($1, $3); }
| exp TIMES exp { $$ = A_TimesExp($1, $3); }
;这里:
NUM,就构造一个数字节点exp PLUS exp,就构造一个加法节点exp TIMES exp,就构造一个乘法节点这说明 semantic action 不一定要“算值”,也可以“建树”。
同样一套 parsing 框架,可以有两种不同目标:
intA_exp例如:
exp PLUS exp { $$ = $1 + $3; }
exp PLUS exp { $$ = A_PlusExp($1, $3); }
所以 semantic action 的本质是:
为“同一个语法结构”选择一种你想要的语义表示。
在 one-pass compiler 里:
但如果编译器采用 AST 结构,情况就不同了:
这时如果发现类型错误、未定义变量等问题,就不能再依赖“当前 token 位置”了。
所以需要:
在 AST 节点里保存它对应的 source-file position。
通常做法是:
pos 字段例如可以想象成:
struct A_exp_ {
position pos;
enum {A_numExp, A_plusExp, A_timesExp} kind;
union { ... } u;
};这个 pos 可以是:
为了给 AST 节点设置位置,通常需要两步:
理想情况下,parser 最好也维护一个与 semantic value stack 平行的:
这样每个 grammar symbol 的位置都可以像 semantic value 一样被访问。
这一点上:
因此在 Yacc 里,一个常见 workaround 是:
额外定义一个
posnon-terminal,让它的 semantic value 就是当前位置。
例如:
%{
extern A_OpExp(A_exp, A_binop, A_exp, position);
%}
%union {
int num;
string id;
position pos;
/* ... */
}
%type <pos> pos
%%
pos : { $$ = EM_tokpos; }
exp : exp PLUS pos exp { $$ = A_OpExp($1, A_plus, $4, $3); }
;这里的意思是:
pos 这个“空产生式 non-terminal”专门用来捕获当前位置$3 传给 AST 构造函数这样做的目的就是:
即使后面很多 phase 才发现错误,也能准确报到源程序中的相关位置。
可以把二者的区别记成下面这张表:
| 概念 | 关注点 | 是否保留所有 token | 是否适合后续 phase |
|---|---|---|---|
| Concrete Parse Tree | grammar 的具体推导过程 | 基本会 | 通常不太适合 |
| Abstract Syntax Tree | 程序真正的结构 | 不会 | 非常适合 |
$1, $2, ..., $$ 以及语义值栈传递Chapter 3 主要回答的是:
How do we parse?
而 Chapter 4 主要回答的是:
After we parse, what useful result do we want to get from parsing?
答案就是两层:
Written by
Comments