
选题解读与整体设计思路这道题我第一眼看到的时候脑子里只有一个想法这不就是一个带着条件跳转的计算器吗但真正动手写起来才发现要把、、!三种运算组织进一条完整编译链路并且通过 Java SpringBoot Vue 做成一个能演示的前后端分离项目需要考虑的事情远比想象中多。先从题目说起。这是一道典型的编译原理课程设计题目要求实现一个简单条件语句4编译器核心支持大于、!不等于、加法三类运算符。这里的4可以理解成版本号也可以理解成编译器需要打通的四个阶段——词法分析、语法分析、语义分析、代码生成与执行怎么理解不重要重要的是你要让一段源代码真正跑完这条链路最后输出变量值和运算结果。这类题目难在哪里做纯命令行版本其实五六百行 Java 就能搞定但如果加了 SpringBoot 后端和 Vue 前端事情就变成两件第一你要把编译器的各个阶段拆成边界清晰、可复用的模块第二你要把中间产物Token 流、四元式、符号表结构化成 JSON 传给前端展示。后者在某些同学看来是多余的包装但我的实际感受是前端可视化能把抽象概念变成看得见的东西无论是答辩演示还是自己调试体验都比 println 高一个档次。适合看这篇内容的人有两类。第一类是正在做编译原理课设的学生想找一个能直接照抄思路的完整方案第二类是已经工作、想用一个小项目把编译原理和 SpringBoot、Vue 串起来复习的人。我下面所有内容都按能跑通、能讲清、能答辩的标准来写代码思路为主关键代码给全细节部分我会解释为什么这么写。文法设计与核心细节解析目标语言子集与文法定义做编译器第一步不是写代码而是定义这个编译器到底支持什么语法。很多同学一上来就写 Lexer结果 Token 类型设计得乱七八糟后面语法分析的时候才发现表达力不够这是典型的没想清楚就动手。我先把本项目支持的语言子集定义清楚program - statement_list statement_list - statement { statement } statement - assignment_stmt | if_stmt assignment_stmt - id expression ; if_stmt - if ( expression ) statement [ else statement ] expression - simple_expression [ ( | ! ) simple_expression ] simple_expression - term { term } term - id | number我刻意做了几个约束。第一变量不需要提前声明首次出现在赋值语句左侧时就自动登记到符号表默认初始值为 0这样省去了处理int a;这类声明语句的复杂度。第二表达式的结果统一使用整数表示比较运算、!的结果是 1真或 0假这样布尔值可以继续参与后续的加法运算解释执行时不用区分类型。第三if语句支持else但不支持else if链不过从文法设计上看else if完全可以通过嵌套else加if实现并不需要额外改动文法。这里要注意一个隐含设计if语句中的圆括号()是语法层面的标志不是表达式的一部分。我在词法分析阶段把(、)划归为分隔符 Token表达式解析时并不会处理它们真正和表达式相关的运算符只有、!、。这样做的好处是文法简洁递归下降的代码也能少写两个分支。运算符优先级与结合性问题优先级是这类题目最容易翻车的地方我先把结论摆出来的优先级最高和!的优先级相同且低于比较运算按从左到右的顺序结合。按照这个优先级设计文法必须是两级表达式结构expression这一层处理比较运算simple_expression这一层处理加法。有一个常见的反例是某些同学偷懒写成expr - expr expr | expr expr | expr ! expr | id | number。这完全是灾难。首先这是左递归文法递归下降解析器直接陷入无限递归其次这样写根本没有优先级概念1 2 3会被解析成各种奇怪结构。必须通过文法分层把优先级显式表达出来这是编译原理教科书反复强调的基本功。比较运算的从左到右结合体现在expression的解析过程里private String parseExpression() { String left parseSimpleExpression(); while (currentTokenIs() || currentTokenIs(!)) { String op currentTokenValue(); consume(); String right parseSimpleExpression(); String temp newTemp(); genQuad(op, left, right, temp); left temp; } return left; }注意我这里是两个运算符共用同一个while循环也就是说a b ! c这种链式比较是合法的它会生成两条四元式第一条计算a b的结果存入临时变量第二条拿这个临时变量和c做!比较。实际的高级语言里比较运算符一般不这样链式使用但作为课程设计这样处理逻辑最简单也能展示临时变量传递数据的机制。Token 定义与词法分析细节词法分析器的 Token 类型我设计了五类IDENTIFIER、NUMBER、OPERATOR、DELIMITER、KEYWORD。if和else作为关键字单独处理剩下的、、!是运算符、;、(、)、{、}是分隔符。词法阶段有个很容易踩坑的点是!的识别。因为还涉及非法字符!所以代码逻辑必须小心当前字符是!时要立刻看下一个字符如果下一个字符是就合并成一个!Token否则报无法识别的字符: !因为本编译器不支持单目逻辑非运算。这个处理和 Java、C 语言词法分析是一模一样的思路——最长匹配原则在读入!之后不急着返回先偷看一个字符能组成更长的合法 Token 就合并。if (c !) { pos; if (peek() ) { pos; tokens.add(new Token(OPERATOR, !, line, col)); } else { throw new CompileException(第 line 行: 无法识别的字符: !); } continue; }另外一个细节是数字识别。本项目只支持整数遇到数字后连续读入直到遇到非数字字符。我没有做负数支持因为负号-不在题目要求里如果用户写-5词法分析器在-上就会报错这是符合预期的行为。边界情况是a 123abc;这种写法词法分析会得到123这个数字 Token紧接着一个abc标识符 Token语法分析阶段会因为缺少运算符而报意外的 Token: abc这种处理方式也算合理。实操过程与后端代码实现工程目录结构与模块划分老生常谈但必须强调编译器的核心逻辑千万不要和 SpringBoot 的 Controller、Service 混在一起。我见过不少同学把 Lexer 的代码直接写在 Controller 里接口一调就全栈溢出根本没法调试。我的工程结构是这样的src/main/java/com/example/compiler/ ├── CompilerApplication.java ├── controller/ │ └── CompileController.java ├── core/ │ ├── Lexer.java │ ├── Parser.java │ ├── Interpreter.java │ ├── Token.java │ ├── Quad.java │ └── SymbolTable.java ├── dto/ │ ├── CompileRequest.java │ └── CompileResponse.java └── exception/ └── CompileException.javacore包里全是纯 Java 类不依赖任何 Spring 注解这样设计的好处是可以用 JUnit 单独测试编译核心逻辑不需要启动 SpringBoot 容器调试速度快一个数量级。Controller 只负责三件事接收 HTTP 请求、调用 core 包里的编译链路、把结果封装成 CompileResponse 返回。词法分析器代码实现Lexer 的核心是一个tokenize()方法逐字符扫描源代码。我维护了一个pos指针和line/col行列号遇到换行\n时line、col归零。跳过空白字符的逻辑放在每个 Token 识别之前public ListToken tokenize() { while (pos input.length()) { skipWhitespace(); if (pos input.length()) break; char c input.charAt(pos); if (isLetter(c)) { readIdentifierOrKeyword(); } else if (isDigit(c)) { readNumber(); } else if (c ) { addToken(OPERATOR, ); } else if (c ) { addToken(OPERATOR, ); } else if (c !) { handleNotEqual(); } else if (c || c ; || c ( || c ) || c { || c }) { addToken(DELIMITER, String.valueOf(c)); } else { throw new CompileException(第 line 行, 第 col 列: 无法识别的字符 - c); } } tokens.add(new Token(EOF, , line, col)); return tokens; }关键字和标识符的识别走同一个方法从当前位置开始连续读字母、数字、下划线得到完整字符串后判断是否为if或else如果是就返回KEYWORD类型否则返回IDENTIFIER。这里有个取舍我把if和else设为关键字意味着用户不能用if作为变量名这是合理的实际情况里这俩词确实不应该出现在标识符位置。我特别建议在 Token 里记录行列号虽然编译器执行阶段用不到但报错信息好不好看直接决定答辩时老师的印象分。错误信息里带第 2 行第 5 列比直接抛一个空指针高了不知道多少个档次。语法分析器与四元式生成Parser 类采用递归下降法每个文法非终结符对应一个方法。这几个方法的返回值设计我反复调整过最后定为String表示该表达式计算结果的存放位置——可能是一个变量名也可能是一个临时变量名。这个方法签名是整个编译器设计最精妙的一点。private String parseTerm() { Token token currentToken(); if (token.getType().equals(IDENTIFIER)) { consume(); table.declareIfAbsent(token.getValue()); return token.getValue(); } else if (token.getType().equals(NUMBER)) { consume(); return token.getValue(); } throw new CompileException(第 token.getLine() 行: 语法错误, 期望标识符或数字, 实际是 token.getValue()); }parseSimpleExpression解析加法链先把第一个 term 当作左值然后循环判断当前 Token 是否为如果是就消费掉并解析下一个 term同时生成四元式(, left, right, temp)private String parseSimpleExpression() { String left parseTerm(); while (isOperator()) { consume(); String right parseTerm(); String temp newTemp(); genQuad(, left, right, temp); left temp; } return left; }parseExpression在前面代码里展示过它做完比较运算后把结果存进临时变量。parseAssign处理赋值语句parseIf处理条件语句。parseIf的四元式生成是核心难点我详细说说private void parseIf() { consumeKeyword(if); expectDelimiter((); String cond parseExpression(); expectDelimiter()); String falseLabel newLabel(); genQuad(jz, cond, _, falseLabel); parseStatement(); if (currentTokenIsKeyword(else)) { consume(); String endLabel newLabel(); genQuad(jmp, _, _, endLabel); addLabel(falseLabel); parseStatement(); addLabel(endLabel); } else { addLabel(falseLabel); } }这里我用的是最经典的回填Backpatching思想。条件表达式cond的运算结果已经在临时变量里jz四元式表示如果结果为 0 就跳转到 falseLabel。因为 falseLabel 一开始不知道具体位置所以先放一个占位符等解析完后续语句后再通过addLabel把真实的下标填进去。四元式序列本质上是一个ArrayListQuad标签就是数组下标。初始化跳转标签时我直接以字符串形式存储数字下标执行器的指令指针移动到对应位置即可。这种实现方式比符号表里的地址回填要简单但对课程设计完全够用而且答辩时能讲清楚原理。解释执行器与符号表管理Interpreter 是整条链路里最直观的一环遍历四元式列表用一个int ip 0指令指针控制流程。每次执行完一条四元式如果它不是跳转指令ip如果是跳转指令根据条件修改ip值。特别注意在ip和跳转之间不能放重复逻辑否则会出现跳转后还继续往下走的经典 bug。public MapString, Integer execute() { SymbolTable table new SymbolTable(); int ip 0; while (ip quads.size()) { Quad q quads.get(ip); switch (q.getOp()) { case : table.assign(q.getResult(), value(q.getArg1()) value(q.getArg2())); ip; break; case : table.assign(q.getResult(), value(q.getArg1()) value(q.getArg2()) ? 1 : 0); ip; break; case !: table.assign(q.getResult(), value(q.getArg1()) ! value(q.getArg2()) ? 1 : 0); ip; break; case : table.assign(q.getResult(), value(q.getArg1())); ip; break; case jz: ip (value(q.getArg1()) 0) ? Integer.parseInt(q.getResult()) : ip 1; break; case jmp: ip Integer.parseInt(q.getResult()); break; default: throw new CompileException(未知的四元式操作符: q.getOp()); } } return table.getAllVariables(); }value(String name)方法先判断参数是否为纯数字如果是就直接转成整数否则从符号表里取值。这种设计让四元式的 arg1、arg2 可以混合使用变量名和数字字面量。符号表使用LinkedHashMapString, Integer实现既保留插入顺序又支持按名字查找。有一点要留意每次执行器的execute()必须创建新的SymbolTable不能复用上一次的状态。因为浏览器可能连续调用多次编译接口如果符号表被上一次执行污染第二次执行时会看到残留的变量值排查起来非常难受。我设置的是每次从execute()入口 new 一个表关闭状态完全隔离。SpringBoot 接口封装Controller 层的代码非常简单核心是把三个阶段的产物都塞进响应对象里RestController RequestMapping(/api) CrossOrigin(origins *) public class CompileController { PostMapping(/compile) public CompileResponse compile(RequestBody CompileRequest request) { CompileResponse response new CompileResponse(); try { Lexer lexer new Lexer(request.getSource()); ListToken tokens lexer.tokenize(); Parser parser new Parser(tokens); parser.parse(); Interpreter interpreter new Interpreter(parser.getQuads()); interpreter.execute(); response.setTokens(tokens); response.setQuads(parser.getQuads()); response.setSymbolTable(interpreter.getSymbolTable()); response.setSuccess(true); } catch (CompileException e) { response.setSuccess(false); response.setError(e.getMessage()); } return response; } }CrossOrigin是前后端联调时最省事的跨域解决方案开发环境下 Vue 跑在 5173 端口Vite 默认SpringBoot 跑在 8080如果不加这个注解浏览器直接拦截响应。生产环境用 Nginx 反代或者把 Vue 打包后放进 SpringBoot 的static目录则不需要依赖这个注解。后面我会单独说这个坑。响应 JSON 的结构大概是这样的{ success: true, tokens: [ { type: IDENTIFIER, value: x, line: 1, col: 1 }, { type: DELIMITER, value: , line: 1, col: 3 } ], quads: [ { op: , arg1: 1, arg2: 2, result: t1 }, { op: , arg1: t1, arg2: _, result: x } ], symbolTable: { x: 3 }, error: null }前端展示与联调Vue 实现页面布局与交互流程Vue 前端的核心目标是解决编译过程不可见的问题。传统命令行工具输入一行输出一行中间发生了什么完全依赖脑补而前端面板可以把 Token 流、四元式、符号表并排展示出来一目了然。我设计了最简单的三栏布局左侧是源代码输入区右上角是编译运行按钮中下方是三个结果卡片分别展示 Token 列表、四元式列表和最终符号表。交互逻辑用 Composition API 写起来非常直接const sourceCode ref(); const tokens ref([]); const quads ref([]); const symbolTable ref({}); const errorMsg ref(); async function compile() { const res await axios.post(/api/compile, { source: sourceCode.value }); if (res.data.success) { tokens.value res.data.tokens; quads.value res.data.quads; symbolTable.value res.data.symbolTable; errorMsg.value ; } else { errorMsg.value res.data.error; } }按钮点击后异步等待后端返回返回后直接更新三个响应式变量Vue 自动刷新视图。整个过程不需要刷新页面。后端数据如何渲染成调试面板Token 列表我用一个简易的table渲染列是类型 / 值 / 行号 / 列号四个字段。这个表格对展示词法分析结果特别直观老师一眼就能看出 Token 类型分得对不对。四元式列表的渲染更有意思我直接在界面上标注每个四元式的下标跳转指令里的 label 是数字下标用户可以看到jz指令后面的数字和另一个jmp指令的结果是同一个值这种视觉对应能帮助理解回填机制。前端不需要做任何逻辑处理因为后端已经把四元式生成过程中的 label 下标填好并放在result字段了。符号表部分用pre直接显示 JSON 字符串也可以做成 key-value 表格。考虑到符号表在示例程序里通常不超过十个变量哪种都行。我更倾向于表格因为答辩时可以逐行指着某个变量的值说最终执行结果里 x 3这是因为加法四元式的结果赋给了 x。如果想让界面更有档次可以加一个生成语法树的按钮后端返回解析树的嵌套 Map前端用递归组件渲染成树形图。这是加分项不是必须项。前后端联调与跨域处理开发环境下最常遇到的坑是跨域。Vite 默认前端跑在http://localhost:5173SpringBoot 后端跑在http://localhost:8080两者端口不同浏览器会拦截跨域请求。我推荐两种解决方式第一种在后端加CrossOrigin(origins *)适合课程设计这种不追求精细权限控制的场景一行注解解决问题。第二种在 Vite 配置文件里加代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } });这样前端代码里请求地址写成/api/compileVite 开发服务器会把请求转发到后端 8080 端口浏览器看到的请求始终是同源的跨域问题从根源消失。我实际项目里用的是第二种方式因为生产部署时也能保持同样的请求路径只需把后端静态资源目录指向 Vue 打包产物就行。两种方式都不复杂我建议至少把CrossOrigin加在后端 Controller 类上双保险。常见问题与排查技巧实录优先级错乱的现场还原优先级问题是所有写表达式解析器的人都会遇到的。我有个朋友做类似题目时把文法写成了expr - expr term | term term结果解析1 2 3时先解析出1 2再看到时发现右边没法解析因为parseTerm只能处理单个 term不能处理3之外更复杂的表达式。然后他改成expr - expr expr expr又出现左递归死循环。最终还是要回到我前面给出的分层设计比较运算层调用加法层加法层调用 term 层。这个顺序就是优先级的顺序层级越靠下优先级越高。如果你发现运算结果和预期不一致第一件事就是检查文法分层而不是怀疑解释器有问题。!和!的词法陷阱词法分析器读到!的时候必须强制偷看下一个字符这一条我在实际测试里吃过亏。有一版实现我偷懒只判断了!本身结果输入a ! b时报错因为!单独被当成了 Token。正确做法是形成!双字符 Token同时对孤立的!给出明确报错信息。还有一个相关问题是!后面跟空格的情况比如a ! b空格会让词法分析器错误地把!当作孤立符号这种输入本来就不合法报错也是合理的。另外注意和的区别本项目不支持用户输入a b时词法分析器会识别出和两个 Token语法分析阶段报错意外的 Token: 这个报错位置如果带上行号列号用户的困惑会小很多。if 的 else 悬空问题递归下降法天然解决了else悬空问题因为parseIf在解析完if主体语句后立刻检查当前 Token 是否为else是就消费掉解析 else 分支否则就结束。这保证了else总是和最近的未匹配if配对行为符合 C 语言语义。但这里有个容易出错的细节parseStatement可能递归调用parseIf也就是说if (a b) if (c ! d) x 1; else y 2;这种嵌套写法里第二个else应该匹配内层if。由于递归下降的顺序天然就是内层先匹配所以这个行为是正确的你不需要额外处理。真正的问题是做语法分析时如果 Token 序列里)或;缺失非常容易报出晦涩的错误建议在关键位置加expect方法把错误信息格式化为第 X 行: 期望 ; 但读到 }这种风格。变量作用域与符号表清理我把符号表设计成整个程序共用一张没有引入作用域概念。这也是符合题意的因为题目没要求函数或块级作用域。但有同学想实现{}块级作用域记录变量只在当前大括号内有效这时候就必须引入作用域栈结构。我在实际扩展中发现一个隐蔽 bug块结束时如果只是简单弹出符号表那么块内的临时变量t1这种也会跟着消失但块外的四元式可能还引用它们运行时光查符号表就会查不到。解决办法是块结束时不清除临时变量或者每次进入新作用域给所有变量添加新的命名前缀。课程设计里不涉及这个场景我提这个坑是为了提醒如果你后续扩展循环或函数作用域管理一定要提前设计好不要等出了 bug 再补。Vue 跨域与中文编码问题中文乱码问题在实际联调时非常常见。我的后端 Controller 返回的错误信息全是中文SpringBoot 默认使用 UTF-8 编码但如果前端 Axios 没有设置正确的响应编码或者后端没有显式声明produces application/json;charsetUTF-8浏览器就有概率把中文显示成乱码。解决办法有两个层面后端的RequestMapping注解显式声明编码前端 Axios 配置responseType: json。这两个一起配上基本不会出问题。另外 Vite 代理方式联调时如果还出现乱码八成是后端application.yml里server.servlet.encoding.force-response没有设置为true。测试用例与运行效果分析常规功能测试我先列几个必须跑的测试用例这些用例覆盖了三个运算符的基本功能和组合逻辑。第一个用例是纯加法链x 1 2 3;词法分析得到 7 个有效 Tokenx、、1、、2、、3、;语法分析生成两条四元式(, 1, 2, t1)和(, t1, 3, t2)最后赋值(, t2, _, x)符号表输出x 6。重点是验证临时变量的传递正确。第二个用例验证比较运算结果能赋给变量a 5; b 3; c a b; d a ! b;预期结果是c 1、d 1。这里要注意一个细节c a b这种写法里赋值运算符的优先级应该高于比较运算符但文法上赋值语句是在parseStatement层调用parseExpression所以实际上是从右往左解析的a b先计算得到 1再赋给c。这个逻辑符合预期不需要单独调整优先级。第三个用例是条件语句a 2; b 3; if (a 1 b) { c a b; } else { c a ! b; }这里a 1 b解析时先生成(, a, 1, t1)再生成(, t1, b, t2)最后jz t2判断跳转。因为2 1 3为假所以执行 else 分支c a ! b即2 ! 3结果为 1。整个执行过程中四元式的跳转逻辑必须准确跳到 else 分支开头这个用例能同时验证加法、比较、jz跳转、jmp无条件下跳四条指令的综合行为。异常与边界测试异常测试的目标不是让程序崩溃而是给出可读的报错信息。我需要验证以下异常场景输入a 1 ;语法分析阶段应该在;处报期望标识符或数字输入if (a b) x 1缺少分号应该在语句结束时报期望 ;输入a 1 ! b;词法分析阶段应该在!处报无法识别的字符: !输入空字符串应该正常返回空 Token 列表语法分析直接结束不报错新增变量出现在表达式右侧但从未赋值按隐式声明规则应该默认值为 0不报错边界测试里我额外测过一种情况变量名和关键字冲突比如if 3;词法分析器会把if识别为KEYWORD类型parseAssign期望IDENTIFIER类型于是报期望标识符错误。这符合预期因为if作为关键字不能当变量名。还有一个有意思的边界是a 999999999999;这种超过 Java int 范围的数字我的解释器用Integer.parseInt直接解析会抛出NumberFormatException。我在 CompileException 之外单独 catch 了运行时异常统一包装成编译失败: 数字超出整数范围避免接口直接返回 500。这种细节虽然不是核心考点但体现了项目的健壮性。最终运行效果与可展示性把上述用例输入前端页面点击编译运行界面会依次呈现 Token 表格、四元式序列和符号表。Token 表格能直观看到每个字符被归入哪种类别四元式序列能完整展示中间代码的生成过程符号表则显示最终变量值。对于答辩场景我建议在演示到 if 语句时精确指出jz四元式的result字段指向的 label 和jmp四元式的result字段指向的 label讲解为什么跳转逻辑是对的这比光说我实现了编译器要有说服力得多。我在这套项目上最终的体会是编译原理作业最难的部分往往不是算法本身而是把一个严谨的算法用工程手段组织起来。词法分析、递归下降、四元式生成这些概念教科书上都有但把 Bug 逐渐修掉、把异常处理补齐、把前端展示做得让不懂编译原理的人也能看明白才是课设真正的锻炼价值。如果后续有时间可以先加一个while循环语句把jz和jmp的组合玩得更熟再考虑加除法除零报错机制顺便把运行期异常管理做起来。题目虽小能扩展的空间其实很大。