解释器模式(Interpreter Pattern)在我带过的几个团队里,属于那种“面试能背出定义、真上手就写不出来”的典型。教科书上给的那张类图只有四个角色,看着比工厂、单例还简单,可一旦让你把“a + b * 2 - (c / 3)这种字符串算出来”,很多人第一反应还是去搜现成的表达式引擎,或者干脆写一堆split加switch。原因不在模式本身难,而在于 GoF 的图里少了一个关键角色——解析器。没有它,语法树根本长不出来,解释器模式就只是个空壳结构。
这一篇我只聊最基础的那一层:把一条字符串表达式变成对象树,再让树自己算结果。这条链路打通了,后面往规则引擎、查询语言、配置 DSL 上走都是同一套骨架。代码我给的是能直接跑的 Java 版本,节奏偏慢,适合刚学完设计模式想找地方落地的同学,也适合写了几年业务代码、被if-else越堆越厚折磨过的同行走个近道。
1. 解释器模式到底在解决什么麻烦
1.1 从一坨 if-else 说起
前几年做过一个促销核算的需求,最初规则很少:订单满 200 减 30,满 500 减 80。写if就够了。过了一个季度,运营开始提“按品类打折”“按会员等级叠加”“满减和折扣不能同时用”,代码里出现了三层嵌套判断外加一个Map<String, BigDecimal>存阈值。再过一个季度,规则变成“订单金额 × 会员系数 - 券金额 ≥ 门槛”这种带四则运算的表达式,if-else彻底撑不住了。
硬编码逻辑的坏味道主要有三条,而且是叠加出现的。第一,分支数量随规则维度做乘法增长,改一条规则要读懂整个方法;第二,规则改动必须发版,业务方等不起;第三,单条规则无法独立测试,你想验证“满减”这条逻辑,得先构造出能满足所有前置判断的数据。
解释器模式给出的回答是:别把规则写进代码结构,把规则写成数据。规则是字符串,程序负责把它解析成一棵树,树上的每个节点自己知道怎么算自己那一小块。1 + 2这棵树里,1和2是叶子节点,+是分枝节点,分枝节点的职责就是“先算左边,再算右边,然后把两个结果加起来”。加一条新语法,是加一个节点类,不是在老方法里插else if。
这个思路有个隐性的好处,很多人没提:规则的复杂度变成了运行时的数据,而不是编译期的代码规模。业务方给你一段两百个字符的表达式,代码一行不用改。
1.2 四个角色各干哪摊活
先把教科书里的角色摆清楚,我按“实际写代码时谁会真的用到”标注一下重要程度。
| 角色 | 职责 | 实际项目里的形态 | 重要程度 |
|---|---|---|---|
| AbstractExpression(抽象表达式) | 声明interpret(Context)方法 | 一个接口或抽象类,一般是接口 | 必需 |
| TerminalExpression(终结符表达式) | 语法里不再展开的最小单位,如数字、变量名 | NumberExpression、VariableExpression | 必需 |
| NonterminalExpression(非终结符表达式) | 语法里还能拆的节点,内部持有子表达式 | AddExpression、MultiplyExpression | 必需 |
| Context(上下文) | 存放解释过程中共享的数据 | 变量表、符号表,绝大多数情况就是个Map | 视场景 |
| Client(客户端) | 组装语法树,调用interpret | 业务入口 | 必需 |
| Parser(解析器) | 把字符串变成语法树 | 递归下降解析器或第三方解析库 | 必需,但 GoF 图里没画 |
最后一行是我最想强调的。经典类图里,Client 直接 new 出一棵树,那是因为书里的例子是布尔表达式,客户端手写组件树还算能接受。一旦你的输入是用户输入的字符串,Client 就必须有一个解析器帮忙。很多同学照着类图写,写着写着发现“树怎么来”这个问题无解,然后就放弃了。
我一般把解析器理解成解释器模式的“前半场”,模式本身只管“树怎么算”,不管“树怎么来”。这两件事分开看,思路会清楚很多。解析器负责语法分析,产出语法树;语法树负责语义执行。两边的接口只靠Expression这一个类型对接,职责相当干净。
注意:解析器不要塞进
Expression体系里。它是构建者,不是表达式。我见过有人在Expression上加static Expression parse(String),刚开始省事,后来想做语法校验、错误定位、缓存 AST 的时候就全缠在一起了。
1.3 什么时候该用它,什么时候果断绕开
解释器模式是我认为被误用概率最高的模式之一,因为它的名字太有吸引力——“解释器”听起来什么都能干。实际边界很窄。
| 场景特征 | 建议 | 理由 |
|---|---|---|
| 文法规则固定,产生式在 10 条以内 | 适合 | 类数量可控,一屏能看完 |
| 规则由业务方配置,需要动态变更 | 适合 | 规则变成数据,改配置不用发版 |
| 输入需要反复执行同一套逻辑 | 适合 | 树可以构建一次、执行多次,还能做常量折叠 |
| 文法超过 30 条产生式 | 绕开 | 类爆炸,维护成本陡增,考虑现成解析库或脚本引擎 |
| 性能要求极高(单次执行微秒级) | 绕开 | 对象树遍历有开销,直接手写状态机更快 |
| 只是一次性的字符串处理 | 绕开 | 用正则或split就够,别为了模式而模式 |
| 需要复杂控制流(循环、函数定义) | 绕开 | 手写解释器会滑向自己造语言,坑很深 |
一个粗略的判断标准:如果你能把语法写在一张便利贴上,就用解释器模式;写不下,就换方案。我自己的经验是产生式控制在 15 条以内比较舒服,超过之后每加一条语法,测试用例的组合数就往上跳一个台阶。
2. 手写一个最小可用的表达式解释器
2.1 需求拆解与文法定义
目标很明确:接受一个字符串,比如price * count - discount / 2 + (price - 10),从上下文里取变量值,算出结果。要支持加减乘除、括号、变量、小数,要能识别非法输入并给出位置。
动手写代码之前,先把文法用 BNF 写下来。这一步是设计图纸,跳过它直接写代码,十有八九会在优先级上翻车。
expression ::= term (("+" | "-") term)* term ::= factor (("*" | "/") factor)* factor ::= number | variable | "(" expression ")" | "-" factor number ::= digit+ ("." digit+)? variable ::= letter (letter | digit | "_")*这四行包含了三层信息,值得拆开看。第一层,优先级被编码进了层级——expression里的操作数只能是term,而term里的操作数只能是factor,所以乘法天然比加法先算,不需要在运行时比较运算符优先级。第二层,(("*" | "/") factor)*里的星号表示“零次或多次”,这个循环结构让1+2+3+4这种链式运算不会嵌套成四层深,而是左结合的平铺结构。第三层,factor里的"-" factor处理一元负号,注意它写成递归而不是带进term里,否则-3 * 2可能被解析成-(3*2)和(-3)*2两种结果,虽然数值一样,但优先级语义会乱。
提示:写文法的时候,左递归(形如
expression ::= expression "+" term)一定要手动改写成循环形式。递归下降解析器遇到左递归会直接无限递归,栈溢出到你看不出是哪行代码的问题。改写方法就是把它变成term (("+" | "-") term)*,这在编译器教材里叫消除左递归,是标准手法。
2.2 终结符表达式:数字和变量
先定义统一的抽象接口和上下文。接口只有一个方法,上下文就是一个变量表,我特意让它支持链式设置,写测试的时候能省几行。
public interface Expression { double interpret(Context context); }public class Context { private final Map<String, Double> variables = new HashMap<>(); public Context set(String name, double value) { variables.put(name, value); return this; } public double get(String name) { Double value = variables.get(name); if (value == null) { throw new IllegalArgumentException("变量未定义: " + name); } return value; } }终结符两个实现类,都很短,但细节上有讲究。
public class NumberExpression implements Expression { private final double value; public NumberExpression(double value) { this.value = value; } @Override public double interpret(Context context) { return value; } }public class VariableExpression implements Expression { private final String name; public VariableExpression(String name) { this.name = name; } @Override public double interpret(Context context) { return context.get(name); } }两个类都用了final字段,这不是强迫症。语法树一旦构建完成,节点内容就不应该再变了,这样一棵树可以被多个线程共享执行,而上下文每线程一份即可。我在一个对账系统里就是靠这个特性,把解析好的表达式缓存起来,一百多个线程同时interpret,没有任何同步开销。
变量未定义这里我选择抛异常而不是返回 0。这是踩过坑之后改的:早期返回 0 的设计,导致一次配置里变量名拼错(amout写成了amout),结果是订单金额算成了 0,线上跑了三天才被发现。静默失败比异常危险一百倍,这个原则在解释器里尤其适用。
2.3 非终结符表达式:把递归调用抽出来
四个运算节点的逻辑高度雷同:先递归算左右子树,再做一次二元运算。这种结构适合抽一个抽象父类,把公共的递归部分收上去。
public abstract class BinaryExpression implements Expression { protected final Expression left; protected final Expression right; protected BinaryExpression(Expression left, Expression right) { this.left = left; this.right = right; } @Override public final double interpret(Context context) { double l = left.interpret(context); double r = right.interpret(context); return apply(l, r); } protected abstract double apply(double left, double right); }注意interpret加了final,子类只能实现apply。这个约束看起来很细,实际意义不小:它保证所有非终结符的执行顺序都是“先左后右”,不会出现某个子类先算右边的情况。顺序无关痛痒的算术里无所谓,但换成&&、||这种需要短路的逻辑运算,顺序就是正确性问题了。把递归骨架固定在父类,是给未来的自己上保险。
四个子类:
public class AddExpression extends BinaryExpression { public AddExpression(Expression left, Expression right) { super(left, right); } @Override protected double apply(double left, double right) { return left + right; } } public class SubtractExpression extends BinaryExpression { public SubtractExpression(Expression left, Expression right) { super(left, right); } @Override protected double apply(double left, double right) { return left - right; } } public class MultiplyExpression extends BinaryExpression { public MultiplyExpression(Expression left, Expression right) { super(left, right); } @Override protected double apply(double left, double right) { return left * right; } } public class DivideExpression extends BinaryExpression { public DivideExpression(Expression left, Expression right) { super(left, right); } @Override protected double apply(double left, double right) { if (right == 0) { throw new ArithmeticException("除数不能为零"); } return left / right; } }除零检查放在apply里而不是解析阶段,因为解析阶段右子树可能是个变量,值要到运行期才知道。这类“只能运行时判断的错误”就该由节点自己负责,不要上抛给解析器。
2.4 解析器与客户端组装
分词器我手写了一个,没用正则。原因是正则做多字符记号的错误定位很麻烦,一旦输入里有非法字符,你很难告诉用户“第 7 个字符不认识”。手写扫描虽然多几行,但位置信息是免费的。
private static List<String> tokenize(String source) { List<String> tokens = new ArrayList<>(); int i = 0, n = source.length(); while (i < n) { char c = source.charAt(i); if (Character.isWhitespace(c)) { i++; continue; } if (Character.isDigit(c) || c == '.') { int start = i; while (i < n && (Character.isDigit(source.charAt(i)) || source.charAt(i) == '.')) i++; tokens.add(source.substring(start, i)); } else if (Character.isLetter(c) || c == '_') { int start = i; while (i < n && (Character.isLetterOrDigit(source.charAt(i)) || source.charAt(i) == '_')) i++; tokens.add(source.substring(start, i)); } else if ("+-*/()".indexOf(c) >= 0) { tokens.add(String.valueOf(c)); i++; } else { throw new IllegalArgumentException("非法字符 '" + c + "',位置 " + i); } } return tokens; }解析部分就是逐层下降,每个文法层级对应一个方法,方法名和 BNF 里的非终结符一一对应。这种写法看着笨,但调试时极其友好——报错在哪一层,直接看方法名就知道。
public class ExpressionParser { private final List<String> tokens; private int pos; private ExpressionParser(List<String> tokens) { this.tokens = tokens; } public static Expression parse(String source) { if (source == null || source.trim().isEmpty()) { throw new IllegalArgumentException("表达式为空"); } ExpressionParser parser = new ExpressionParser(tokenize(source)); Expression result = parser.parseExpression(); if (parser.hasMore()) { throw new IllegalArgumentException( "无法解析的剩余内容: " + parser.tokens.subList(parser.pos, parser.tokens.size())); } return result; } private Expression parseExpression() { Expression left = parseTerm(); while (hasMore() && ("+".equals(peek()) || "-".equals(peek()))) { String op = next(); Expression right = parseTerm(); left = "+".equals(op) ? new AddExpression(left, right) : new SubtractExpression(left, right); } return left; } private Expression parseTerm() { Expression left = parseFactor(); while (hasMore() && ("*".equals(peek()) || "/".equals(peek()))) { String op = next(); Expression right = parseFactor(); left = "*".equals(op) ? new MultiplyExpression(left, right) : new DivideExpression(left, right); } return left; } private Expression parseFactor() { if (!hasMore()) { throw new IllegalArgumentException("表达式在预期操作数处提前结束"); } String token = next(); if ("(".equals(token)) { Expression inner = parseExpression(); if (!hasMore() || !")".equals(next())) { throw new IllegalArgumentException("缺少右括号"); } return inner; } if ("-".equals(token)) { return new SubtractExpression(new NumberExpression(0), parseFactor()); } if (token.matches("\\d+(\\.\\d+)?")) { return new NumberExpression(Double.parseDouble(token)); } if (token.matches("[A-Za-z_]\\w*")) { return new VariableExpression(token); } throw new IllegalArgumentException("非预期的记号: " + token); } private boolean hasMore() { return pos < tokens.size(); } private String peek() { return tokens.get(pos); } private String next() { return tokens.get(pos++); } }客户端用起来就三行:
Expression expr = ExpressionParser.parse("price * count - discount / 2 + (price - 10)"); Context ctx = new Context().set("price", 120).set("count", 3).set("discount", 40); double result = expr.interpret(ctx);120 * 3 - 40 / 2 + (120 - 10)算出来是 450。手算验证一下:360 - 20 + 110 = 450,对得上。
2.5 测试怎么写才不白写
写完能跑不等于写对。解释器最容易出错的地方是优先级和边界,这两块必须靠用例兜住,靠肉眼 review 是看不出来的。
我一般分三组用例。第一组是优先级,比如2 + 3 * 4必须是 14 而不是 20,10 - 2 - 3必须是 5(左结合)而不是 11。第二组是括号,(2 + 3) * 4必须是 20,嵌套括号((1+2)*(3+4))必须是 21。第三组是异常,空字符串、1 +、(1 + 2、1 @ 2、除零、未定义变量,每一条都要断言抛出异常,而且断言异常消息里带上关键信息。
@Test void 运算符优先级必须由文法保证() { Context ctx = new Context(); assertEquals(14.0, ExpressionParser.parse("2 + 3 * 4").interpret(ctx), 1e-9); assertEquals(20.0, ExpressionParser.parse("(2 + 3) * 4").interpret(ctx), 1e-9); assertEquals(5.0, ExpressionParser.parse("10 - 2 - 3").interpret(ctx), 1e-9); } @Test void 变量未定义必须抛异常() { assertThrows(IllegalArgumentException.class, () -> ExpressionParser.parse("amount * 2").interpret(new Context())); }这三组用例我每次搭新解释器都会先写。经验是:优先级用例能抓出 80% 的文法设计错误,剩下 20% 靠异常用例。有人觉得写测试比写实现还费劲,但解释器这种“错一个层级就全盘皆错”的代码,没有测试基本等于裸奔。
3. 核心细节解析与实操要点
3.1 文法设计的三条硬约束
第一条,每个产生式必须有出口。expression ::= expression这种没有终结符兜底的循环,解析器会一直往下钻直到栈溢出。判断方法很简单:从起始非终结符出发画一遍推导路径,看能不能走到某个终结符。走不到,就是死循环。
第二条,优先级编码在层级里,不要编码在运算符里。这话我说过一遍,这里再说一遍是因为真的很多人踩。有些实现会在interpret里比较运算符的优先级,比如节点持有左右两个运算符,运行时决定先算谁——这本质上是把解析期该做的事推迟到了执行期,每次求值都要做一次比较,而且很难处理括号(括号在运行时没有任何意义,它只影响树的结构)。
第三条,同一层级内的运算符必须是同优先级的。我刚写的第一版文法里,term只放了*和/,但如果有人把%也塞进来,取模和乘除是同级的,没问题;如果哪天想加幂运算^,它比乘除优先级高,就必须单独开一层power ::= factor ("^" factor)*,不能图省事塞进term。
3.2 Context 该放什么,不该放什么
Context 这个角色的定位,GoF 原书里说的是“包含解释器之外的一些全局信息”。这句话非常模糊,我按实际用法给几条明确的标准。
该放的:变量表、符号表、环境配置、当前时间这类所有节点都需要的只读数据。比如风控规则里经常需要currentHour,放 Context 里比在每个节点传递要好。
不该放的:中间计算结果。有些实现会把interpret设计成返回void,结果往 Context 里塞,节点从 Context 里取。这种设计一旦遇到同一子树被两个父节点引用(共享子树是常见优化),中间结果就会互相覆盖,出 bug 的时候极难定位。
线程安全的做法:Expression树设计成不可变,构建一次全局共享;Context每次执行新建一份。这样不需要任何锁。反过来,如果在 Context 里放了可变集合、又在多线程里共享,就必须加同步,性能掉一个数量级。
提示:判断 Context 有没有被写坏,有个简单办法——给 Context 加个只读包装,
interpret内部传进去的是只读副本。真有人往里写,直接抛异常。这个检查在开发期打开,上线关掉即可。
3.3 递归深度与性能的取舍
解释器的执行依赖递归,递归就绕不开栈深度。Java 默认栈大约 512KB 到 1MB,按一次interpret调用消耗 100 字节左右估算,深度几千层就可能出问题。而1 + 1 + 1 + ...这种长链,如果每个+都是一层二叉树,深度就是运算个数的线性叠加。
不过这里有个容易误解的点:左结合的链式运算,树深度其实是右侧嵌套。1+2+3+4解析出来是((1+2)+3)+4,左子树很深,右子树只有一层。而interpret会先递归左子树到底,所以栈深确实等于运算个数。我实测过,大概 3000 个连续加法会触发栈溢出。
解决办法有两个。第一个是压平:把链式的同种运算折叠成一个 N 元节点,内部用循环代替递归。AddExpression改成持有List<Expression>,interpret里循环累加。这一改,深度从线性变成常数。实际项目里用处很大,尤其是批量求和、条件 OR 链这种场景。
第二个是常量折叠,也就是构建完树之后做一遍优化,把所有子节点都是常量的分支直接算掉,替换成一个NumberExpression。配置规则里经常有大量写死的常量,比如1.5 * 100,这类节点在执行期反复算毫无意义,折叠掉能省不少事。
public static Expression fold(Expression e) { if (!(e instanceof BinaryExpression)) { return e; } BinaryExpression b = (BinaryExpression) e; Expression left = fold(b.getLeft()); Expression right = fold(b.getRight()); if (left instanceof NumberExpression && right instanceof NumberExpression) { return new NumberExpression(b.applyValue( ((NumberExpression) left).getValue(), ((NumberExpression) right).getValue())); } return b.rebuild(left, right); }这段代码需要给BinaryExpression额外开三个方法:getLeft、getRight、rebuild,把apply提成包可见。改动不大,但收益明显。我在一个日调用量千万级的计价服务里做过这个优化,单次求值耗时从平均 40 微秒降到 12 微秒,主要省的就是重复的常量计算和 HashMap 查找。
注意:常量折叠必须在解析完成后、缓存之前做,而且折叠后的树依然是不可变的。别边执行边改树,那是给自己埋雷。
3.4 运算符优先级处理的两种思路对比
除了文法分层,还有一种常见做法是调度场算法(Shunting-yard),用两个栈把中缀表达式转成后缀,再求值。两种方案我都写过,对比一下。
| 维度 | 文法分层(递归下降) | 调度场算法 |
|---|---|---|
| 代码结构 | 每个非终结符一个方法,层层对应 BNF | 一个主循环,两个栈,状态机风格 |
| 可读性 | 高,和文法一一对应 | 中,需要理解栈的操作时机 |
| 扩展新运算符 | 加一个文法层级和一个节点类 | 改优先级表,加一个分支 |
| 括号处理 | 天然支持,递归进入即可 | 需要额外压栈标记 |
| 一元运算符 | 容易,在 factor 里加分支 | 麻烦,要区分正负号 |
| 出错定位 | 精确,能报到具体层级 | 粗略,报位置容易飘 |
我的选择是文法分层,理由是可读性和可扩展性都更好,尤其是一元负号这种语法特例,递归下降处理起来几乎不需要额外思考。调度场算法的优势是循环实现、没有递归深度问题,如果你的表达式可能包含上万个运算符,它是更稳的选择。
还有一个方案是引入现成的解析库,比如语法定义工具自动生成解析器。这类工具的收益在文法复杂时才会体现,文法简单的时候反而是负担——你多了一个构建步骤、多了一套调试方式,为了十来条产生式不值当。我的判断线是:文法超过 20 条产生式,考虑引入工具;不到 20 条,手写更快。
4. 解释器模式与相邻模式的边界
4.1 组合模式:结构上的孪生兄弟
初学的时候我一度觉得解释器模式就是组合模式换了个名字,因为两者的类图几乎一模一样:都有一抽象基类,都有叶子节点和容器节点,容器节点持有子节点列表。区别在语义上。
组合模式关心的是结构统一——把叶子和容器一视同仁地对待,用户可以对整棵树调用同一个方法,比如计算文件总大小、渲染整棵 UI 树。它的重点是“遍历”。
解释器模式关心的是语义执行——每个节点不只是被遍历,它要参与计算,而且节点之间有数据流动,父节点的结果依赖子节点的结果。它的重点是“求值”。
这个区别在代码上体现得很直接:组合模式的容器节点通常是“把子节点的结果汇总起来”,逻辑很薄;解释器的非终结符节点必须明确知道自己是二元、三元还是 N 元,知道哪些子表达式在什么条件下被执行(比如短路求值)。
理解了这层关系,你就知道为什么解释器模式的语法树可以直接用组合模式的遍历方式做打印、做统计——结构确实是同一棵,只是用途不同。
4.2 访问者模式:给语法树加新操作
语法树一旦建起来,你会不断想对它做新的事:打印成中缀形式、转成后缀形式、收集用到的变量名、做类型检查、做静态优化。每加一个功能就改一遍所有节点类,代价太高。
访问者模式就是给这种场景准备的。给Expression加一个accept方法,把操作外置。
public interface ExpressionVisitor<R> { R visitNumber(NumberExpression e); R visitVariable(VariableExpression e); R visitBinary(BinaryExpression e); } public interface Expression { double interpret(Context context); <R> R accept(ExpressionVisitor<R> visitor); }然后写一个收集变量名的访问者,十来行就搞定:
public class VariableCollector implements ExpressionVisitor<Set<String>> { @Override public Set<String> visitNumber(NumberExpression e) { return Collections.emptySet(); } @Override public Set<String> visitVariable(VariableExpression e) { return Collections.singleton(e.getName()); } @Override public Set<String> visitBinary(BinaryExpression e) { Set<String> result = new HashSet<>(); result.addAll(e.getLeft().accept(this)); result.addAll(e.getRight().accept(this)); return result; } }这个能力在实际项目里非常有用:规则保存到数据库之前,先跑一遍变量收集,校验所有变量都在数据字典里注册过,避免运行期才发现配置错误。
代价也要说清楚。第一,Expression接口多了accept方法,节点类从五个变成五个加一段样板代码;第二,每新增一种节点类型,所有访问者实现都要跟着改,这是访问者模式最被诟病的地方。如果节点类型相对稳定、操作经常增加,用访问者;反过来节点类型天天变,就在Expression上直接加方法更省事。
提示:节点类型和操作哪个更稳定,这个问题值得在白板上想五分钟。我的经验是,表达式语法一旦定下来,半年内基本不动,而操作(打印、校验、优化、翻译成 SQL)会持续加。所以我倾向于用访问者。
4.3 与策略、工厂、模板方法的配合边界
有同学会问:我在解析器里用if ("+".equals(op))决定 new 哪个类,这不就是工厂干的事吗,要不要抽个工厂?
我的答案是:不要。工厂模式的价值在于把“创建逻辑”和“使用逻辑”解耦,当创建逻辑复杂到需要多个分支、依赖外部配置、需要缓存实例的时候才有意义。解析器里的创建逻辑就是四个if,一眼看得完,抽出去反而让人多跳一个文件。设计模式不是凑数游戏,能一眼读懂的if就别包装。
策略模式的边界更清晰一些:策略解决的是“同一件事有多个算法,运行时选一个”,比如运费计算有顺丰、圆通、韵达三套算法,选哪套是外部决定的。解释器解决的是“一件事由若干子步骤按语法结构组合而成”,是一变多、多重组。真要结合,可以让某个非终结符节点内部持有一个策略对象,但那属于局部优化,不是模式替代。
模板方法倒是真的在解释器里用上了,就是前面BinaryExpression里那个final interpret加抽象apply的组合。骨架固定、细节外放,这就是模板方法。不用刻意声明“我在这里用了模板方法”,但知道它是它,有助于你判断final到底该不该加。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 现象 | 最可能原因 | 解决方向 |
|---|---|---|
| 栈溢出 StackOverflowError | 长链运算导致树过深,或文法里写了左递归 | 压平链式节点,检查产生式是否左递归 |
| 结果差一个数量级 | 优先级层级设计错误,乘除和加减同级了 | 对照 BNF 检查层级,补优先级用例 |
| 解析完不报错但结果不对 | 解析后没检查是否有剩余 token | parse末尾断言pos == tokens.size() |
| 括号嵌套多了就崩 | 括号处理递归进了错误的层级 | 括号内应重新调用parseExpression而非parseFactor |
| 变量未定义却算出 0 | Context 取值返回了默认值 | 改成查不到直接抛异常 |
| 多线程下结果抖动 | Context 或语法树被共享且可变 | 树不可变 + Context 每线程一份 |
| 小数算出来有 0.30000000000000004 | double的固有精度问题 | 金额场景换BigDecimal,全链路替换 |
| 性能越来越慢 | 每次求值都重新解析字符串 | 解析一次缓存 AST,执行时才传 Context |
| 报错信息只说“解析失败” | 异常里没带位置和 token | 分词阶段记录 offset,异常消息带上位置 |
这个表里的每一行我基本都遇到过。最想说的是最后一行,报错信息这个事。你想,业务方配错了一段规则,系统只回一句“表达式格式错误”,他改都不知道从哪改。后来我在异常里加上“位置 7 附近的 '+' 后面缺少操作数”,运维支持工单直接少了八成。
5.2 几处踩过才知道的细节
Parser 是有状态的,不能复用。它持有一个pos游标,解析完一条表达式之后游标停在末尾。有人为了省对象创建把 Parser 做成单例,第二条表达式直接就乱了。要么每次 new,要么在parse开头把pos归零。我选前者,因为 Parser 创建的代价远小于一次解析。
空字符串和纯空格要单独判断。" "分词之后是空列表,parseFactor一进去就next()越界。这个分支必须在入口就拦掉,别指望后面的逻辑兜底。
全角字符是隐形杀手。用户从文档里复制公式,经常带全角空格、全角括号、全角加号。分词器如果不处理,会报“非法字符”,但人眼看上去一模一样,排查能耗掉半天。我的做法是在分词前统一做一次字符归一化,把常见全角符号转半角,顺手把中文逗号也处理掉。
除法的语义要提前定。double除法保留小数,BigDecimal除法如果不指定精度会在无限小数时抛ArithmeticException。金额场景我用BigDecimal并且固定 4 位小数、HALF_UP舍入,这个规则必须写进文档,否则财务对账差一分钱都要扯皮。
.matches()在解析热路径上很慢。parseFactor里用两次正则判断数字和变量,看着简洁,压测的时候发现占了总耗时的三成。后来换成手写字符判断,耗时降到原来的四分之一。这个优化不用一开始就做,但性能敏感的场景要留个心眼。
别急着支持函数调用。一旦支持max(a, b)这种函数,你就得引入参数列表解析、函数注册表、参数个数校验,工作量翻三倍,而收益往往是“看起来更专业”。我见过一个项目为了支持两个函数写了两千行代码,最后那两个函数一次都没被用过。先把手头的文法做扎实,函数这种扩展放到后面的版本再说。
我在实际项目里的体会是,解释器模式的难点从来不在那四个角色的类图上,而在于“文法—解析—执行”这条链路的每一环都要想清楚,任何一环偷懒,后面都会以 bug 的形式还回来。先用一张便利贴把 BNF 写清楚,再写分词和递归下降,最后才去套模式的结构,顺序反了就会一直在“树怎么长出来”这个问题上打转。至于规则引擎、查询 DSL 这些更复杂的场景,等基础这层跑通了,往上加节点类型、加访问者做优化,其实都是顺水推舟的事。