☰
从规则配置到表达式求值:手写解释器模式与语法树解析
2026/10/26 19:14:35 网站建设 项目流程

做过几年业务系统的人,多少都会撞上一个相似的坎:需求方希望某些逻辑能自己配,而不是每次改一行判断就提一次发版。我最早接触解释器模式,就是在一个促销规则模块里被逼出来的。当时的诉求很朴素——运营能在后台写"订单金额大于 500 且 会员等级等于 3"这种条件,系统照着判断就行。第一版我们用字段名、运算符、值三段式存数据库,简单条件还能凑合,一碰到带括号的嵌套组合就直接崩盘,谁先算谁后算根本表达不出来。后来把整串条件文本解析成一棵语法树,让每个节点自己回答"我这段值是多少",代码一下子顺了,那套东西本质上就是解释器模式最朴素的样子。它解决的问题只有一个:把用某种小语言写成的文本,变成程序能理解、能执行的结构。这里的"语言"不需要多复杂,几个算术公式、一串布尔条件、一段筛选语法都算数。这篇内容面向的是已经能写面向对象代码、但一遇到"规则要动态变、用户要自己配"就卡住的中级开发者,读完你应该能独立写出一个支持加减乘除、括号、一元负号和自定义变量的表达式求值器,并且清楚它在什么工况下会反过来咬你一口。

1. 解释器模式到底在解决什么问题

1.1 从一段硬编码判断的演变过程说起

任何模式的出现都不是因为教科书需要一个条目,而是因为某类重复劳动实在让人受不了。我拿一个最常见的场景拆给你看。假设有个订单折扣逻辑,最初长这样:

if (order.getAmount() > 500 && order.getUserLevel() == 3) { return order.getAmount().multiply(new BigDecimal("0.9")); }

这时候一切安好。第二周产品说,会员等级 2 也要打折,但门槛提到 800;第三周说,新客首单不看金额;第四周说,某些品类不参与。你会发现 if 里的条件越来越长,&&和||混在一起,括号套括号,改一次就要通读一遍,生怕把老条件改坏。这就是硬编码判断的第一个崩溃点:组合爆炸。条件不再是线性的,而是树状的,但代码结构还是线性的。

第二个崩溃点更致命:改一次就要发一次版。业务方等不起,研发也烦。于是自然想到配置化,把条件抽到数据库或配置文件里。到这里为止,很多人会栽进一个坑——用"字段名 + 运算符 + 值"的三段式结构存条件,然后 Java 里跑一个 switch 去拼 SQL 或者拼判断。单条没问题,一旦需求方写出(A 且 B) 或 (C 且 D 且 E),这套结构就完全表达不了优先级,因为它是平的,缺少嵌套能力。

要表达嵌套,就必须有树。而只要有树,就需要一套文法来描述树怎么长、节点怎么算。解释器模式就是"用面向对象的方式描述一套文法,并让文法里的每个符号都知道自己怎么执行"。它把"解析"和"执行"这两件事,拆成了可以分别扩展的两块,这才是它真正的价值所在,而不是什么炫技。

1.2 把"解释"这个动作拆成三步:词法、语法、求值

初学者最容易犯的错,是把解释器模式理解成"一个巨大的 switch 或一堆 if-else"。其实标准的解释流程分三段,每一段职责非常清晰,混在一起就必然烂掉。

第一步是词法分析,行话叫 Lexer 或 Tokenizer。它的任务是把一坨字符流切成一串有意义的词元,比如金额 > 500切成[标识符:金额] [大于号] [数字:500]。这一步不关心语法对错,只关心"这几个字符连起来是个什么东西"。第二步是语法分析,行话叫 Parser,把词元序列按照文法组织成抽象语法树(AST)。这一步才开始判断"大于号后面跟个数字合不合法"。第三步才是求值,也就是模式里那个interpret方法,遍历语法树算出结果。

为什么必须拆开?因为它们的变化频率完全不同。词法层可能因为支持新的字面量(比如日期常量)而变,语法层可能因为新增运算符而变,求值层可能因为业务口径调整而变。三层揉在一起,任何一处改动都会牵一发动全身。我见过一个团队把这三步全写在interpret方法里,结果加一个>=运算符要改四个地方,最后没人敢动。

