36、C++项目实战:简易编译器(词法分析)
说实话,很多学C++的朋友一听到「编译器」三个字就头大。觉得那是大神才碰的东西。其实不然。
词法分析,是编译器最基础的一步。说白了,就是把源代码的字符串流,拆成一个个有意义的「单词」——我们叫它Token。我当年第一次写词法分析器的时候,花了整整一个周末。嗯,踩了不少坑,但搞完之后,对C++的理解直接上了一个台阶。
什么是词法分析?
你想想看,编译器拿到你的源代码,它看到的只是一长串字符。比如:
int a = 42;
在编译器眼里,这就是 'i','n','t',' ','a',' ','=',' ','4','2',';' 这么一串东西。词法分析要做的,就是把这些字符组合成有意义的单元:
- 关键字:
int - 标识符:
a - 运算符:
= - 数字字面量:
42 - 分隔符:
;
每个单元就是一个Token。词法分析器会输出一个Token流,供下一步语法分析使用。
核心要点:词法分析不关心代码逻辑对不对,只关心「能不能正确拆出单词」。比如 int a = ; 语法上明显缺东西,但词法分析照样能拆出 int、a、=、; 四个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; // 列号,方便定位
};
行号和列号很重要。我在项目中遇到过,没有行号信息,调试时根本不知道错误出在哪一行。那叫一个痛苦。
词法分析的核心流程
词法分析器通常是一个状态机。我们从左到右扫描字符,根据当前字符决定下一步做什么。我画了一张流程图,帮你理清思路:
这个流程看着简单,但实际写起来有不少细节。我来拆解一下。
实现一个简单的词法分析器
我们实现一个简化版,支持整数、标识符、基本运算符和关键字。先看代码:
#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」的循环。核心是状态机思维。你只要把每种字符的处理逻辑写清楚,剩下的就是体力活。嗯,我当年写完这个之后,再看编译原理的书,感觉豁然开朗。