编译原理课程设计里,“简单条件语句4编译器”是我印象很深的一个题目。名字听起来很学术,其实就是让你用程序实现一门小到不能再小的编程语言:支持if-else条件语句,支持>、!=两个比较运算符,支持+加法运算,再配上错误报错和运行输出。放在Java+Spring Boot+Vue这套技术栈里做,意味着你不但要把编译原理前几章的词法分析、语法分析、求值执行啃下来,还得把整个流程包成一个Web服务、做一个能互动的前端页面。这门小语言的语法集虽然精简,但编译器的骨架一样不少:Token流、AST、符号表、递归下降解析、表达式求值,全都要落地。这篇文章我会把整个项目的设计与实现思路拆开讲清楚,从Java核心编译逻辑到Spring Boot接口封装,再到Vue前端交互,最后列出我在开发过程中踩过的坑,给后面做编译原理选题的同学一个可以直接参考的完整方案。
1. 项目整体设计与需求拆解
1.1 选题目标与语法能力边界
开始写代码之前,第一件事是把这门“小语言”的语法定义清楚。我给它起名叫“简单条件语句4编译器”,这里的“4”我理解为选题编号或第四个版本,核心能力是条件语句的编译与执行。最终确认的语法能力只有四类:
- 赋值语句:
a = 10;,把表达式的值存入变量。 - 表达式:支持整数常量、变量引用,以及
+加法运算,例如a + b + 3。 - 条件语句:
if (条件) { 语句块 } else { 语句块 },支持嵌套。 - 输出语句:
print(表达式);,把值输出到控制台。
比较运算符只有>和!=,且出现在if的条件表达式中,例如if (a > b)。去掉了<、<=、==、&&、||等一切多余符号,明显是为了控制课程设计的工作量,同时把编译原理最核心的几个环节完整保留下来。
文法定成这样:
program := statement_list statement_list := statement* statement := if_statement | assign_statement | print_statement if_statement := IF LPAREN condition RPAREN block (ELSE block)? block := LBRACE statement_list RBRACE | statement condition := add_expr (GT | NE) add_expr add_expr := primary (PLUS primary)* primary := INTEGER | IDENTIFIER | LPAREN add_expr RPAREN assign_statement := IDENTIFIER ASSIGN add_expr SEMI print_statement := PRINT LPAREN add_expr RPAREN SEMI这个文法支持if后面跟一个花括号块,也支持跟单条语句,所以写if (a > 1) print(a);和if (a > 1) { print(a); }都能正确解析。else块是可选分支,解析时只有看到ELSE关键字才往下读。
1.2 技术栈选型的真实考量
很多人看到Java+Spring Boot+Vue的组合会觉得“杀鸡用牛刀”——一个编译原理作业,为什么要搭前后端?从课程设计答辩的角度说,这套组合有三个实际收益:
第一,Java是编译原理实验最稳妥的选择。词法分析器要维护很多状态,语法分析要构建AST节点,Java的面向对象特性让每种节点、每个Token天然对应一个类,代码组织清晰,后续扩展语义检查或者中间代码生成都很方便。相比C语言还要手动管理内存,Java能让你的精力集中在编译逻辑本身。
第二,Spring Boot把“编译器”变成了“服务”。实验的核心代码写得再漂亮,如果只是在命令行里打印结果,答辩时展示效果非常单薄。Spring Boot用几十行代码就能暴露一个REST接口,前端把源代码POST过来,后端返回输出结果和错误信息,演示效果好得多。
第三,Vue前端解决的是“输入体验”问题。命令行方式需要先在本地装JDK、编译运行,评委没法快速上手。Web页面直接复制粘贴代码、点按钮看结果,整个体验就像用一个小型在线IDE,直观且易于展示。
如果只是应付验收,不用Spring Boot和Vue当然也能完成,但做成Web应用会显著提升项目的完整度和评委印象分。
1.3 编译器整体工作流程
编译器再怎么简化,核心链路都是四步:
- 词法分析:把源代码字符串拆成Token流。
if (a > 3)变成IF LPAREN IDENTIFIER GT INTEGER RPAREN。 - 语法分析:根据文法把Token流组装成AST(抽象语法树),这一步能发现括号不匹配、语句缺少分号之类的语法错误。
- 语义分析:对AST做检查,比如变量是否声明、条件表达式两侧的值是否可比较。这门小语言没有类型系统,所以语义检查相对轻量。
- 执行求值:遍历AST,维护一个符号表记录变量当前值,输出print语句的结果。
整个流程在Java里就是三个类各司其职:Lexer负责第1步,Parser负责第2步,Evaluator负责第3、4步。Spring Boot负责把这三个类串起来,Vue负责把源代码送进去、把结果展示出来。后面几章逐个展开。
2. 词法分析与语法分析的实现细节
2.1 词法分析器:从源码字符串到Token流
词法分析是编译器的第一个关卡。你需要把输入的源代码字符串逐字符扫描,识别出一个个有意义的单词,也就是Token。每个Token至少包含三样信息:类型、文本、在源码中的行列位置。
我把Token类型定义成一个枚举:
public enum TokenType { IF, ELSE, PRINT, IDENTIFIER, // 变量名 INTEGER, // 整数常量 GT, NE, PLUS, ASSIGN, // > != + = LPAREN, RPAREN, // ( ) LBRACE, RBRACE, // { } SEMI, // ; EOF }词法分析器核心逻辑是nextToken()方法,逐字符判断。难处理的点有两个:一是如何区分if关键字和普通变量名,二是如何识别!=而不把它拆成!和=。
我用的方案:遇到字母开头就持续读字母,拼出完整单词后查关键字表,命中就是关键字,否则就是标识符。遇到!时预读下一个字符,如果是=就认作NE,否则直接抛词法错误,因为这门小语言里根本没有单独使用的!。
public class Lexer { private final String src; private int pos = 0; private int line = 1; private int col = 1; private static final Set<String> KEYWORDS = Set.of("if", "else", "print"); public Lexer(String src) { this.src = src; } public Token nextToken() { skipWhitespace(); if (pos >= src.length()) { return new Token(TokenType.EOF, "<EOF>", line, col); } char c = src.charAt(pos); if (Character.isDigit(c)) { return readNumber(); } if (Character.isLetter(c)) { return readIdentOrKeyword(); } switch (c) { case '>': advance(); return new Token(TokenType.GT, ">", line, col - 1); case '+': advance(); return new Token(TokenType.PLUS, "+", line, col - 1); case '=': advance(); return new Token(TokenType.ASSIGN, "=", line, col - 1); case '(': advance(); return new Token(TokenType.LPAREN, "(", line, col - 1); case ')': advance(); return new Token(TokenType.RPAREN, ")", line, col - 1); case '{': advance(); return new Token(TokenType.LBRACE, "{", line, col - 1); case '}': advance(); return new Token(TokenType.RBRACE, "}", line, col - 1); case ';': advance(); return new Token(TokenType.SEMI, ";", line, col - 1); case '!': // 预读一个字符,判断是否为 != if (peek(1) == '=') { advance(); advance(); return new Token(TokenType.NE, "!=", line, col - 2); } throw new LexException("无法识别的字符 '!',此处只支持 != 运算符", line, col); default: throw new LexException("无法识别的字符 '" + c + "'", line, col); } } }行号和列号必须在读取每个字符时同步维护,这是后面做错误定位的关键。advance()方法里,遇到换行符\n时line++、col归位1,其他字符col++,这样每个Token都知道自己在源码中精确的位置。
补充一点,readNumber()一定要循环读取连续的数字字符,遇到非数字就停止,例如输入123abc,词法分析阶段就会把123识别为整数,然后abc识别为标识符,语法分析阶段再报位置错误,这样错误信息反而更能说明问题。
2.2 递归下降解析器与AST构建
语法分析我用的递归下降法,这是手工编写编译器最直观的方式,每个语法规则对应一个方法,方法之间互相嵌套调用。这门小语言的文法层次很简单,解析顺序从顶层往下:语句列表 -> 单条语句 -> if语句 / 赋值语句 / print语句 -> 表达式 -> 基础单元。
先定义AST节点类,我用一个抽象基类加上具体子类:
public abstract class ASTNode { } public class ProgramNode extends ASTNode { public List<ASTNode> statements = new ArrayList<>(); } public class IfNode extends ASTNode { public ConditionNode condition; public ASTNode thenBranch; public ASTNode elseBranch; // 可能为 null } public class AssignNode extends ASTNode { public String varName; public ASTNode expr; } public class PrintNode extends ASTNode { public ASTNode expr; } public class BinaryExprNode extends ASTNode { public String op; // "+" public ASTNode left; public ASTNode right; } public class ConditionNode extends ASTNode { public String op; // ">" 或 "!=" public ASTNode left; public ASTNode right; } public class NumberNode extends ASTNode { public int value; } public class VarNode extends ASTNode { public String name; }有了节点定义,Parser就比较好写了。每个方法返回一个ASTNode,解析失败时抛出带行列号的异常。
表达式是编译原理中比较容易踩坑的地方。加法的文法是primary (PLUS primary)*,这天然是左结合的,写成循环而不是递归,可以有效避开左递归问题:
private ASTNode parseAddExpr() { ASTNode left = parsePrimary(); while (cur.type == TokenType.PLUS) { advance(); ASTNode right = parsePrimary(); left = new BinaryExprNode("+", left, right); } return left; }parsePrimary()处理三种情况:整数常量、标识符、括号包裹的子表达式。看到(就直接递归调用parseAddExpr(),解析完必须立刻消费),否则报“缺少右括号”。
if语句的解析是整个Parser里最长的分支,要依次消费if、(、条件、)、then分支、可选的else和else分支:
private ASTNode parseIfStatement() { expect(TokenType.IF); expect(TokenType.LPAREN); ConditionNode cond = parseCondition(); expect(TokenType.RPAREN); ASTNode thenBranch = parseBlock(); ASTNode elseBranch = null; if (cur.type == TokenType.ELSE) { advance(); elseBranch = parseBlock(); } return new IfNode(cond, thenBranch, elseBranch); }这里有个经典问题叫悬空else(dangling else)。理论上if (a) if (b) ... else ...有歧义,但递归下降法默认把else和最近的if匹配,这正好是大多数语言标准行为。我们文法里if块用花括号包裹,本身歧义很小,但仍建议代码风格强调使用花括号。
2.3 符号表设计与表达式求值
语义分析和执行我用一个Evaluator类合并完成。符号表是核心数据结构,这里直接用HashMap<String, Integer>,原因是这门小语言只有一种类型——整数,不需要存储类型信息,变量名映射到值就够了。
public class Evaluator { private final Map<String, Integer> symbolTable = new HashMap<>(); private final List<String> output = new ArrayList<>(); public List<String> exec(ASTNode node) { execNode(node); return output; } private void execNode(ASTNode node) { if (node instanceof ProgramNode p) { for (ASTNode stmt : p.statements) execNode(stmt); } else if (node instanceof AssignNode a) { int v = evalExpr(a.expr); symbolTable.put(a.varName, v); } else if (node instanceof PrintNode pn) { int v = evalExpr(pn.expr); output.add(String.valueOf(v)); } else if (node instanceof IfNode in) { boolean cond = evalCondition(in.condition); if (cond) execNode(in.thenBranch); else if (in.elseBranch != null) execNode(in.elseBranch); } } private int evalExpr(ASTNode node) { if (node instanceof NumberNode n) return n.value; if (node instanceof VarNode v) { Integer val = symbolTable.get(v.name); if (val == null) { throw new IncludeExecException("变量未声明: " + v.name, v.line, v.col); } return val; } if (node instanceof BinaryExprNode b) { int l = evalExpr(b.left); int r = evalExpr(b.right); // b.op 固定为 "+" return l + r; } throw new IncludeExecException("无法求值的表达式节点", node.line, node.col); } private boolean evalCondition(ConditionNode c) { int l = evalExpr(c.left); int r = evalExpr(c.right); if (">".equals(c.op)) return l > r; if ("!=".equals(c.op)) return l != r; throw new IncludeExecException("不支持的条件运算符: " + c.op, c.line, c.col); } }关于变量未声明的处理,这里要做一个设计决策:是直接当成0值,还是抛异常?我的选择是抛异常,因为编译器的价值就在于提前发现错误,如果把未声明变量默默当成0,等于把程序员的错误藏起来,这在课程设计答辩时是很减分的。异常通过统一入口捕获后,格式化成一行的错误消息,比如第2行第7列: 变量未声明: b。
符号表需要注意的一个细节是:如果允许else块和then块访问同一个变量,那符号表必须是全程单张的,不能在每个块内复制一份。因为这是门教学语言,没有引入作用域嵌套,所有变量都在同一个作用域里。你要是想扩展为多作用域,就需要在进入块时push一层、离开时pop一层,符号表会变成一个栈结构的Map列表。我这次没有做,文法和求值都简单不少。
3. Spring Boot后端服务与编译器集成
3.1 REST接口定义与请求响应模型
Spring Boot在项目里的角色是编译器的HTTP入口。核心Java类写完之后,编译原理本身的工作就基本完成了,接下来需要把这些类接到Web层。接口设计成最简单的POST请求,前端把源代码放在请求体里,后端返回编译执行结果或错误信息。
我定义的接口是:
POST /api/compiler/run Content-Type: application/json请求体格式:
{ "code": "a = 10;\nb = 3;\nif (a > b) {\n print(a + b);\n} else {\n print(0);\n}" }响应体分两种情况。成功时返回输出结果,附带Token列表和AST的JSON表示,方便答辩时展示编译过程:
{ "success": true, "output": "13", "tokenCount": 23, "astDump": "ProgramNode(Assign, Assign, If)" }失败时返回false、空输出、错误数组,每个错误元素都包含行号、列号和描述:
{ "success": false, "output": "", "errors": [ { "line": 2, "col": 10, "message": "变量未声明: x" } ] }接口的Java实现非常直接。一个CompilerController,内部按流程拼装Lexer、Parser、Evaluator,用一个try-catch包裹所有异常。
@RestController @RequestMapping("/api/compiler") public class CompilerController { @PostMapping("/run") public CompileResponse run(@RequestBody CompileRequest request) { CompileResponse resp = new CompileResponse(); try { Lexer lexer = new Lexer(request.getCode()); Parser parser = new Parser(lexer); ASTNode ast = parser.parseProgram(); Evaluator evaluator = new Evaluator(); List<String> output = evaluator.exec(ast); resp.setSuccess(true); resp.setOutput(String.join("\n", output)); resp.setTokenCount(parser.getTokenCount()); resp.setAstDump(ast.toString()); } catch (LexException | ParseException | IncludeExecException e) { resp.setSuccess(false); resp.setErrors(List.of(new CompileError(e.getLine(), e.getCol(), e.getMessage()))); } return resp; } }3.2 编译核心的封装与调用
这里有个容易忽略的问题:每次HTTP请求都要创建新的Lexer、Parser和Evaluator实例。不能把这些对象设置成Spring的单例Bean,原因是它们内部都有可变状态。Lexer有pos、line、col,Parser持有当前Token,Evaluator维护符号表,如果多个用户同时请求,一个用户的编译过程会污染另一个用户的符号表。Controller方法里手动new是最稳妥的,虽然每次请求都有一点对象创建开销,但这门小语言规模很小,完全无感。
为了让AST能方便地展示给前端,我建议在ASTNode基类上加一个toString()或dump()方法,输出成一棵缩进树。例如:
ProgramNode AssignNode: a = BinaryExpr(10 + 3) IfNode Condition: Var(a) > Var(b) Then: PrintNode(BinaryExpr(a + b)) Else: PrintNode(NumberNode(0))这个dump字符串直接返回给前端展示,能让评委直观看到语法分析的产出。代码量不大,但对展示效果提升明显。
3.3 异常捕获与错误信息格式化
编译器的错误处理跟普通业务系统不太一样。普通Web应用异常直接返回500,前端弹个“服务器错误”就行。编译器的错误必须带位置信息,否则用户根本不知道哪里写错了。
我定义了三个自定义异常,分别对应三阶段:
LexException:词法错误,例如出现了不支持的字符$。ParseException:语法错误,例如语句结尾缺少分号、括号不匹配。IncludeExecException:执行期错误,例如变量未声明。
三个异常都继承自同一个父类CompileException,父类持有line和col。Controller里一次性catch父类即可,不需要写三个catch块。
public class CompileException extends RuntimeException { protected final int line; protected final int col; public CompileException(String message, int line, int col) { super(message); this.line = line; this.col = col; } }这种设计让前端展示错误信息非常轻松,直接拿到行列号就能在编辑框里定位并高亮出错位置。答辩时如果能现场演示“在源码里故意写错一个字符,编辑器立即标出错误位置”,这个加分效果非常明显。
4. Vue前端交互界面与前后端联调
4.1 页面功能拆解与组件结构
前端部分不需要做得很花哨,核心是三个功能:输入源代码、触发编译运行、展示输出和错误。我用的Vue 3单文件组件,Axios发请求。
页面布局我拆成左右两栏,左侧是代码编辑区,右侧分上下两块展示输出和错误信息。没有引入任何UI组件库,避免加装Element Plus之类的依赖导致体积膨胀;纯手写CSS完全够用。
组件结构可以是:
App.vue:整体布局和状态管理。EditorPanel.vue:textarea输入区域。ResultPanel.vue:展示输出与错误。
如果项目本身就小,也可以把所有东西放在App.vue一个组件里。我建议分开组件,原因是代码可读性更好,评委问起来技术要点时也更容易说清楚组件化开发的思想。
App.vue的关键部分:
<template> <div class="app-container"> <h1>简单条件语句4编译器</h1> <div class="editor-area"> <textarea v-model="sourceCode" rows="12" spellcheck="false" placeholder="请输入源代码,例如: a = 10; b = 3; if (a > b) { print(a + b); } else { print(0); }" ></textarea> <button class="run-btn" :disabled="running" @click="handleRun"> {{ running ? '编译中...' : '编译运行' }} </button> </div> <div class="result-area"> <div class="result-box"> <h3>程序输出</h3> <pre class="output">{{ output || '(暂无输出)' }}</pre> </div> <div class="result-box"> <h3>错误信息</h3> <pre class="error" :class="{ 'no-error': !error }"> {{ error || '(无错误)' }} </pre> </div> </div> </div> </template>4.2 API调用与交互逻辑
点击“编译运行”按钮后,前端要做的事情很简单:把textarea里的源代码POST到后端接口,然后根据返回值渲染。
<script> import axios from 'axios'; export default { data() { return { sourceCode: [ 'a = 10;', 'b = 3;', 'if (a > b) {', ' print(a + b);', '} else {', ' print(0);', '}' ].join('\n'), output: '', error: '', running: false }; }, methods: { async handleRun() { this.running = true; this.output = ''; this.error = ''; try { const res = await axios.post('/api/compiler/run', { code: this.sourceCode }); if (res.data.success) { this.output = res.data.output; } else if (res.data.errors && res.data.errors.length > 0) { this.error = res.data.errors .map(e => `第 ${e.line} 行第 ${e.col} 列:${e.message}`) .join('\n'); } } catch (err) { this.error = '请求失败:' + (err.response?.data?.message || err.message); } finally { this.running = false; } } } }; </script>编码格式统一用UTF-8,前端和后端都要设置。前端npm dev server跑在localhost:5173,Spring Boot跑在localhost:8080,两者端口不同。开发阶段最简单的联调方式不是开启后端CORS,而是给前端的vite配置开发代理:
// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });这样前端代码里可以直接写相对路径/api/compiler/run,请求会由vite转发到8080端口,浏览器里不会出现跨域错误。生产环境则可以用Spring Boot直接把前端打包后的静态文件一起提供服务,这是课程设计演示时最省事的一种方式。
4.3 生产部署与演示模式
如果需要在答辩现场演示,建议直接打成jar包,把Vue构建后的dist目录放进Spring Boot的static目录下。这样评委只需要一个命令java -jar comiler-demo.jar,浏览器打开http://localhost:8080就能看到完整页面,不会因为端口不一致或者前后端服务没启动而出洋相。
实现方式:先npm run build生成dist目录,把dist里的静态文件复制到Spring Boot项目的src/main/resources/static/下,重新打包即可。Spring Boot会自动把这些文件当作用户界面资源来提供。api请求路径仍然走/api/compiler/run,同一个端口,没有跨域烦恼。
5. 实战中踩过的坑与排查思路
5.1 "!="的贪婪匹配陷阱
词法分析器最容易犯的一个错误,就是扫描到!之后直接返回一个错误Token,而没有往后看下一个字符是不是=。我开始写的时候偷懒,先判断单字符,导致输入a != b时词法分析直接报错。正确做法是:
if (c == '!') { if (peek(1) == '=') { advance(); advance(); return new Token(TokenType.NE, "!=", line, col - 2); } throw new LexException("...", line, col); }这本质上是最长匹配原则:词法分析总是贪心地读取最长可能的Token。同一个原则也适用于数字常量123不能识别成1和23。编译原理教材里会讲“超前搜索”,这里就是最朴素的一次实现。
5.2 表达式向左递归的坑
递归下降解析器最大的忌讳是写成parseExpression直接调用自己。如果我把加法的文法写成:
add_expr := add_expr PLUS primary | primary递归下降会无限递归直到栈溢出,因为parseAddExpr()的第一行就调用自己,形成死循环。解决办法就是消除左递归,把文法改写为:
add_expr := primary (PLUS primary)*对应代码就是while (cur.type == PLUS)循环。这个坑几乎所有手写Parser的人都会踩一次,踩过之后就能记住:递归下降只能处理LL型文法,左递归必须先改写。
5.3 未声明变量与符号表共享
Evaluator中我直接用了symbolTable.get(name),如果get返回null说明变量未声明,然后抛异常。这里有个坑是Java 8之前的Map.getOrDefault或者写if判断时容易把null和0混淆,导致变量明明未声明却被当成0参与计算。我的写法是先把结果存到Integer,判断null后再拆箱为int,这样能保证未声明的变量一定报错。
另外一个设计取舍是:当一个变量被重新赋值时,直接map.put覆盖即可,不需要报“重复声明”错误,因为赋值语句本身就是修改已有变量或创建新变量。如果要严格区分声明和赋值,就需要引入declare_statement语法,那会复杂很多,课程设计阶段没必要。
5.4 前后端联调的跨域与端口问题
Spring Boot默认跑在8080,Vue默认跑在5173,浏览器跨域拦截是必然的。我一开始图省事在后端加了@CrossOrigin,一路写下来也能跑通,但答辩时如果前端改了端口又得改注解,不如直接配置vite代理来得干净。代理配置有个细节:changeOrigin必须设为true,否则后端看到的请求源还是5173,某些严格校验的场景会出问题。
如果你选择直接部署模式(dist放进Spring Boot),记得把前端代码里的axios baseURL设置成空字符串,请求路径直接写/api/compiler/run,不要写死http://localhost:8080,不然后面换机器演示就全乱了。
5.5 错误信息定位的行列号维护
错误信息“哪个位置、哪里错了”是做编译器的关键体验。我最初只在异常里带一个字符串消息,不记录行列号,结果前端只能显示“存在语法错误”,用户根本不知道要改哪里。后来在Lexer里每次advance()都更新行列号,Parser和Evaluator里每个AST节点保存自己的行列信息,异常抛出时带上节点位置,才真正做到精确报错。
还有一个提升体验的小技巧:错误信息里的column建议从1开始计数,不要从0开始。因为人眼习惯看到第1列而不是第0列,这会让使用者直观感受到代码中的差错位置。
5.6 常见问题速查
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
输入a != b报字符错误 | 词法分析没有预读!= | 在!分支预读下一个字符 |
解析1 + 2 + 3时栈溢出 | 表达式文法写成左递归 | 改写为primary (PLUS primary)*循环结构 |
| 变量未声明却被当成0运算 | map.get返回值直接拆箱为int | 先用Integer判断null再拆箱 |
| 前端页面访问8080报红字跨域 | 端口不一致导致CORS拦截 | 配置vite代理或直接打包进Spring Boot |
| 错误信息只有文案没有位置 | Token/AST节点未记录行列 | Lexer维护行列号,异常携带位置 |
| 前端中文乱码 | 字符编码不一致 | 统一UTF-8,IDE和编译参数都检查 |
| 多个用户同时访问结果互相串 | Compiler相关类被设置为单例 | 在Controller方法内部new实例 |
这个项目做下来,我最想说的一点是:别把编译原理实验当成纯粹的算法题刷。把编译器做成带界面的Web应用,虽然多花了一些时间在Spring Boot配置和Vue组件上,但带来的正反馈非常明显——你能亲眼看着自己定义的“语言”在浏览器里跑起来,调通的那一刻是真的有成就感。后面如果想扩展,可以试试给这门小语言加上循环语句、乘除法、逻辑与或,甚至函数调用。词法分析和语法分析的核心框架基本不用动,只需要增加Token类型、扩展文法分支、在AST节点里加新类就足够。说到底,编译原理离工程实践并不遥远,一个不到一千行代码的小编译器,足以让你把从前几章里那些抽象的概念全部串起来。