实战:编译器前端——词法分析+语法分析+AST
说实话,很多C++程序员写了好几年代码,对编译器内部到底怎么工作的,心里还是模糊的。我当年也一样,直到有一次需要自己写一个领域特定语言(DSL)的解析器,才硬着头皮把编译器前端啃了一遍。嗯,今天我们就来亲手实现一个简化版的编译器前端,把词法分析、语法分析和AST(抽象语法树)这三块核心内容彻底搞明白。
编译器前端到底在干什么?
你想想看,你写的一行C++代码,比如 int a = 42;,计算机能直接看懂吗?当然不能。编译器前端就是负责把人类可读的源代码,转换成计算机能进一步处理的中间表示。这个过程分三步走:
- 词法分析:把字符流拆成一个个有意义的单词(Token)
- 语法分析:根据语法规则,把Token序列组织成语法树
- AST生成:构建一棵抽象语法树,忽略掉括号、分号等细节,只保留核心结构
我在项目中遇到过最典型的坑是:词法分析阶段没处理好注释和字符串字面量,结果语法分析器拿到了一堆乱七八糟的Token,调试了整整一个下午。所以,每一步都要稳扎稳打。
第一步:词法分析器(Lexer)
词法分析说白了就是「拆词」。我们需要定义好Token的类型,然后逐个字符扫描源代码,识别出关键字、标识符、数字、运算符、分隔符等。
我个人习惯用枚举来定义Token类型,清晰又高效:
enum class TokenType {
KEYWORD, // int, return, if 等
IDENTIFIER, // 变量名、函数名
NUMBER, // 整数或浮点数
OPERATOR, // +, -, *, /, =
SEPARATOR, // ;, {, }, (, )
END_OF_FILE // 文件结束
};
每个Token除了类型,还需要记录它的文本值和位置(行号、列号),方便后续报错时定位。来看一个简单的词法分析器核心逻辑:
class Lexer {
public:
Lexer(const std::string& source) : source(source), pos(0) {}
std::vector<Token> tokenize() {
std::vector<Token> tokens;
while (pos < source.size()) {
char c = source[pos];
if (isspace(c)) { pos++; continue; }
if (isalpha(c)) { tokens.push_back(parseKeywordOrIdentifier()); }
else if (isdigit(c)) { tokens.push_back(parseNumber()); }
else if (c == '+' || c == '-' || c == '*' || c == '/') {
tokens.push_back(Token(TokenType::OPERATOR, std::string(1, c), line, col));
pos++;
}
// ... 其他符号处理
}
tokens.push_back(Token(TokenType::END_OF_FILE, "", line, col));
return tokens;
}
private:
std::string source;
size_t pos;
int line = 1, col = 0;
};
int 虽然是字母组成,但它不是普通标识符,而是关键字。我一般用 std::unordered_set<std::string> 来存关键字集合,查找效率高。
第二步:语法分析器(Parser)
语法分析器拿到Token流之后,要按语法规则把它们组织起来。这里最常用的方法是「递归下降解析」。说白了,就是为每一条语法规则写一个解析函数,函数之间互相调用。
举个例子,假设我们要解析一个简单的赋值语句:a = 42;。语法规则可以定义为:
// 伪代码表示语法规则
statement → identifier '=' expression ';'
expression → number
对应的解析函数大概长这样:
class Parser {
public:
Parser(const std::vector<Token>& tokens) : tokens(tokens), pos(0) {}
std::unique_ptr<ASTNode> parseStatement() {
// 期望第一个Token是标识符
Token idToken = consume(TokenType::IDENTIFIER);
// 期望下一个是 '='
consume(TokenType::OPERATOR, "=");
// 解析右侧表达式
auto expr = parseExpression();
// 期望最后是 ';'
consume(TokenType::SEPARATOR, ";");
// 构建AST节点
return std::make_unique<AssignNode>(idToken.value, std::move(expr));
}
private:
std::unique_ptr<ASTNode> parseExpression() {
Token numToken = consume(TokenType::NUMBER);
return std::make_unique<NumberNode>(numToken.value);
}
};
第三步:构建AST(抽象语法树)
AST和语法树有什么区别?语法树包含所有语法元素,比如分号、括号这些。而AST只保留对后续编译有意义的节点。说白了,AST是语法树的「瘦身版」。
我们来看一个AST节点的基类设计:
struct ASTNode {
virtual ~ASTNode() = default;
virtual std::string toString() const = 0;
};
struct NumberNode : ASTNode {
std::string value;
explicit NumberNode(const std::string& val) : value(val) {}
std::string toString() const override { return "Number(" + value + ")"; }
};
struct AssignNode : ASTNode {
std::string varName;
std::unique_ptr<ASTNode> expr;
AssignNode(const std::string& name, std::unique_ptr<ASTNode> e)
: varName(name), expr(std::move(e)) {}
std::string toString() const override {
return "Assign(" + varName + ", " + expr->toString() + ")";
}
};
你看,AST节点之间通过 std::unique_ptr 来管理所有权,这样内存管理就清晰了,不会出现悬空指针。嗯,这里要注意:AST一旦构建完成,后续的语义分析、代码生成都直接在这棵树上操作,所以它的结构一定要稳定。
完整流程可视化
为了让你更直观地理解整个流程,我画了一张图:
实战:把它们串起来
好了,现在我们把三个组件组合成一个完整的编译器前端。主函数大概长这样:
int main() {
std::string source = "a = 42;";
// 1. 词法分析
Lexer lexer(source);
auto tokens = lexer.tokenize();
// 2. 语法分析 + AST构建
Parser parser(tokens);
auto ast = parser.parseStatement();
// 3. 输出AST验证结果
std::cout << "AST: " << ast->toString() << std::endl;
return 0;
}
输出结果应该是:AST: Assign(a, Number(42))。你看,分号和等号这些语法细节都被AST过滤掉了,只保留了核心的赋值关系和数值。
避坑指南
最后,分享几个我踩过的坑:
- 词法分析中的贪婪匹配:比如
>>是右移操作符,不是两个>。词法分析器要尽量匹配最长的有效Token。我曾经没注意这个,导致模板嵌套vector<vector<int>>解析失败。 - 语法分析中的左递归:递归下降解析器不能直接处理左递归文法,比如
E → E + T。需要改写文法或用其他方法。我建议初学者先避开左递归,用右递归替代。 - AST节点的生命周期:用
std::unique_ptr管理子节点,不要裸指针。否则析构时容易内存泄漏或重复释放。
嗯,编译器前端其实没有想象中那么神秘。你只要把词法、语法、AST这三块拆开,逐个击破,就能写出一个能用的解析器。下次遇到需要解析配置文件、DSL甚至脚本语言的时候,你就能直接上手了。