36、C++项目实战:简易编译器(词法分析)

说实话,很多学C++的朋友一听到「编译器」三个字就头大。觉得那是大神才碰的东西。其实不然。

词法分析,是编译器最基础的一步。说白了,就是把源代码的字符串流,拆成一个个有意义的「单词」——我们叫它Token。我当年第一次写词法分析器的时候,花了整整一个周末。嗯,踩了不少坑,但搞完之后,对C++的理解直接上了一个台阶。

什么是词法分析?

你想想看,编译器拿到你的源代码,它看到的只是一长串字符。比如:

int a = 42;

在编译器眼里,这就是 'i','n','t',' ','a',' ','=',' ','4','2',';' 这么一串东西。词法分析要做的,就是把这些字符组合成有意义的单元:

  • 关键字int
  • 标识符a
  • 运算符=
  • 数字字面量42
  • 分隔符;

每个单元就是一个Token。词法分析器会输出一个Token流,供下一步语法分析使用。

核心要点:词法分析不关心代码逻辑对不对,只关心「能不能正确拆出单词」。比如 int a = ; 语法上明显缺东西,但词法分析照样能拆出 inta=; 四个Token。

Token的数据结构设计

写代码之前,先把数据结构定好。我个人习惯用枚举表示Token类型:

enum class TokenType {
    KEYWORD,      // 关键字:int, return, if 等
    IDENTIFIER,   // 标识符:变量名、函数名
    NUMBER,       // 数字字面量
    OPERATOR,     // 运算符:+ - * / = 等
    SEPARATOR,    // 分隔符:; { } ( ) 等
    STRING,       // 字符串字面量
    COMMENT,      // 注释(可以跳过)
    END_OF_FILE   // 文件结束
};

然后定义Token结构体:

struct Token {
    TokenType type;
    std::string value;
    int line;      // 行号,方便报错
    int column;    // 列号,方便定位
};

行号和列号很重要。我在项目中遇到过,没有行号信息,调试时根本不知道错误出在哪一行。那叫一个痛苦。

词法分析的核心流程

词法分析器通常是一个状态机。我们从左到右扫描字符,根据当前字符决定下一步做什么。我画了一张流程图,帮你理清思路:

开始扫描 读取下一个字符 字符类型? 字母/下划线 标识符/关键字 数字 数字字面量 符号/运算符 运算符/分隔符 输出Token

这个流程看着简单,但实际写起来有不少细节。我来拆解一下。

实现一个简单的词法分析器

我们实现一个简化版,支持整数、标识符、基本运算符和关键字。先看代码:

#include <iostream>
#include <string>
#include <vector>
#include <cctype>

class Lexer {
public:
    Lexer(const std::string& source)
        : source(source), pos(0), line(1), column(1) {}

    std::vector<Token> tokenize() {
        std::vector<Token> tokens;
        while (pos < source.size()) {
            char ch = peek();
            
            // 跳过空白
            if (std::isspace(ch)) {
                if (ch == '\n') {
                    line++;
                    column = 1;
                }
                advance();
                continue;
            }
            
            // 处理注释:单行 //
            if (ch == '/' && peekNext() == '/') {
                skipSingleLineComment();
                continue;
            }
            
            // 处理数字
            if (std::isdigit(ch)) {
                tokens.push_back(readNumber());
                continue;
            }
            
            // 处理标识符或关键字
            if (std::isalpha(ch) || ch == '_') {
                tokens.push_back(readIdentifierOrKeyword());
                continue;
            }
            
            // 处理运算符和分隔符
            Token token;
            token.line = line;
            token.column = column;
            token.value = std::string(1, ch);
            
            switch (ch) {
                case '+': case '-': case '*': case '/':
                case '=': case '<': case '>':
                    token.type = TokenType::OPERATOR;
                    break;
                case ';': case '{': case '}': case '(': case ')':
                    token.type = TokenType::SEPARATOR;
                    break;
                default:
                    // 不认识字符,报错
                    std::cerr << "词法错误: 未知字符 '" << ch 
                              << "' 在第 " << line << " 行" << std::endl;
                    token.type = TokenType::END_OF_FILE; // 标记错误
                    break;
            }
            tokens.push_back(token);
            advance();
        }
        
        // 添加文件结束Token
        tokens.push_back({TokenType::END_OF_FILE, "", line, column});
        return tokens;
    }

private:
    // ... 辅助方法见下文
};

