2. 手工构建词法分析器:状态转移图实现,关键字与标识符识别

好,咱们今天来聊聊词法分析器的手工构建。说实话,很多教材一上来就讲 Lex 或 Flex 这种自动生成工具,确实方便。但我个人觉得,如果你没亲手画过状态转移图,没手写过识别逻辑,你对词法分析的理解始终隔着一层纱。

我当年刚入行时,就踩过这个坑。项目里用工具生成了一个词法分析器,结果遇到一个诡异的 bug——关键字被识别成了标识符。查了半天,才发现是工具生成的 DFA 状态合并出了问题。从那以后,我坚持手工构建核心逻辑。你想想看,自己画的状态图,自己写的代码,哪里容易出问题心里门儿清。

2.1 状态转移图:词法分析器的骨架

状态转移图,说白了就是一张“地图”。它告诉你:当前在哪个状态,读入一个字符后,下一步该去哪。

举个例子,我们要识别一个简单的标识符(比如变量名)。规则是:以字母或下划线开头,后面跟字母、数字或下划线。对应的状态图长这样:

状态0(起始) --字母/下划线--> 状态1(标识符中)
状态1(标识符中) --字母/数字/下划线--> 状态1(标识符中)
状态1(标识符中) --其他字符--> 状态2(接受/结束)

嗯,这里要注意:状态2其实是一个“伪状态”,它表示我们已经读到了一个完整的标识符,需要把当前字符回退回去,交给下一轮分析。

我建议你画图时,用圆圈表示状态,用箭头表示转移,箭头上标注触发字符。这样一目了然。我在项目中习惯用 Graphviz 画,但手绘在白板上也完全够用。

2.2 关键字与标识符:一对“双胞胎”

关键字和标识符长得太像了。比如 if,它既可以是关键字,也可以是变量名(虽然大多数语言禁止)。怎么区分?

核心思路就一句话:先识别,后查表

具体来说:

  1. 用同一套状态图识别出“单词”
  2. 识别完成后,去关键字哈希表里查一下
  3. 如果在表中,就是关键字;否则就是标识符

我曾经见过有人试图在状态图里直接区分关键字,比如为 if 单独画一条路径。结果关键字一多,状态图变成了一团乱麻。千万别这么干。

我的小技巧: 关键字表用哈希集合(HashSet)实现,查找是 O(1) 的。别用线性查找,否则词法分析会成为性能瓶颈。

2.3 手工实现:从状态图到代码

好,理论说完了,咱们直接上代码。下面是一个极简的词法分析器核心逻辑,只处理标识符和关键字。

// 伪代码,但逻辑完整
int state = 0;
char ch = nextChar();
StringBuilder token = new StringBuilder();

while (ch != EOF) {
    switch (state) {
        case 0: // 起始状态
            if (isLetter(ch) || ch == '_') {
                token.append(ch);
                state = 1;
                ch = nextChar();
            } else {
                // 处理其他 token(数字、运算符等)
            }
            break;
        case 1: // 标识符中
            if (isLetterOrDigit(ch) || ch == '_') {
                token.append(ch);
                ch = nextChar();
            } else {
                // 识别完成,查关键字表
                String word = token.toString();
                if (keywordSet.contains(word)) {
                    return new Token(TokenType.KEYWORD, word);
                } else {
                    return new Token(TokenType.IDENTIFIER, word);
                }
                // 注意:ch 要回退
                state = 0;
                token = new StringBuilder();
            }
            break;
    }
}

这段代码看着简单,但有几个坑:

  • 字符回退:识别完一个 token 后,当前字符不属于这个 token,必须回退。我习惯用一个 unreadChar() 函数处理。
  • 空串检查:如果 token 是空串,说明状态图有 bug,直接报错。
  • 边界情况:比如文件末尾刚好是一个标识符,要能正确处理。
我曾经踩过的坑: 有一次我忘了回退字符,结果下一个 token 的第一个字符丢了。调试了一下午才发现。从那以后,我在每个接受状态后面都加了一行注释:// 别忘了回退!

2.4 状态转移表:另一种实现方式

除了用 switch-case,你还可以用状态转移表。说白了,就是一张二维数组:行是当前状态,列是输入字符类别,值是下一个状态。

当前状态 字母/下划线 数字 其他
0(起始) 1 错误 错误
1(标识符中) 1 1 接受

用表的好处是:逻辑统一,容易扩展。加一个新 token 类型,只需要加几行表项。坏处是:表大了以后,维护起来有点费劲。

我个人习惯:小项目用 switch-case,大项目用状态转移表。没有绝对的好坏,看场景。

2.5 实战建议:从零开始搭一个

如果你真想学会手工构建词法分析器,我建议你按这个步骤来:

  1. 先画图:在白板上画出所有 token 的状态转移图。别急着写代码。
  2. 走查一遍:用手模拟几个输入串,看看状态怎么跳转。比如输入 if123,看看能不能正确识别为标识符。
  3. 写核心逻辑:只写标识符和关键字的识别,其他 token 先放一放。
  4. 加测试:写几个测试用例,包括正常情况、边界情况、错误情况。
  5. 逐步扩展:加上数字、运算符、分隔符等。

核心要点总结:

  • 状态转移图是词法分析器的骨架,一定要先画清楚
  • 关键字和标识符用同一套逻辑识别,最后查表区分
  • 字符回退是常见 bug 来源,务必小心
  • switch-case 和状态转移表各有优劣,按需选择

嗯,这一章的内容就这些。手工构建词法分析器,说白了就是“画图 + 查表 + 回退”这三板斧。你动手试试,遇到问题再回来翻翻这一章,应该能解决大部分疑惑。


专注资料整理