4、词法分析器手工实现:手工编写词法分析器、状态转换图、代码实现技巧
好,咱们今天来聊聊手工写词法分析器这件事。
说实话,很多人一听到「词法分析」就觉得高大上,以为非得用 Lex、Flex 这种工具才行。其实不然。我个人的经验是,对于大部分前端项目,手工写一个词法分析器反而更灵活、更好控制。你想想看,一个简单的模板引擎、一个配置文件解析器,犯得着上重型工具吗?
4.1 状态转换图:词法分析器的「地图」
在动手写代码之前,我强烈建议你先画一张图——状态转换图。说白了,它就是词法分析器的「导航地图」。没有这张图,你写代码就像蒙着眼睛走路。
什么是状态转换图?
- 状态:词法分析器当前处于什么「模式」下。比如:空闲状态、读取数字状态、读取字符串状态、注释状态等。
- 转换:当读入一个字符时,从当前状态跳转到另一个状态。
- 动作:到达某个状态后,执行什么操作(比如记录 Token 的开始位置、生成 Token 等)。
举个例子,解析一个简单的算术表达式 3 + 5 * 2,状态转换图大致是这样的:
核心思路: 每个状态只关心「当前字符是什么」,然后决定下一步去哪。不要试图在一个状态里处理所有逻辑。
下面是我用 SVG 画的一张状态转换图,展示了一个简易词法分析器的核心逻辑:
这张图看着简单,但它是整个词法分析器的骨架。我在实际项目中,每次写新解析器之前,都会先在白板上画这么一张图。画清楚了,代码就是照着图「翻译」一遍的事。
4.2 手工实现的核心技巧
好,图有了,咱们开始写代码。手工实现词法分析器,我总结了几个关键技巧:
4.2.1 用「指针 + 状态」驱动
别搞什么复杂的递归下降,手工词法分析器最朴素也最有效的模式就是:一个指针从头扫到尾,一个状态变量记录当前模式。
// 伪代码,展示核心逻辑
function tokenize(input) {
let pos = 0;
let state = 'START';
let tokens = [];
while (pos < input.length) {
let ch = input[pos];
switch (state) {
case 'START':
if (isDigit(ch)) {
state = 'NUMBER';
// 记录起始位置
start = pos;
} else if (isLetter(ch)) {
state = 'IDENTIFIER';
start = pos;
} else if (ch === '"') {
state = 'STRING';
start = pos;
} else {
// 处理单字符 Token
tokens.push({ type: ch, value: ch });
pos++;
}
break;
case 'NUMBER':
if (isDigit(ch)) {
pos++; // 继续读取
} else {
// 生成 Token
tokens.push({ type: 'NUMBER', value: input.slice(start, pos) });
state = 'START';
}
break;
// ... 其他状态类似
}
}
return tokens;
}
我的经验: 状态切换时,一定要处理好「回退」逻辑。比如数字读完后,当前字符不是数字,但可能是运算符。这时候不能直接 pos++,要让它回到 START 状态重新判断。我曾经在这上面栽过跟头——少回退一个字符,整个解析就乱了。
4.2.2 善用「前瞻字符」
有些场景下,只看当前字符不够。比如解析 >= 和 >,你得看下一个字符是不是 =。
我习惯用一个 peek() 函数:
function peek(pos, input) {
return pos + 1 < input.length ? input[pos + 1] : null;
}
这样在遇到 > 时,看一眼 peek() 是不是 =,是的话就一起消费掉。否则只消费 >。
4.2.3 错误恢复要「温柔」
词法分析器遇到非法字符怎么办?直接抛异常?我建议不要。更好的做法是:记录错误,然后跳过这个字符继续解析。
case 'START':
if (isValid(ch)) {
// 正常处理
} else {
// 记录错误,但继续
errors.push(`第 ${pos} 位置出现非法字符: ${ch}`);
pos++; // 跳过
}
break;
注意: 错误恢复不是万能的。如果错误太多,解析结果可能毫无意义。我一般会设置一个错误数量上限,超过上限就停止解析。
4.3 实战:一个简易 JSON 词法分析器
光说不练假把式。咱们手写一个解析 JSON 的简易词法分析器。JSON 的 Token 类型不多:字符串、数字、布尔值、null、以及各种符号。
function jsonTokenizer(input) {
let pos = 0;
let tokens = [];
let errors = [];
while (pos < input.length) {
let ch = input[pos];
// 跳过空白
if (ch === ' ' || ch === '\n' || ch === '\t') {
pos++;
continue;
}
// 字符串
if (ch === '"') {
let start = pos;
pos++; // 跳过开头的引号
while (pos < input.length && input[pos] !== '"') {
if (input[pos] === '\\') pos++; // 转义字符
pos++;
}
if (pos >= input.length) {
errors.push('字符串未闭合');
break;
}
pos++; // 跳过结尾的引号
tokens.push({ type: 'STRING', value: input.slice(start + 1, pos - 1) });
continue;
}
// 数字
if (ch >= '0' && ch <= '9' || ch === '-') {
let start = pos;
if (ch === '-') pos++;
while (pos < input.length && input[pos] >= '0' && input[pos] <= '9') pos++;
if (pos < input.length && input[pos] === '.') {
pos++;
while (pos < input.length && input[pos] >= '0' && input[pos] <= '9') pos++;
}
tokens.push({ type: 'NUMBER', value: parseFloat(input.slice(start, pos)) });
continue;
}
// 关键字 true/false/null
if (ch === 't' && input.slice(pos, pos + 4) === 'true') {
tokens.push({ type: 'BOOLEAN', value: true });
pos += 4;
continue;
}
if (ch === 'f' && input.slice(pos, pos + 5) === 'false') {
tokens.push({ type: 'BOOLEAN', value: false });
pos += 5;
continue;
}
if (ch === 'n' && input.slice(pos, pos + 4) === 'null') {
tokens.push({ type: 'NULL', value: null });
pos += 4;
continue;
}
// 符号
if ('{}[],:'.includes(ch)) {
tokens.push({ type: 'SYMBOL', value: ch });
pos++;
continue;
}
// 非法字符
errors.push(`位置 ${pos} 发现非法字符: ${ch}`);
pos++;
}
return { tokens, errors };
}
关键点: 注意看代码中每个分支最后都有 continue 或 pos++。这是为了防止死循环。我见过有人忘记写 pos++,结果程序卡在同一个字符上无限循环——嗯,调试起来很酸爽。
4.4 代码实现技巧总结
最后,我把手工实现词法分析器的技巧整理成一张表,方便你对照:
| 技巧 | 说明 | 我的建议 |
|---|---|---|
| 状态驱动 | 用状态变量控制当前解析模式 | 状态不要太多,5-8个以内最佳 |
| 指针推进 | 一个 pos 变量从头扫到尾 | 每次只推进一个字符,除非确定是复合 Token |
| 前瞻字符 | 用 peek() 看下一个字符 | 不要滥用,只在必要时使用 |
| 错误恢复 | 记录错误,跳过非法字符 | 设置错误上限,避免无限循环 |
| 先画图再写码 | 状态转换图是代码的蓝图 | 画图花10分钟,省下2小时调试时间 |
嗯,手工写词法分析器其实没那么玄乎。核心就是:画好状态图,管好指针,处理好边界情况。你按这个思路走,基本不会出大问题。
我曾经在一个模板引擎项目里,用这套方法手写了一个词法分析器,处理了变量、循环、条件判断等十几种 Token,总共也就三百多行代码。跑起来比用工具生成的还快——因为手工写的可以针对特定场景做优化。
所以别怕手工实现。它让你对解析过程有完全的控制权,出了问题也知道去哪找。这才是真正的「硬核」能力。