带界面词法分析器源码:DFA状态机实现与排错验证
简介:面向编译原理课程学习者与Java初学者的一套词法分析实践材料,聚焦类C语言词法单元的识别与Token流生成,并配套基于Swing的可视化界面,可在MyEclipse中完整运行。压缩包共5个文件,均为Java源文件,整体约5KB,分别承担词法规则与记号类型定义、字符与文件读取工具、分词逻辑及主程序测试等职责,代码含注释便于对照阅读。已有526人学习,说明其在课程实验与自学场景中具有一定参考价值。读者可据此理解关键字、标识符、常量、运算符等词法单元的划分方式,观察有限状态自动机或规则表驱动的分词过程,并借鉴错误处理与界面展示的实现思路;界面还能直观呈现输入源码后的分词结果与异常提示,便于课程设计、实验报告和前期方案验证,适合作为编译原理词法分析实验的完整参考案例。
1. 词法分析器最容易卡在“跑不起来”:一份带界面的源码解决的问题
编译原理课设里,词法分析是最容易被低估的一环。课本讲正则、NFA、DFA 用掉一整章,真到动手,卡住人的往往不是状态机理论,而是<=被切成<和=、注释里的一个引号把后面二十行全吃掉、界面上点两次按钮结果翻一倍。这份带界面、可直接运行、关键处都写了注释的源文件,解决的正是从理论到能跑起来这段落差:它把词法分析器拆成输入层、扫描层、符号表、输出层四段,外面套一个能粘贴代码、能看 Token 表、能定位错误行的窗口。适合三类人:赶编译原理课程设计的、想给自研小语言配个前端的、以及只想搞明白 DFA 究竟怎么落进一个 for 循环里的。下面按看结构、改参数、接界面、排错、验证的顺序拆。
2. 源码骨架拆解:从字符流到 Token 流的四层结构
拿到一份词法分析器源码,我一般先不看界面,直接找那个把String变成List<Token>的方法。这个方法的结构决定了后面所有改动方不方便。市面上这类课程设计源码写法差异很大,但骨架基本逃不出四层:把源程序整块读进来的输入层、靠双指针推进的扫描层、负责识别关键字和登记标识符的符号表、以及最后产出<类型, 字面量, 位置>三元组的输出层。四层分清楚了,改一个 Token 类型、加一种运算符、换一套界面,都只是动其中一层的事。
2.1 输入层:一次性读成字符数组,不要逐字符读流
最常见的坏味道是用FileReader或InputStreamReader边读边扫。词法分析天然需要任意前瞻:判断0x要知道后两位、判断/*要知道后一位、判断1.2e-3要知道指数部分有没有符号。流式读取要么自己维护缓冲区,要么就只能 peek 一位,遇到十六进制字面量立刻抓瞎。稳妥做法是一次性把文件读成字节数组再解码成字符串,让charAt(i + k)随时可用。
// 输入层:一次性读入 + 统一换行 + 补哨兵 public static String loadSource(Path path) throws IOException { byte[] raw = Files.readAllBytes(path); String src = new String(raw, StandardCharsets.UTF_8); // 去掉 UTF-8 BOM,否则第一个 Token 会变成非法字符 if (!src.isEmpty() && src.charAt(0) == '\uFEFF') { src = src.substring(1); } // \r\n 统一成 \n,行号列号才不会每行多算一列 src = src.replace("\r\n", "\n").replace('\r', '\n'); // 末尾补哨兵,peek 越界时统一返回 \0,状态判断不用再写边界 return src + "\0"; }这段代码有三个参数值得注意。编码写死成UTF-8而不是用平台默认值,因为 Windows 上默认编码经常是 GBK,读 UTF-8 文件时中文注释会变乱码,更麻烦的是乱码字符会占掉列号,报错位置整体偏移。换行统一这一步不做,\r会被当成一个普通空白字符,行号能对、列号全错。哨兵的作用是让peek(1)、peek(2)这类前瞻方法永远不用判断下标越界,代价只是扫描循环的终止条件从i < len变成c != '\0',省下来的是每个状态分支里的边界判断,非常划算。
如果源码里没有这一步,而是直接用new FileReader(file),那基本可以确认它只在纯 ASCII 的测试用例上验证过。改成上面这样,界面上的“打开文件”按钮才有意义。
2.2 扫描层:双指针加最大匹配,回溯点要留清楚
扫描层的核心是双指针:start记住当前 Token 的起始位置,i一路向前推进,直到某个字符不再属于当前 Token,然后回退到最后一个接受位置。这就是所谓最大匹配原则(maximal munch),翻译成人话就是“能多吃就多吃”。<=必须先看两个字符,>>=必须先看三个字符,1.2e-3必须把指数部分一起吃掉。源码里如果只写了单字符匹配再补几个if,那<=、>=、==、!=、++、--、+=这几组一定会有漏网的。
// 扫描层骨架:最大匹配 + 位置记录 public List<Token> scan(String src) { List<Token> out = new ArrayList<>(); int i = 0, line = 1, col = 1; while (src.charAt(i) != '\0') { char c = src.charAt(i); // 1. 跳过空白,但要把行号列号算对 if (c == ' ' || c == '\t') { i++; col++; continue; } if (c == '\n') { i++; line++; col = 1; continue; } int start = i, startLine = line, startCol = col; // 2. 标识符 / 关键字:先按规则吃满,再查关键字表 if (Character.isLetter(c) || c == '_') { while (isLetterOrDigitOrUnderline(src.charAt(i))) { i++; col++; } String lexeme = src.substring(start, i); TokenType type = KEYWORDS.containsKey(lexeme) ? KEYWORDS.get(lexeme) : TokenType.ID; out.add(new Token(type, lexeme, start, startLine, startCol)); continue; } // 3. 数字:整数、实数、十六进制三种分支 if (Character.isDigit(c)) { i++; col++; // 0x / 0b 前缀要先判,否则 0 会被当成一个完整的整数吐出去 if (c == '0' && (src.charAt(i) == 'x' || src.charAt(i) == 'X')) { i++; col++; while (isHex(src.charAt(i))) { i++; col++; } out.add(new Token(TokenType.NUM_HEX, src.substring(start, i), start, startLine, startCol)); continue; } while (Character.isDigit(src.charAt(i))) { i++; col++; } // 小数点:后面必须跟数字,否则 "1..2" 里的点不属于这个数 if (src.charAt(i) == '.' && Character.isDigit(src.charAt(i + 1))) { i++; col++; while (Character.isDigit(src.charAt(i))) { i++; col++; } out.add(new Token(TokenType.NUM_REAL, src.substring(start, i), start, startLine, startCol)); continue; } out.add(new Token(TokenType.NUM_INT, src.substring(start, i), start, startLine, startCol)); continue; } // 4. 运算符:两字符优先,其次单字符 String two = src.substring(i, i + 2); if (TWO_CHAR_OPS.containsKey(two)) { out.add(new Token(TWO_CHAR_OPS.get(two), two, i, line, col)); i += 2; col += 2; continue; } if (ONE_CHAR_OPS.containsKey(c)) { out.add(new Token(ONE_CHAR_OPS.get(c), String.valueOf(c), i, line, col)); i++; col++; continue; } // 5. 认不出来的字符:产出错误 Token,不要抛异常 out.add(new Token(TokenType.ERROR, String.valueOf(c), i, line, col)); i++; col++; } return out; }几个参数上的取舍说清楚。Token里我额外放了offset(绝对字符偏移),行列号给人看,偏移量给界面着色用,后面第 4 章会用到。错误 Token 走返回值而不是throw,是因为界面上用户粘贴一段带中文标点的代码太常见了,抛异常整棵树就断了,什么都没显示;改成产出ERROR类型的 Token,界面上标红那一个字符,其余部分照常分析,体验完全不同。数字分支里0x必须在普通整数之前判断,这是最常见的一个顺序错误,写反了0xFF会输出成NUM_INT(0)加ID(xFF)。小数点判断加了“后面必须跟数字”的条件,是为了让1..2这种写法拆成1、..、2而不是把1.当成实数。
2.3 关键字表与符号表:查的顺序决定结果对不对
关键字识别的正确顺序只有一种:先按标识符的规则把整个词吃满,拿到完整字符串,再去关键字表里查。反过来做,用关键字做前缀匹配,ifx会被切成if和x,int_count会被切成int和_count,这类 bug 在课程设计里非常常见,而且因为测试用例一般只写if (a) b;这种标准写法,根本暴露不出来。
// 关键字表和符号表:一个查、一个记,职责分开 private static final Map<String, TokenType> KEYWORDS = new HashMap<>(); static { KEYWORDS.put("if", TokenType.KW_IF); KEYWORDS.put("else", TokenType.KW_ELSE); KEYWORDS.put("while", TokenType.KW_WHILE); KEYWORDS.put("int", TokenType.KW_INT); KEYWORDS.put("return", TokenType.KW_RETURN); // 关键字表建一次就够,别在扫描循环里反复 put } // 符号表用 LinkedHashMap,保住首次出现顺序,界面上显示更好看 private final Map<String, Symbol> symbolTable = new LinkedHashMap<>(); private Token makeIdent(String lexeme, int offset, int line, int col) { TokenType type = KEYWORDS.getOrDefault(lexeme, TokenType.ID); if (type == TokenType.ID) { symbolTable.computeIfAbsent(lexeme, k -> new Symbol(k, line, col)); // 只登记首次出现的位置 } return new Token(type, lexeme, offset, line, col); }符号表在词法阶段只做“登记”,不做重定义检查。这一点很多源码写得含糊:有人在词法阶段就判断“变量重复定义”并报错,结果int a; { int a; }这种在内层作用域合法的写法被误报。词法阶段拿不到作用域信息,重定义是语义分析的事。不过如果课程设计只做到词法分析为止,顺手在符号表里加个出现次数统计,报告里能多写一段,也算是这份注释源码比较实用的地方。
2.4 输出层:三元组里没有位置信息,界面就是个黑匣子
输出层的设计直接决定界面好不好用。只输出<类型, 字面量>的词法器,界面上只能干巴巴列一列 Token,用户看不出哪个 Token 对应源码哪一段,一个错误也没有定位。加上行号、列号、绝对偏移之后,同一个词法器立刻能干三件事:错误行高亮、点击 Token 表跳转源码、导出 CSV 给后续语法分析器当输入。
| 字段 | 类型 | 用途 | 备注 |
|---|---|---|---|
| type | enum | 类型判断 | 用枚举不用 int 常量,界面显示名字方便 |
| lexeme | String | 原始字面量 | 保留原样,不要提前转成数值 |
| offset | int | 绝对字符偏移 | 界面着色、点击跳转靠它 |
| line | int | 行号,1 起 | 报错定位 |
| column | int | 列号,1 起 | 报错定位,按字符算不按字节算 |
字面量保留原样这点值得强调。有些源码在扫描阶段就把"123"转成int 123存进 Token,结果后面语法分析要输出“第 3 行 123 处错误”时,前导零、十六进制前缀全丢了。词法阶段只负责切分和分类,值转换是后续阶段的事,这条边界守住了,这份源码才能当语法分析器的前端继续用。
3. 状态机选型:手写 switch 还是表驱动 DFA,参数怎么定
词法分析器的理论模型是 DFA,但落到代码上有两条路:一条是手写switch/if-else状态机,一条是把转移关系抽成二维表查表驱动。这两种写法在课程设计里都常见,选哪个不看先进不先进,看你要拿它干什么。要答辩讲清楚原理、要逐步调试,手写更直观;要加 Token 类型、要给别人接手、要在报告里画状态转移矩阵,表驱动更省事。
3.1 手写 switch 的边界在哪
手写状态机的典型结构是一个state变量加一个while,每个case里判断当前字符决定下一个状态。好处是每一步都能打断点,注释也好写,“这里遇到/要看下一位是不是*”这种话直接写在 case 里。麻烦的地方在两处:一是状态一多,switch会长到几百行,加一个新 Token 要改好几处;二是字符串状态和注释状态会互相干扰,比如注释里出现引号、字符串里出现//,如果状态跳转写漏了,就会一路吞到文件尾。
我见过最典型的手写翻车是注释扫描用递归实现:遇到/*就递归调用一次扫描函数直到遇到*/。单层注释没问题,一旦代码里出现/* a /* b */,递归返回后外层还在等*/,后面的几十行全被当成注释。改成状态机里的一个IN_BLOCK_COMMENT状态、用一个标志位记录“是否已见过*”,问题立刻消失。
3.2 表驱动的转移表怎么建
表驱动的核心是把“当前状态 + 当前字符类 → 下一状态”这张表显式写出来,扫描循环本身变成十行代码。字符不用一个一个字列,先归类,几十个字符压成七八个类,表就好维护了。
// 字符分类:把 60 多个字符压成 8 类 static int charClass(char c) { if (c == '\0') return 7; // EOF if (Character.isLetter(c) || c == '_') return 0; // 字母 if (Character.isDigit(c)) return 1; // 数字 if (c == '.') return 2; // 小数点 if (c == '/' || c == '*') return 3; // 注释相关 if (c == '"' || c == '\'') return 4; // 引号 if (c == '+' || c == '-' || c == '=' || c == '<' || c == '>' || c == '!') return 5; // 运算符 if (c == ' ' || c == '\t' || c == '\n') return 6; // 空白 return 8; // 其它 } // 转移表:行是状态,列是字符类,-1 表示该状态不接受这个字符 // 0=START 1=ID 2=INT 3=REAL 4=DOT 5=OP 6=COMMENT_SLASH 7=BLOCK 8=STRING static final int[][] TRANS = { // 字母 数字 . /* 引号 运算符 空白 EOF 其它 /* 0 */ { 1, 2, 4, 6, 8, 5, 0, -1, -1 }, /* 1 */ { 1, 1, -1, -1, -1, -1, -1, -1, -1 }, /* 2 */ { -1, 2, 4, -1, -1, -1, -1, -1, -1 }, /* 3 */ { -1, 3, -1, -1, -1, -1, -1, -1, -1 }, /* 4 */ { -1, 3, -1, -1, -1, -1, -1, -1, -1 }, /* 5 */ { -1, -1, -1, -1, -1, -1, -1, -1, -1 }, /* 6 */ { -1, -1, -1, 7, -1, -1, -1, -1, -1 }, /* 7 */ { -1, -1, -1, 7, -1, -1, -1, -1, -1 }, /* 8 */ { 8, 8, 8, 8, 8, 8, 8, -1, 8 }, }; // 查表驱动:循环体固定,加 Token 只改表不改逻辑 int state = 0, i = start; while (state >= 0) { state = TRANS[state][charClass(src.charAt(i))]; if (state >= 0) i++; } // i 现在停在第一个不属于当前 Token 的字符上,回退一位 i--;这张表读起来比一堆if直观:第 0 行是起始状态,遇到字母进 1、数字进 2、小数点进 4、斜杠进 6、引号进 8,遇到 EOF 直接返回 -1 结束。第 1 行全是自环加一个 -1,含义就是“标识符可以一直吃字母和数字,吃到别的就停”。第 7 行的块注释状态里,除了/和 EOF 之外所有字符都自环,这就是“块注释里什么都吃,包括引号”的正确写法。第 8 行的字符串状态里,空白字符也自环,意思是允许字符串跨行,这一点按语言规范定,不允许跨行就把空白列改成 -1。
需要提醒的是i--这一步。查表循环退出时i已经指向第一个不属于当前 Token 的字符,必须回退,否则下一个 Token 会从中间开始,整条流全错位。这是表驱动最容易漏的一行,漏了以后现象很怪:单个 Token 看起来都对,连起来就少字符。
3.3 数值常量和注释这两块要单独加状态
纯表驱动的干净版本只适合处理标识符、整数、运算符。数值常量和注释会让状态数暴涨:十六进制要区分0x之后、二进制要区分0b之后、实数的指数部分要区分“有没有 e”“e 后面有没有符号”“符号后面有没有数字”。我一般会把这两块从主表里拆出去,写成两个独立的小函数,主表只负责“看到数字就调用scanNumber”“看到斜杠就调用scanComment”。这样主表的规模控制在十行以内,数值和注释的复杂逻辑各在各的函数里,改一个不影响另一个。
注释这块,行注释和块注释要分开处理,且都必须在遇到 EOF 时返回带错误标记的 Token,错误位置指向注释起始的/*或者//,而不是文件末尾。指向文件末尾的报错等于没报错,用户得自己从头找。
3.4 几个必须提前定死的参数
这些参数不写在代码里就会在答辩时被问到,提前定好省事。
| 参数 | 常见取值 | 影响 |
|---|---|---|
| 标识符最大长度 | 不限制 / 32 / 255 | 超长时截断还是报错,截断会让符号表出现同名项 |
| 是否区分大小写 | 区分 | 不区分时关键字表要统一转小写再查 |
| 标识符首字符 | 字母或下划线 | 允许$要单独加,Java 风格 |
| 注释是否可嵌套 | 否 | 可嵌套要维护深度计数器,不能只用一个状态 |
| 字符串是否可跨行 | 否 | 可跨行时换行符在字符串状态里也要自环 |
| 数值进制 | 十/十六/二/八 | 八进制用前导 0 表示时和十进制 0 冲突,要明确规则 |
最大标识符长度这一项,我建议直接不限制。截断听起来省内存,实际后果是veryLongNameA和veryLongNameB在符号表里变成同一个条目,后面语法、语义分析全乱,而且这个 bug 只在长变量名出现时才触发,测试用例基本覆盖不到。
4. 界面与词法器的接缝:三个地方不做处理必然出问题
界面这部分,课程设计源码里最常见的写法是把所有逻辑塞进按钮的actionPerformed里,读文件、扫描、刷表格一气呵成。小文件跑起来没问题,一粘贴几百行就假死,因为扫描跑在事件分发线程上,界面根本没机会重绘。接缝处理好了,词法器本身一行都不用改。
4.1 事件线程和解析线程分离
Swing 的所有界面更新都必须在事件分发线程(EDT)上做,但耗时计算不能放在 EDT 上。正确做法是SwingWorker:doInBackground里跑scan,done里更新表格模型。C# WinForms 那边对应的是BackgroundWorker或者async/await配Invoke,思路完全一样:计算在后台,刷界面回主线程。
// 点击"分析"按钮后做的事:后台扫描,主线程刷新 new SwingWorker<List<Token>, Void>() { @Override protected List<Token> doInBackground() { // 这里拿到的 sourceArea.getText() 是快照,不会随用户输入变化 String src = sourceArea.getText() + "\0"; return lexer.scan(src); // 纯计算,不碰任何 Swing 组件 } @Override protected void done() { try { List<Token> tokens = get(); // 后台异常会在这里抛出 tableModel.setRowCount(0); // 必须先清空,否则结果会不断追加 for (Token t : tokens) { tableModel.addRow(new Object[]{ t.line, t.column, t.type, t.lexeme}); } statusLabel.setText("共 " + tokens.size() + " 个 Token,错误 " + tokens.stream().filter(t -> t.type == TokenType.ERROR).count() + " 个"); highlightErrors(tokens); // 着色必须在 EDT 上做 } catch (Exception ex) { statusLabel.setText("分析失败:" + ex.getMessage()); } } }.execute();三个细节。tableModel.setRowCount(0)不能省,省了就是“点两次按钮结果翻倍”这个经典现象。get()必须调用,因为doInBackground里抛的异常会被SwingWorker吞掉,不调get()界面上什么都不显示,跟没反应一样。扫描用的源码要在后台任务开始时拿快照,不能在后台线程里调sourceArea.getText(),Swing 组件不是线程安全的。
4.2 Token 表和源码着色联动
源码着色是这份资源里比较实用的部分,也是很多同类源码缺失的。做法是用JTextPane而不是JTextArea,JTextArea只能整体设字体颜色,没法部分着色。JTextPane背后的StyledDocument支持按区间加样式,只要有每个 Token 的绝对偏移量就能上色,这也是第 2 章里坚持在 Token 中存offset的原因。
// 按 Token 类型着色:错误标红,关键字标蓝,注释标灰 private void highlightErrors(List<Token> tokens) { StyledDocument doc = sourcePane.getStyledDocument(); doc.setCharacterAttributes(0, doc.getLength(), sourcePane.getStyle("default"), true); // 先全部还原 for (Token t : tokens) { if (t.type == TokenType.ERROR) { doc.setCharacterAttributes(t.offset, t.lexeme.length(), sourcePane.getStyle("error"), true); // 红底 } else if (t.type.name().startsWith("KW_")) { doc.setCharacterAttributes(t.offset, t.lexeme.length(), sourcePane.getStyle("keyword"), true); // 蓝色 } } }setCharacterAttributes的第三个参数是replace,传true表示覆盖原有属性,传false会叠加。每次重算前要把全文档还原成默认样式,否则上一次分析的红底会一直留着,改完代码重新分析还是红的,像个解不开的玄学问题。样式对象在初始化时用StyleConstants.setForeground建一次,别在循环里反复建。
4.3 文件导入、编码和换行符
打开文件这一步,JFileChooser加FileNameExtensionFilter过滤.txt、.c、.java这类扩展名就够。真正容易翻车的是编码。用户手里的测试文件可能是 GBK 保存的,直接按 UTF-8 读会得到一堆替换字符�,每个替换字符都会被词法器当成非法 Token,界面上满屏红。稳妥点是在打开失败或者错误 Token 比例异常高时,用CharsetDecoder试一次 GBK,两个结果里错误 Token 少的那个胜出。这个判断逻辑放在输入层,界面只负责显示最终用了哪个编码。
换行符的问题上一章说过,统一成\n之后,JTextPane里显示的位置和 Token 里的 offset 才能对上。如果界面用的是JTextArea并且没有统一换行,Windows 文件每行末尾的\r\n占两个字符,offset 就会和显示位置错开一位,越往下错得越多。
4.4 导出和回放
Token 表能导出成 CSV,这个功能看起来不起眼,实际是接后续语法分析器的关键。语法分析作业通常要求读入 Token 序列,如果前一个阶段的输出只能看不能导,后面就得手抄。导出的字段就用第 2 章那张表里的五列,类型列导出枚举名而不是数字,接收方解析成本最低。
回放是另一个小技巧:把 Token 列表反序列化回一段规范化的源码,比如每个 Token 用空格分隔输出,再喂给词法器扫一遍,两次结果一致就说明扫描过程没有上下文依赖。这个自检手段我在第 6 章会展开。
5. 避坑排查:五个让输出错位的真实原因
下面这五条是我在拆这类词法分析器源码时反复遇到的,现象都很像“程序跑起来了但结果不对”,实际原因分布在完全不同的层。按现象、原因、解决的顺序记下来,比通读代码快得多。
5.1 关键字全变成 ID
现象:if、while、int在 Token 表里都显示成ID,符号表里还能看到if这个条目。
原因:扫描标识符时没有查关键字表,或者关键字表初始化放在了扫描之后。还有一种情况是关键字表用HashMap但存的时候键带了空格,比如put("if ", ...),永远查不中。
解决:确认标识符分支里走的是“先吃满、再查表”的顺序,并在static块里初始化关键字表。加一个单元测试,输入if ifx int_1,期望输出KW_IF、ID、ID,这一步能同时验证“关键字识别”和“前缀不误切”两件事。
5.2 注释里的引号把后面几十行全吃掉
现象:从某一行开始,后面所有代码都变成了注释颜色,Token 表里只剩注释。
原因:块注释扫描用递归实现,遇到嵌套的/*时内层返回后外层还在等*/;或者注释扫描里没有屏蔽字符串状态,注释中出现"就跳进了字符串状态,一路吃到文件尾。
解决:注释扫描改成独立的循环或者独立状态,注释内部除了*/什么都不认。同时在“未闭合注释”这条路径上产出一个ERRORToken,位置指向起始/*。
5.3 行号对、列号从某一行开始整体偏移
现象:第 1 行报错位置准确,第 2 行开始列号多 1 或者少 1。
原因:换行处理只认\n,\r\n里的\r被当成普通空白字符多算了一列;或者中文注释里的字符按字节算列号,一个汉字算成 3 列。
解决:输入层统一换行符,列号一律按char计数,不按字节。这两步都在第 2 章的loadSource里做完了,改完重新跑一遍带中文注释的用例就能验证。
5.4<=被切成<和=
现象:Token 表里出现连续的<和=,语法分析阶段报“意外的 =”。
原因:运算符匹配只做了单字符查表,没做两字符前瞻;或者做了前瞻但peek在文件末尾越界返回了默认值,导致最后一个字符被误判成两字符运算符的一部分。
解决:运算符匹配统一“先看两位、再看一位”,peek越界返回\0(哨兵的价值就在这里)。补一组用例覆盖<= >= == != ++ -- += -= << >>,一条一条对着看。
5.5 点两次分析结果翻倍,或者界面直接假死
现象:连点两次“分析”,Token 表行数变成两倍;粘贴大文件后窗口白屏几秒。
原因:刷新表格前没有setRowCount(0),一直在追加;扫描跑在 EDT 上,界面没机会重绘。
解决:刷新前清空模型,扫描放进SwingWorker。另外注意别在JTextArea上用setText触发的DocumentListener里再调setText,那会形成事件循环,表现为输入一个字符卡一下。
6. 验证与进阶:用正则做差分测试,把词法器固定住
词法分析器最难受的地方是“看起来对了”。随便写几行代码扫一遍,Token 表有内容,颜色也上了,就以为完事了,结果换个测试用例就崩。我后来固定用一个笨办法:用 Python 的re写一份“标准答案”,跟 Java 词法器的输出做逐条比对。正则引擎是现成的、经过验证的实现,用它对同一份输入跑一遍,两个结果不一致的地方就是 bug 所在。这就是差分测试的思路,写起来不到三十行。
import re, csv, sys # 顺序很重要:注释、字符串必须排在运算符前面,否则 // 会先被当成除号 RULES = [ ("COMMENT", r"//[^\n]*|/\*[\s\S]*?\*/"), ("STRING", r'"(?:[^"\\\n]|\\.)*"'), ("REAL", r"\d+\.\d+(?:[eE][+-]?\d+)?"), ("HEX", r"0[xX][0-9a-fA-F]+"), ("INT", r"\d+"), ("ID", r"[A-Za-z_]\w*"), ("OP", r"<=|>=|==|!=|\+\+|--|\+=|-=|\+|-|\*|/|=|<|>"), ("PUNC", r"[(){}\[\];,]"), ("WS", r"\s+"), ] def scan(src): out, i = [], 0 while i < len(src): for kind, pat in RULES: m = re.match(pat, src[i:]) if m: if kind != "WS": out.append((kind, m.group(0))) i += m.end() break else: out.append(("ERROR", src[i])) # 一条规则都不匹配 i += 1 return out def compare(java_csv, source_path): src = open(source_path, encoding="utf-8").read() expect = scan(src) actual = [(r["type"], r["lexeme"]) for r in csv.DictReader(open(java_csv, encoding="utf-8"))] # 注释在两边都要先过滤掉,否则必然对不上 actual = [t for t in actual if t[0] != "COMMENT"] for idx, (e, a) in enumerate(zip(expect, actual)): if e[1] != a[1]: print(f"第 {idx} 个 Token 不一致:期望 {e},实际 {a}") return False return len(expect) == len(actual) if __name__ == "__main__": ok = compare(sys.argv[1], sys.argv[2]) print("一致" if ok else "存在差异")这段脚本的关键在RULES的顺序。正则交替的语义是“第一个匹配成功的胜出”,所以注释和字符串必须排在运算符前面,否则//会先被OP里的/吃掉,后面整行都错。而 Java 那边手写词法器里,注释的优先级是靠状态判断保证的——两条实现路径用完全不同的机制表达同一个优先级,一旦结果对不上,八成就是某一边的优先级写错了。这比盯着代码看有效得多。
跑通之后可以做几件事把它变成日常习惯。一是准备一组固定的回归用例,覆盖空文件、只有注释、未闭合字符串、1..2、0x后面没数字、超长标识符、中文注释,每改一次词法器就把这组跑一遍。二是统计 Token 数量做交叉验证:把 Token 的lexeme全部拼起来(丢弃空白和注释),长度应该等于源码去掉空白和注释后的长度,短了就是漏字符,长了就是重复扫描。这一条能抓住 90% 的“指针没回退”类 bug。
还有一个进阶玩法,把 Token 流当成一种中间表示来用。写一个format(List<Token>)把 Token 序列按固定规则重新拼成源码,再喂回词法器扫一遍,两次的 Token 序列必须完全一致。这个自检叫幂等性验证,一旦通过,说明词法器没有隐藏的上下文依赖,后续接语法分析器时不会出现“单独扫都对、连着扫就错”的怪事。
血泪经验是:我以前改词法器从来不跑回归,全凭“看着像对的”,结果有一次为了支持十六进制,把数字分支的判断顺序调了一下,0x1F是对了,0后面跟标识符的场景全错,等到演示前一天才发现。从那以后我每次动扫描循环,都强制先跑一遍那组回归用例,再看界面。希望这套思路帮到你。
本文还有配套的精品资源,点击获取