实战:编译器前端——词法分析+语法分析+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 a = 42; 词法分析 Token流 语法分析 语法树 AST生成 抽象语法树 AST示例:a = 42; AssignNode Identifier: "a" NumberNode: "42" 编译器前端三阶段:词法分析 → 语法分析 → 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,语法分析建结构,AST生成提精华。每一步都有明确的输入输出,环环相扣。写的时候一定要先设计好Token类型和语法规则,再动手编码,否则很容易陷入「改来改去」的泥潭。

避坑指南

最后,分享几个我踩过的坑:

  • 词法分析中的贪婪匹配:比如 >> 是右移操作符,不是两个 >。词法分析器要尽量匹配最长的有效Token。我曾经没注意这个,导致模板嵌套 vector<vector<int>> 解析失败。
  • 语法分析中的左递归:递归下降解析器不能直接处理左递归文法,比如 E → E + T。需要改写文法或用其他方法。我建议初学者先避开左递归,用右递归替代。
  • AST节点的生命周期:用 std::unique_ptr 管理子节点,不要裸指针。否则析构时容易内存泄漏或重复释放。

嗯,编译器前端其实没有想象中那么神秘。你只要把词法、语法、AST这三块拆开,逐个击破,就能写出一个能用的解析器。下次遇到需要解析配置文件、DSL甚至脚本语言的时候,你就能直接上手了。

公众号:蓝海资料掘金营,微信 deep3321