提示:判断一段代码是不是"真解释器",看它有没有独立的语法树结构。如果只是把字符串 split 完 switch 一下,那是字符串处理,不是解释器模式。

1.3 五个角色与它们的职责边界

解释器模式的标准角色划分不复杂,但每个角色的边界在哪里,直接决定了你的代码能不能活过第二个迭代周期。

抽象表达式(AbstractExpression)定义统一的解释接口,通常就一个方法,比如interpret(Context ctx)。这个接口的所有实现都必须能接受同一个上下文对象。

终结符表达式(TerminalExpression)对应文法里不能再拆的最小单元,比如一个数字、一个变量名。它的interpret通常直接返回自身持有的值,或者去上下文里查一下。

非终结符表达式(NonterminalExpression)对应文法里的组合规则,比如加法、加法左边的表达式和右边的表达式。它的interpret会递归调用子节点的interpret,再把结果合并。这里就是模式的核心——树的递归。

上下文(Context)存放解释器之外的全局信息,最典型的就是变量表。它有点像执行环境,终结符从这里取数据,非终结符从子节点拿结果。

客户端(Client)负责构建语法树并触发求值。它通常是你的 Parser,也可能是缓存好的语法树复用方。

有个细节值得单独说:上下文到底该不该放进interpret的参数里。有些实现把上下文做成成员变量,看着省事,但一旦你的解释器需要并发执行、或者同一棵树要在不同变量环境下求值多次,就会出问题。我做过的几个项目里,上下文一律作为方法参数传入,语法树本身保持无状态,这样同一棵树可以被不同请求安全复用,也方便做缓存。

1.4 什么工况该用它,什么工况赶紧绕道

任何模式都有适用范围,解释器模式的边界其实相当明确。

适合用的信号有这几个:文法规则确实小,比如表达式运算、布尔条件、简单的筛选语法,规则数量在几十个节点这个量级;规则需要动态变化,改规则不该改代码、不该重新编译;执行频率不高,或者说性能不是首要瓶颈;你希望规则本身具有可读性,能被打出来、被日志记录、被可视化。

该绕道的信号同样清晰。第一,文法复杂度一旦上升,类数量会爆炸。每个语法规则都要一个类,加减乘除四个类,再加括号、函数调用、数组下标、类型转换,很快就三四十个类,维护成本陡增。第二,性能敏感场景别硬上。解释器模式走的是递归遍历语法树,每个节点一次虚方法调用,相比直接编译成字节码或者生成一段可执行的闭包,慢一到两个数量级是常态。第三,文法里有左递归、有复杂上下文相关的规则,手写递归下降解析会非常难受,这时候老老实实上解析器生成工具更划算。

我个人的判断标准是一句话:如果你的规则用一页纸能写完文法,写解释器;写不完,去找工具。这个界限简单粗暴但好用。

2. 抽象表达式与终结符:把最小骨架立起来

2.1 interpret 方法该不该抛受检异常

先看接口设计。绝大多数教科书给的签名是Object interpret(Context ctx),我建议改两处。

第一处,返回值类型别用Object。用Object意味着每个调用点都要强转,运行期才能发现类型不对。如果你的解释器只做数值求值,直接定BigDecimal;如果做条件判断,定boolean。能编译期确定的类型,就别扔给运行期,这是我在几个规则引擎项目里用血换来的教训——Object返回值带来的 ClassCastException 排查成本极高,因为堆栈里根本看不出是哪个节点转错了。

第二处,异常。interpret该不该声明受检异常?我的做法是不声明受检异常,但抛一个自定义的运行时异常,比如InterpretException,并且携带出错位置信息。理由是:解释失败在业务语义上属于"输入不合法",用户写错了规则,这跟文件读不到、网络断了是两类事,不该强迫每个调用方都去 try-catch。但自定义异常里必须带位置,否则用户反馈"我的规则算错了",你完全无从下手。

public interface Expression { BigDecimal interpret(Context ctx); }

就这两行,是整个模式的地基。接口越薄,实现越自由。

2.2 数字与变量:两个终结符的写法差异

终结符里最典型的是两种:字面量和变量。它们的实现都是"返回一个值",但细节差别很大。

public class NumberExpression implements Expression { private final BigDecimal value; public NumberExpression(BigDecimal value) { this.value = value; } @Override public BigDecimal interpret(Context ctx) { return value; } }

