词法分析基础:编译器的第一道关卡
说实话,很多初学者觉得词法分析很简单,不就是把代码切分成单词吗?嗯,表面上看确实是这样。但我在做编译器的时候,发现词法分析器如果写得不好,后面整个编译流程都会跟着遭殃。
今天我们就来聊聊词法分析的基础知识。我会结合自己踩过的坑,帮你把这个环节吃透。
词法分析到底在干什么?
你想想看,编译器拿到源代码时,看到的只是一串长长的字符。比如:
int a = 10 + 20;
这串字符对计算机来说,就是一堆没有意义的字节。词法分析的任务,就是把这些字符流,转换成有意义的词法单元(Token)。
说白了,就是把「int」、「a」、「=」、「10」、「+」、「20」、「;」这些元素一个个识别出来,并且告诉编译器:
- 「int」是一个关键字
- 「a」是一个标识符
- 「=」是一个运算符
- 「10」是一个数字字面量
- 「+」是一个运算符
- 「20」是一个数字字面量
- 「;」是一个分隔符
我刚开始做词法分析时,犯过一个低级错误——没有处理好注释。结果注释里的内容被当成代码解析了,编译直接报错。嗯,这种坑踩一次就记住了。
词法单元(Token)的定义
Token 是词法分析的核心产物。每个 Token 通常包含两部分信息:
| 属性 | 说明 | 示例 |
|---|---|---|
| Token 类型 | 标识这个 Token 属于哪一类 | KEYWORD, IDENTIFIER, NUMBER |
| Token 值 | 具体的文本内容 | "int", "a", "10" |
| 位置信息 | 在源代码中的行列号 | 第3行第5列 |
我个人习惯用枚举来定义 Token 类型,这样后面语法分析阶段用起来特别顺手。比如:
enum TokenType {
KEYWORD, // 关键字
IDENTIFIER, // 标识符
NUMBER, // 数字
OPERATOR, // 运算符
SEPARATOR, // 分隔符
STRING, // 字符串
COMMENT, // 注释
EOF // 文件结束
};
正则表达式基础
词法分析离不开正则表达式。为什么?因为我们需要用正则来描述 Token 的模式。
举个例子,一个合法的标识符,通常要求:以字母或下划线开头,后面跟字母、数字或下划线。用正则表达就是:
[a-zA-Z_][a-zA-Z0-9_]*
数字字面量呢?可能是整数或浮点数:
[0-9]+(\.[0-9]+)?
常用的正则表达式元字符,我整理了一张表:
| 元字符 | 含义 | 示例 |
|---|---|---|
| . | 匹配任意单个字符 | a.b 匹配 aab, acb |
| * | 匹配前一个字符0次或多次 | ab* 匹配 a, ab, abb |
| + | 匹配前一个字符1次或多次 | ab+ 匹配 ab, abb |
| ? | 匹配前一个字符0次或1次 | ab? 匹配 a, ab |
| [] | 字符集合 | [abc] 匹配 a, b, c |
| () | 分组 | (ab)+ 匹配 ab, abab |
| | | 或运算 | a|b 匹配 a 或 b |
有限自动机(DFA/NFA)概念
正则表达式写好了,怎么让计算机高效地识别呢?这就引出了有限自动机。
有限自动机有两种:
- NFA(非确定有限自动机):从一个状态出发,遇到同一个输入字符,可能跳到多个状态。说白了就是「不确定下一步去哪」。
- DFA(确定有限自动机):从一个状态出发,遇到同一个输入字符,只会跳到唯一的一个状态。确定性很强,效率更高。
我一般会先把正则表达式转成 NFA,然后再把 NFA 转成 DFA。虽然多了一步,但 DFA 的执行效率高,适合做词法分析。
下面这张图展示了从正则表达式到 DFA 的转换流程:
手工编写词法分析器
理论讲完了,我们来动手写一个简单的词法分析器。我会用 C 语言实现,因为 C 语言足够底层,能让你看清每一步的细节。
先定义 Token 结构:
typedef struct {
TokenType type;
char value[256];
int line;
int column;
} Token;
然后实现一个核心函数 getNextToken,它从输入流中读取字符,返回下一个 Token:
Token getNextToken(FILE *input) {
Token token;
int c = fgetc(input);
// 跳过空白字符
while (c == ' ' || c == '\t' || c == '\n') {
if (c == '\n') {
currentLine++;
currentColumn = 1;
} else {
currentColumn++;
}
c = fgetc(input);
}
// 识别标识符或关键字
if (isalpha(c) || c == '_') {
int i = 0;
while (isalnum(c) || c == '_') {
token.value[i++] = c;
c = fgetc(input);
currentColumn++;
}
token.value[i] = '\0';
ungetc(c, input); // 多读的字符放回去
// 判断是否是关键字
if (isKeyword(token.value)) {
token.type = KEYWORD;
} else {
token.type = IDENTIFIER;
}
token.line = currentLine;
token.column = currentColumn - strlen(token.value);
return token;
}
// 识别数字
if (isdigit(c)) {
int i = 0;
while (isdigit(c)) {
token.value[i++] = c;
c = fgetc(input);
currentColumn++;
}
token.value[i] = '\0';
ungetc(c, input);
token.type = NUMBER;
token.line = currentLine;
token.column = currentColumn - strlen(token.value);
return token;
}
// 识别运算符和分隔符
// ... 类似的处理逻辑
// 文件结束
if (c == EOF) {
token.type = EOF;
strcpy(token.value, "EOF");
}
return token;
}
ungetc 函数。词法分析时经常需要「多读一个字符」来判断 Token 的边界,多读的字符要放回流中,否则会丢失。我刚开始写的时候忘了这步,结果数字后面紧跟着的运算符全丢了,排查了半天。
完整的词法分析器还需要处理字符串、注释、多字符运算符(比如 ==、!=)等。但核心逻辑就是上面这套——逐个字符读取,根据当前字符决定进入哪个识别分支。
总结一下
词法分析是编译器的第一道关卡,它把源代码从字符流变成 Token 流。核心知识点包括:
- Token 的定义和分类
- 用正则表达式描述 Token 模式
- 用 DFA 高效识别 Token
- 手工实现词法分析器的技巧
嗯,这些基础打牢了,后面语法分析、语义分析才能顺利进行。记住,词法分析器虽然看起来简单,但它是整个编译器的地基。地基不稳,后面盖的楼再漂亮也没用。
想要更多资料,联系可可