编译器课程最容易让人产生一种错觉:只要词法分析器能把字符串切成 Token,后面的工作就是把几个类继续补齐。真正开始写语法树、错误恢复和生成阶段以后,才会发现每一层都在依赖前一层没有说清楚的边界。
公开仓库里的提交很短,却能看出一个学生项目经常遇到的节奏:先补模块和导入,再处理构建文件里的重复符号,最后清掉临时的 dSYM 文件。代码本身可能已经能运行,构建系统却仍然会因为一个符号重复或一个残留产物停下来。它让我重新认识了“能编译”与“可维护”之间的距离。
词法分析的输出应该尽量稳定。Token 需要携带类型、原始文本和位置信息,错误也要指向输入中的具体位置。只返回一个字符串数组,后面的语法分析器会被迫猜测空格、换行和错误在哪里。位置一旦丢失,后面的错误提示就只能说“解析失败”,用户很难知道该改哪一行。
语法树则要求我们把文法结构真正写下来。一个表达式中的优先级、结合性和括号,不能靠生成阶段再补救。树节点如果只保存“看起来能算”的值,后续做类型检查或优化时就会发现信息已经丢了。课程作业可以简化语言,但简化应该写在文法里,而不是藏在一堆条件分支里。
构建问题同样是一种工程问题。重复符号通常来自源文件被重复加入、头文件定义没有收敛或构建脚本对平台做了错误假设。把构建命令放进 README、固定依赖和清理临时文件,都会让下一个人更容易复现。它们没有语法树那么有成就感,却是项目能不能交付的底座。
这次回看让我觉得,编译原理并不只是把一串字符变成另一串字符。它训练的是“每一步都把输入变得更明确”的习惯:先确认 Token,再确认结构,再确认含义,最后才生成结果。调试时也可以沿着同一条路退回去,找到第一处变得含糊的地方。
课程项目会结束,代码却可能在仓库里继续存在。把边界、构建和失败情况写清楚,至少能让它在几年后重新打开时,不需要从一团报错开始猜。
我会把错误恢复单独列成测试,而不是只测一条正确输入。缺少括号、未知字符和嵌套结构都应该有稳定的错误位置。编译器项目的边界越早被测试,后面的语法树和代码生成越不需要靠猜。
编译器练习还让我意识到,错误信息本身就是接口。词法阶段应该报告字符位置,语法阶段应该说明期待的结构,后续阶段则要保留足够的上下文。只把所有错误压成“解析失败”,会让前面的工作都失去价值。
测试也不应只围绕一条正确输入。空文件、非法字符、嵌套结构和较长的表达式会把不同阶段的边界暴露出来。每次发现一个错误,就补一份最小样例,久而久之,测试集会比最初的语法说明更能代表项目目前真正支持的范围。