数字节点是完全无状态的,同一个实例可以被无数棵树共享,甚至可以做常量池缓存。这种节点天然线程安全。

public class VariableExpression implements Expression { private final String name; public VariableExpression(String name) { this.name = name; } @Override public BigDecimal interpret(Context ctx) { BigDecimal v = ctx.lookup(name); if (v == null) { throw new InterpretException("变量未定义: " + name); } return v; } }

变量节点持有一个名字,求值时去上下文里查。这里有个坑:变量查不到时返回null还是抛异常?如果返回null,后面做加法时会变成空指针,堆栈指向加法节点,排查的人会以为加法写错了。在源头抛异常,带上变量名,这是唯一正确的做法。

还有个更隐蔽的坑:变量名大小写。如果配置平台允许用户在页面上手输变量名,很容易出现amount和Amount混用。我在一个项目里就吃过这个亏,运营写了一个大写开头的变量,系统里没定义,报错信息又不明显,来回扯了两天。后来统一在词法层做小写归一化,并且上下文里维护一份"可用变量清单",报错时把清单打出来,问题就再没复现过。

2.3 上下文对象:看起来简单,最容易设计歪

上下文的对象设计,是决定这套解释器能不能长期用的关键。我给三个原则。

原则一:上下文只做数据存取,不做业务计算。很多人喜欢在上下文里塞各种辅助方法,比如getAmountWithTax(),理由是"求值时方便"。这会让求值逻辑分散在节点和上下文两处,最后没人能说清一个表达式到底依赖了哪些数据。上下文保持成一个Map 加少量元信息,最干净。

原则二:取值要能报错,也要能列出全集。前面说的"变量清单"就是这条的体现。上下文应该提供lookup(name)和keySet()两个能力,前者供求值,后者供报错提示和依赖分析。

原则三:上下文要能被序列化传递。如果规则求值发生在异步线程、或者在分布式节点上,上下文需要能跨线程甚至跨进程传。这就要求里面只放基本类型和字符串,别放数据库连接、别放 HttpServletRequest。

public class Context { private final Map<String, BigDecimal> variables; public Context(Map<String, BigDecimal> variables) { this.variables = Collections.unmodifiableMap(new HashMap<>(variables)); } public BigDecimal lookup(String name) { return variables.get(name); } public Set<String> keySet() { return variables.keySet(); } }

Collections.unmodifiableMap加一层拷贝,是为了防止求值过程中有人偷偷改上下文导致结果诡异。防御性拷贝这点开销,在规则求值这种量级下完全不值一提。

2.4 第一个能跑起来的版本

骨架有了,先拼一个只用终结符的最小版本验证一下:

Context ctx = new Context(Map.of("price", new BigDecimal("128.00"))); Expression e = new VariableExpression("price"); System.out.println(e.interpret(ctx)); // 128.00

单节点没有递归,看不出模式的好处,但这一步的意义在于把接口、上下文、异常三条线打通。我习惯在写任何复杂结构之前,先把最小闭环跑通,哪怕它只有一行输出。因为一旦后续出问题,你至少知道地基是稳的,排查范围可以收窄。

注意:这一步不要急着写非终结符。很多人一上来就想把加减乘除全写完,结果接口设计的小毛病被复制到十几个类里,改起来非常痛苦。

3. 非终结符与递归组合:让语法树真正长起来

3.1 二元运算节点的统一抽象

四个四则运算节点长得几乎一样,区别只在interpret里那一行运算。直接复制四份代码当然能跑,但抽出公共父类更划算,尤其是后面要做节点遍历、打印、统计的时候。

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 BigDecimal interpret(Context ctx) { BigDecimal l = left.interpret(ctx); BigDecimal r = right.interpret(ctx); return doInterpret(l, r); } protected abstract BigDecimal doInterpret(BigDecimal l, BigDecimal r); }

这个设计里有个关键点:interpret声明成final,把"先递归求两个子节点,再合并"这个模板固定下来,子类只负责doInterpret这一行真正的运算。这是模板方法模式的味道,用在这里非常合适,因为它保证了所有二元运算的求值顺序一致——都是先左后右,且不会有人手滑写成先右后左。

public class AddExpression extends BinaryExpression { public AddExpression(Expression left, Expression right) { super(left, right); } @Override protected BigDecimal doInterpret(BigDecimal l, BigDecimal r) { return l.add(r); } }

