词法分析基础:编译器的第一道关卡

说实话,很多初学者觉得词法分析很简单,不就是把代码切分成单词吗?嗯,表面上看确实是这样。但我在做编译器的时候,发现词法分析器如果写得不好,后面整个编译流程都会跟着遭殃。

今天我们就来聊聊词法分析的基础知识。我会结合自己踩过的坑,帮你把这个环节吃透。

词法分析到底在干什么?

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

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 结构体里加上行号和列号。这样报错时能精确告诉用户「第几行第几列出错了」,用户体验会好很多。

正则表达式基础

词法分析离不开正则表达式。为什么?因为我们需要用正则来描述 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 的转换流程:

正则表达式 → NFA → DFA 转换流程 正则表达式 Thompson 构造法 NFA 子集构造法 DFA 最小化 最小化 DFA 最终得到的最小化 DFA,就是词法分析器的核心引擎
核心要点: DFA 的每个状态代表一个「识别进度」。比如识别关键字「if」,需要经过 i → f 两个字符,对应 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 的边界,多读的字符要放回流中,否则会丢失。我刚开始写的时候忘了这步,结果数字后面紧跟着的运算符全丢了,排查了半天。

完整的词法分析器还需要处理字符串、注释、多字符运算符(比如 ==、!=)等。但核心逻辑就是上面这套——逐个字符读取,根据当前字符决定进入哪个识别分支。

避坑指南: 我曾经在处理多字符运算符时,写了一个很长的 if-else 链。后来发现维护起来特别痛苦。建议你用一个查找表(lookup table)来管理运算符的匹配规则,代码会清晰很多。

总结一下

词法分析是编译器的第一道关卡,它把源代码从字符流变成 Token 流。核心知识点包括:

  • Token 的定义和分类
  • 用正则表达式描述 Token 模式
  • 用 DFA 高效识别 Token
  • 手工实现词法分析器的技巧

嗯,这些基础打牢了,后面语法分析、语义分析才能顺利进行。记住,词法分析器虽然看起来简单,但它是整个编译器的地基。地基不稳,后面盖的楼再漂亮也没用。

想要更多资料,联系可可