小技巧:我习惯把 peek()advance() 封装成私有方法。这样主逻辑看起来干净很多。你想想看,如果每个字符操作都写 source[pos],代码会乱成一团。

关键辅助方法

这几个方法很常用,我直接贴出来:

private:
    char peek() const {
        return pos < source.size() ? source[pos] : '\0';
    }
    
    char peekNext() const {
        return pos + 1 < source.size() ? source[pos + 1] : '\0';
    }
    
    void advance() {
        pos++;
        column++;
    }
    
    Token readNumber() {
        Token token;
        token.type = TokenType::NUMBER;
        token.line = line;
        token.column = column;
        
        std::string num;
        while (pos < source.size() && std::isdigit(peek())) {
            num += peek();
            advance();
        }
        token.value = num;
        return token;
    }
    
    Token readIdentifierOrKeyword() {
        Token token;
        token.line = line;
        token.column = column;
        
        std::string word;
        while (pos < source.size() && 
               (std::isalnum(peek()) || peek() == '_')) {
            word += peek();
            advance();
        }
        
        // 判断是否为关键字
        static const std::set<std::string> keywords = {
            "int", "return", "if", "else", "while", "void"
        };
        
        if (keywords.count(word)) {
            token.type = TokenType::KEYWORD;
        } else {
            token.type = TokenType::IDENTIFIER;
        }
        token.value = word;
        return token;
    }
    
    void skipSingleLineComment() {
        // 跳过 //
        advance(); advance();
        while (pos < source.size() && peek() != '\n') {
            advance();
        }
    }

注意peekNext() 一定要做边界检查。我曾经在项目里忘了检查,结果 pos + 1 越界,程序直接崩溃。调试了半天才发现是这里的问题。

测试一下

写个简单的测试:

int main() {
    std::string code = R"(
        int main() {
            int a = 42;
            return a;
        }
    )";
    
    Lexer lexer(code);
    auto tokens = lexer.tokenize();
    
    for (const auto& token : tokens) {
        std::cout << "Token: " << token.value 
                  << " | 类型: ";
        switch (token.type) {
            case TokenType::KEYWORD:    std::cout << "关键字"; break;
            case TokenType::IDENTIFIER: std::cout << "标识符"; break;
            case TokenType::NUMBER:     std::cout << "数字"; break;
            case TokenType::OPERATOR:   std::cout << "运算符"; break;
            case TokenType::SEPARATOR:  std::cout << "分隔符"; break;
            case TokenType::END_OF_FILE:std::cout << "文件结束"; break;
            default: std::cout << "未知";
        }
        std::cout << " | 行: " << token.line 
                  << " 列: " << token.column << std::endl;
    }
    return 0;
}

输出结果应该是这样的:

Token: int | 类型: 关键字 | 行: 2 列: 9
Token: main | 类型: 标识符 | 行: 2 列: 13
Token: ( | 类型: 分隔符 | 行: 2 列: 17
Token: ) | 类型: 分隔符 | 行: 2 列: 18
Token: { | 类型: 分隔符 | 行: 2 列: 20
Token: int | 类型: 关键字 | 行: 3 列: 13
Token: a | 类型: 标识符 | 行: 3 列: 17
Token: = | 类型: 运算符 | 行: 3 列: 19
Token: 42 | 类型: 数字 | 行: 3 列: 22
Token: ; | 类型: 分隔符 | 行: 3 列: 24
Token: return | 类型: 关键字 | 行: 4 列: 13
Token: a | 类型: 标识符 | 行: 4 列: 20
Token: ; | 类型: 分隔符 | 行: 4 列: 21
Token: } | 类型: 分隔符 | 行: 5 列: 9
Token:  | 类型: 文件结束 | 行: 6 列: 1

常见坑与避坑指南

  • 多字符运算符:比如 ==!=<=。我建议先读一个字符,再 peek 下一个,如果是 = 就合并。不要偷懒只处理单字符。
  • 字符串字面量:遇到 " 要一直读到下一个 ",中间还要处理转义字符 \"。这个坑我踩过,转义没处理好,字符串直接炸了。
  • 错误恢复:遇到不认识字符时,不要直接崩溃。跳过去,记录错误,继续分析后面的代码。这样用户能一次性看到所有错误。

总结一下:词法分析其实就是一个「读字符 → 判断 → 组Token」的循环。核心是状态机思维。你只要把每种字符的处理逻辑写清楚,剩下的就是体力活。嗯,我当年写完这个之后,再看编译原理的书,感觉豁然开朗。


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