减法、乘法同理。加法减法直接add和subtract,没有问题。除法和乘方必须显式指定精度,这是 BigDecimal 的老规矩:

public class DivExpression extends BinaryExpression { public DivExpression(Expression left, Expression right) { super(left, right); } @Override protected BigDecimal doInterpret(BigDecimal l, BigDecimal r) { if (r.compareTo(BigDecimal.ZERO) == 0) { throw new InterpretException("除数不能为零"); } return l.divide(r, 10, RoundingMode.HALF_UP); } }

保留 10 位小数、四舍五入,这个选择不是随便定的。金额类计算通常要求 2 位,但中间过程多留几位可以防止误差累计;如果最终结果要展示,再单独做一次setScale(2, HALF_UP)。不要在每一步运算里都截断到 2 位,否则(1/3)*3会得到 0.99 而不是 1.00,用户一眼就能看出问题。

3.2 递归求值时栈到底长什么样

这部分光看代码容易糊,我拿1 + 2 * 3手动走一遍。

树的结构是Add(Number(1), Mul(Number(2), Number(3)))。调用根节点的interpret,进入模板方法,先求左子树,Number(1)直接返回 1,栈上压了一帧。再求右子树,进入Mul的interpret,又压一帧,它再去求Number(2)和Number(3),各压一帧,分别返回 2 和 3。Mul的doInterpret算出 6 返回,右子树这一帧弹出。最后Add的doInterpret拿到 1 和 6,算出 7。

整个过程中,栈深度等于树的高度。线性表达式比如1+2+3+4+...+1000,如果语法树是左深链结构,栈深度就是 1000。这就是为什么长表达式可能触发StackOverflowError。我在一个客户现场见过运营配置了一条上千项求和的公式,直接让线程栈爆掉,日志里只有一行StackOverflowError,完全看不出是哪条规则,排查花了整整一个下午。

顺带说一句,递归求值还有一个隐含假设:每个节点只求值一次。上面模板方法里left.interpret和right.interpret各调一次,符合这个假设。但如果你在某个自定义节点里写了if (left.interpret(ctx) > 0) ... else left.interpret(ctx),那就求了两次,遇到有副作用的节点(比如查数据库)就会出问题。这条约束要在团队里讲清楚。

3.3 类数量失控的苗头与组合模式的影子

写到这儿,你大概感觉到了:每加一种运算就要加一个类。现在已经有 5 个类了,再加上比较运算、逻辑运算、函数调用,二三十个类跑不掉。这就是解释器模式最常被诟病的地方——类爆炸。

有意思的是,这棵树的结构和组合模式几乎一模一样。组合模式里,容器节点持有一组子节点,叶子节点持有数据,两者实现同一个接口。解释器模式里,非终结符持有子表达式,终结符持有字面量,同样实现同一个接口。区别只有一点:组合模式关注"结构上能一视同仁地遍历",解释器模式关注"语义上能递归地计算"。实际项目里,这两个模式经常是同一套类,只是你从哪个角度去用它。

类爆炸的解法有两个方向。一个方向是延迟到运行期,别为每种运算单独建类,改成BinaryExpression持有一个BiFunction<BigDecimal, BigDecimal, BigDecimal>,运算逻辑以函数形式注入。这样四则运算变成四个静态工厂方法,类数量从 N 降到常数。代价是牺牲了一点可读性和调试友好度,因为断点打不到具体的类里。另一个方向是引入访问者模式,把求值操作从节点类里抽出来,节点只负责结构,这样增加"打印成字符串""转成 SQL""做静态检查"这些操作时不用改节点类。这个思路我在规则引擎项目里用过,效果不错,但一次性代码量不小,小项目不值得。

我的习惯是:运算种类少于 10 种,老老实实建类;超过 10 种,改成函数注入。经验之谈,不一定通用。

4. 从字符串到语法树:词法与递归下降解析

4.1 手写词法分析器:一个状态机就够

前面所有内容都假设语法树已经存在,实际业务里树是从字符串来的。词法分析器说白了就是个状态机,从头扫到尾,遇到数字就一路吃进所有数字字符,遇到字母就一路吃进所有字母数字下划线,遇到符号就单独成一个词元。

public enum TokenType { NUMBER, IDENT, PLUS, MINUS, STAR, SLASH, LPAREN, RPAREN, EOF } public class Token { public final TokenType type; public final String text; public final int pos; public Token(TokenType type, String text, int pos) { this.type = type; this.text = text; this.pos = pos; } @Override public String toString() { return type + "('" + text + "')@" + pos; } }

pos字段必须留,它是后面报错定位的唯一依据。没有位置信息的解析器,用户一旦写错就只能在黑箱里瞎猜,体验极差。

public class Lexer { private final String src; private int pos = 0; public Lexer(String src) { this.src = src; } public List<Token> tokenize() { List<Token> list = new ArrayList<>(); while (pos < src.length()) { char c = src.charAt(pos); if (Character.isWhitespace(c)) { pos++; continue; } if (Character.isDigit(c) || c == '.') { list.add(readNumber()); continue; } if (Character.isLetter(c) || c == '_') { list.add(readIdent()); continue; } switch (c) { case '+': list.add(new Token(TokenType.PLUS, "+", pos++)); break; case '-': list.add(new Token(TokenType.MINUS, "-", pos++)); break; case '*': list.add(new Token(TokenType.STAR, "*", pos++)); break; case '/': list.add(new Token(TokenType.SLASH, "/", pos++)); break; case '(': list.add(new Token(TokenType.LPAREN, "(", pos++)); break; case ')': list.add(new Token(TokenType.RPAREN, ")", pos++)); break; default: throw new ParseException("无法识别的字符 '" + c + "',位置 " + pos); } } list.add(new Token(TokenType.EOF, "", pos)); return list; } private Token readNumber() { int start = pos; while (pos < src.length() && (Character.isDigit(src.charAt(pos)) || src.charAt(pos) == '.')) { pos++; } return new Token(TokenType.NUMBER, src.substring(start, pos), start); } private Token readIdent() { int start = pos; while (pos < src.length() && (Character.isLetterOrDigit(src.charAt(pos)) || src.charAt(pos) == '_')) { pos++; } return new Token(TokenType.IDENT, src.substring(start, pos), start); } }

注意最后一定要补一个EOF词元。这是个很小的细节,但作用很大——解析器可以靠它判断"是不是还有剩",不用到处做ptr < size的边界检查,代码会干净很多。

还有个容易忽略的点:readNumber里允许两个小数点通过。1.2.3会被当成一个合法数字词元,然后在new BigDecimal那一步才炸。更好的做法是在词法层就校验格式,用正则\d+(\.\d+)?匹配,不匹配直接报错。错误发现得越早,用户越容易理解。

4.2 用 BNF 把优先级写进函数层级

语法分析最容易写错的地方就是优先级。很多人第一反应是"先解析出一堆节点,再按优先级排序重组",这条路极其容易写崩。正确姿势是递归下降 + 分层函数,优先级直接体现在函数调用的层次里。

先写文法:

expression ::= term (('+' | '-') term)* term ::= factor (('*' | '/') factor)* factor ::= NUMBER | IDENT | '(' expression ')' | '-' factor

这份 BNF 里,expression调term,term调factor,层次越深优先级越高。乘法在term这一层,加法在expression这一层,所以乘法天然结合得更紧。括号通过factor回到最外层的expression,实现优先级重置。一元负号放在factor里递归调用自己,正好处理-(-x)这种。

这种写法的精髓在于:优先级不是算出来的,是"调出来的"。你不需要维护什么优先级表,函数调用的嵌套顺序就是优先级。

public class Parser { private final List<Token> tokens; private int ptr = 0; public Parser(List<Token> tokens) { this.tokens = tokens; } public Expression parse() { Expression e = parseExpression(); expect(TokenType.EOF); return e; } private Expression parseExpression() { Expression left = parseTerm(); while (peek().type == TokenType.PLUS || peek().type == TokenType.MINUS) { Token op = next(); Expression right = parseTerm(); left = op.type == TokenType.PLUS ? new AddExpression(left, right) : new SubExpression(left, right); } return left; } private Expression parseTerm() { Expression left = parseFactor(); while (peek().type == TokenType.STAR || peek().type == TokenType.SLASH) { Token op = next(); Expression right = parseFactor(); left = op.type == TokenType.STAR ? new MulExpression(left, right) : new DivExpression(left, right); } return left; } private Expression parseFactor() { Token t = peek(); if (t.type == TokenType.MINUS) { next(); return new NegateExpression(parseFactor()); } if (t.type == TokenType.NUMBER) { next(); return new NumberExpression(new BigDecimal(t.text)); } if (t.type == TokenType.IDENT) { next(); return new VariableExpression(t.text); } if (t.type == TokenType.LPAREN) { next(); Expression inner = parseExpression(); expect(TokenType.RPAREN); return inner; } throw new ParseException("意外的词元 " + t); } private Token peek() { return tokens.get(ptr); } private Token next() { return tokens.get(ptr++); } private void expect(TokenType type) { Token t = tokens.get(ptr); if (t.type != type) { throw new ParseException("期待 " + type + ",实际是 " + t); } ptr++; } }

4.3 一元负号与括号:两个最容易翻车的细节

先说一元负号。-3和5 - 3里的减号长得一模一样,怎么区分?答案是看上下文。在递归下降里这个判断天然存在:解析factor的时候,如果遇到减号,说明它出现在一个"期待操作数"的位置,那它只能是一元负号;而解析expression的循环里遇到的减号,出现在"操作数已解析完"的位置,那就是二元减法。同一个符号,位置决定了语义,这就是为什么分层函数能把这件事处理得这么干净。

再说括号。括号的处理看起来很不起眼,实际上它承担了"打断递归层级"的职责。进入factor遇到左括号,就递归调回parseExpression,等于把优先级重置到最低。如果括号没闭合,expect(RPAREN)会抛异常,报错位置也准确。这里有个优化点值得做:在expect里判断当前是不是 EOF,如果是,报"括号未闭合"而不是"期待右括号,实际是 EOF",后者对用户不友好。

4.4 完整跑一遍:执行记录与结果验证

拿一条真实表达式走一遍完整的流程:

String src = "price * (1 - discount) - 10 / 2"; Lexer lexer = new Lexer(src); List<Token> tokens = lexer.tokenize(); Expression tree = new Parser(tokens).parse(); Map<String, BigDecimal> vars = new HashMap<>(); vars.put("price", new BigDecimal("200")); vars.put("discount", new BigDecimal("0.15")); Context ctx = new Context(vars); System.out.println(tree.interpret(ctx)); // 200 * 0.85 - 5 = 165.00

词元序列是[IDENT(price), STAR, LPAREN, NUMBER(1), MINUS, IDENT(discount), RPAREN, MINUS, NUMBER(10), SLASH, NUMBER(2), EOF]。

解析过程:parseExpression先调parseTerm,parseTerm先调parseFactor拿到VariableExpression(price),回到parseTerm的循环,看到STAR,吃进去,再调parseFactor,这次遇到LPAREN,递归回parseExpression,内部完整解析出1 - discount,expect掉右括号后返回。parseTerm继续循环,下一个词元是MINUS,不属于乘法层级,循环退出,返回Mul(price, Sub(1, discount))。回到最外层parseExpression的循环,看到MINUS,吃进去,再调parseTerm,解析出10 / 2,最终得到Sub(Mul(price, Sub(1, disc)), Div(10, 2))。

求值:先算Sub(1, 0.15) = 0.85,再算Mul(200, 0.85) = 170.00,再算Div(10, 2) = 5,最后170.00 - 5 = 165.00。

这条链路跑通,整个模式的核心就闭环了。我建议你在这一步多做一件事:把语法树打印出来看看。打印逻辑可以简单写一个递归方法,或者临时在Expression上加个toString。肉眼看一眼树的结构,比在脑子里模拟递归靠谱得多,很多优先级错误一眼就能看出来。

5. 常见问题与排查技巧实录

5.1 递归深度失控与左递归陷阱

先说最凶的那个:StackOverflowError。

有两种成因。第一种是表达式太长。前面分析过,栈深度等于树高,一条几千项的求和公式如果是左深链,递归深度就是几千。JVM 默认栈大小一般在 512KB 到 1MB,每帧占多少取决于局部变量表大小,几百到几千层不等。解决办法不是加大-Xss(那只是把爆点推后),而是改成显式栈迭代求值,或者把左深链平衡化。迭代版本实现起来有点绕,需要后序遍历,我一般先用深度限制兜底——解析时统计节点数,超过 500 个直接拒绝并提示"表达式过长,请拆分"。

第二种是左递归。如果你把文法写成expression ::= expression '+' term | term,那parseExpression第一件事就是调自己,无限递归,一启动就爆。这是教材里最容易误导人的地方,因为它数学上完全正确,但递归下降实现不了。递归下降必须把左递归改写成循环,就是把expr op term的重复部分用while表达,这正好对应我上面parseExpression里那个while的写法。很多人第一次写解析器栽在这一步,而且报错现象是启动即崩,很容易误以为是环境问题。

5.2 解析错误定位的实用手法

用户写错了规则,你给的报错信息质量,直接决定了你要被叫去现场几次。

最差的做法是抛一个IllegalArgumentException("表达式不合法"),用户完全不知道哪里错。稍微好一点是带上词元,"意外的词元 NUMBER('2')@18"。最好的做法是带上原始字符串、位置、以及一个指向标记:

String src = "price * (1 - discount"; // 报错:括号未闭合,位置 18 // price * (1 - discount // ^

这个^标记法成本极低,收益极高。我在一个后台系统里加了这行之后,运营自助排查率明显上升,因为一眼就能看到问题在哪。

另外建议在ParseException里同时带两类信息:技术信息(词元、位置)和业务信息(比如"可用变量有:price、discount、level")。技术信息给研发看,业务信息直接透给用户看。分两个字段存,前端按需展示。

5.3 常见问题速查表

现象常见根因排查与处理
启动即StackOverflowError文法写了左递归把expr ::= expr op term改写为循环形式
长表达式求值栈溢出语法树退化成左深链限制节点数、平衡化、或改迭代求值
计算结果与手算不符优先级分层写错打印语法树,肉眼看层级是否符合预期
1+2*3得到 9乘法没在更深的函数层级按 BNF 拆成三个函数,乘法放在term
变量报"未定义"但明明传了大小写不一致或前后空格词法层统一归一化,报错时打印可用变量清单
1.2.3被当成数字词法层校验缺失用正则\d+(\.\d+)?校验格式
除以零时不报错,结果是 Infinity用了double而非BigDecimal统一BigDecimal,除法显式校验并指定精度
同一棵树并发求值结果错乱上下文被做成成员变量上下文一律作为方法参数传入,树保持无状态
括号嵌套多层报"意外词元"expect(RPAREN)失败但报错不友好判断是否已到 EOF,单独报"括号未闭合"
加一元负号后减法全乱未区分符号位置语义一元负号只在factor层处理

5.4 我在实际项目里踩过的几个坑

第一个坑:把求值写成有副作用。早期我们在变量节点里做了一次"查汇率"的远程调用,因为变量值本身是动态算出来的。结果同一个变量在表达式里出现两次,就查了两次,两次汇率还不一样,算出来的数自相矛盾。后来改成上下文预加载好所有变量值,求值阶段纯内存计算,问题消失。这条经验我后来反复用——解释器只解释,不获取,取数是外层的事。

第二个坑:借用了缓存但忘了树的无状态约束。语法树解析一次后缓存起来复用,这个优化效果很明显,尤其规则模板被高频调用的时候。但我们早期某个节点里缓了"上次的计算结果"当加速,复用时直接返回旧值,出过一次相当严重的线上问题。树必须绝对无状态,任何缓存放在树外面,按"树 + 上下文"的组合做 key。

第三个坑:错误处理吞掉了根因。有个同事在interpret外面套了一层 try-catch,统一转成"规则执行失败",理由是"用户看不懂技术报错"。结果所有问题都变成这一句话,连我们自己都查不出来。正确做法是保留原始异常作为 cause 往上抛,只在展示层做友好化包装。

第四个坑:忽略空表达式。用户提交了一条空规则,词法分析出[EOF],parseFactor遇到 EOF 直接抛"意外的词元",报错信息让用户一脸茫然。加一个前置判断,空串直接返回一个Boolean.TRUE之类的恒真表达式,体验立刻好很多。

提示:这四个坑有个共同点,都不是算法问题,而是"边界和约束没想清楚"。写解释器的时候,先把接口约束(纯函数、无状态、异常带上下文)定死,比写代码本身重要得多。

6. 解释器模式的边界与替代方案

6.1 与组合、策略、访问者的关系梳理

这几个模式经常被混在一起问,我按自己的理解划一下界限。

组合模式关注结构统一。它让容器和叶子实现同一个接口,从而可以一视同仁地递归处理。解释器模式的语法树就是用组合模式搭起来的,Expression接口、BinaryExpression容器、NumberExpression叶子,这套结构和组合模式完全一致。区别在于语义落点:组合模式关心"我能不能把整棵树当成一个元素来操作",解释器关心"这棵树算出来是多少"。

策略模式关注横向替换。同一个接口,多种算法实现,运行期选一个用。它是扁平的、一次性的,不涉及递归。解释器是纵向的、递归的、自组合的,这跟策略完全是两个维度。有人把运算节点当成策略来理解,逻辑上说得通(每个节点确实是一种"运算策略"),但会误导人忽略最关键的递归组合特性。

访问者模式关注操作与结构的分离。当你的语法树需要支持越来越多种操作(求值、打印、生成 SQL、做静态校验),把这些操作全塞进节点类会让节点类膨胀。访问者的做法是把操作外置,节点只提供一个accept(Visitor)方法。这个方案在规则引擎类项目里非常实用,代价是节点类要开一个口子,而且新增节点类型时要改所有访问者。

我用一张表总结:

模式解决的问题与解释器的关系
组合模式树形结构统一处理解释器的语法树骨架
策略模式算法横向替换维度不同,容易混淆但不相关
模板方法固定流程,变化步骤用于BinaryExpression固化求值顺序
访问者模式操作与结构分离解释器类爆炸时的解法之一

6.2 文法一旦复杂起来,有几条出路

解释器模式撑不住的临界点大概在哪里?我的经验是:运算符超过 15 种、出现函数调用、出现类型系统、或者出现上下文相关的规则,就该考虑换路了。

第一条出路是引入成熟的解析器生成工具。你写文法,工具生成词法和语法分析器代码。这条路的好处是文法即文档,可读性极高,而且生成的解析器经过大量验证,边界处理比自己手写靠谱。代价是多一个构建步骤,调试起来没那么直观(生成代码断点难打)。

第二条出路是用现成的表达式求值库。这类库通常支持变量、函数、条件、集合操作,直接嵌进项目就能用。适合的场景是"我只需要一个能算数的东西,不想自己造"。风险在于能力边界和可控性——有些库语法糖很多,但错误信息很烂,出问题的时候你只能连蒙带猜。

第三条出路是编译成可执行结构。把语法树进一步编译成字节码、或者编译成一棵Function<Context, Object>的闭包树,求值时不再走虚方法递归,性能能提升一个数量级。这个做法在规则命中频繁的场景很值得投入,实现复杂度中等,核心思路是给每种节点加一个compile方法。

我做过一个粗略对比,仅供参考:手写解释器模式,几十个节点的表达式求值大概在几十微秒量级;编译成闭包树后能压到个位数微秒;如果还要更快,那就是把规则整体编译成 Java 代码再动态加载了,那个复杂度是另一个量级,绝大多数业务根本不需要。

6.3 一段不算成熟的选型经验

我把这些年做规则相关模块的判断压缩成三句话,你可以对照自己的场景套一下。

规则条数少于 50,变化频率低于每月一次:别上解释器,一个配置表加一段 switch 就够了。过度设计带来的维护成本,比硬编码高得多。

规则需要用户自助配置,但文法限定在四则运算加简单比较:解释器模式非常合适,自己手写词法加递归下降,两三百行代码,可控性最高,出了问题也好查。

文法里出现函数、集合、时间运算、类型转换:老老实实上工具或库,别硬写。我见过一个团队硬手写支持 30 多种函数的解析器,最后代码量近万行,没人敢改,加个函数要测三天。

这次先把骨架、终结符、非终结符、词法、递归下降这几块跑通,做一个能算四则运算加变量的完整求值器。至于语法树的复用策略、编译成闭包树的性能优化、以及怎么把布尔条件和四则运算混在一个文法里还不打架,这些属于另一层复杂度,涉及的设计取舍比这里多得多,涉及到的坑也更隐蔽,留着后面单独聊。你现在如果想把手上这套跑起来,最省事的路径就是照第 4 节的代码抄一遍,然后拿自己业务里的几条真实规则串进去跑,跑通之后再加打印语法树的能力——信我一句,这个能力在你后面所有调试里都会派上用场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询