解释器模式实战:从零实现表达式解释器与规则引擎

发布时间:2026/10/18 3:59:02
解释器模式实战:从零实现表达式解释器与规则引擎 1. 解释器模式到底在解决什么问题第一次接触解释器模式Interpreter Pattern的人十有八九会有同一个反应这玩意儿到底什么时候用工厂模式、单例模式、策略模式这些在业务代码里天天见可解释器模式好像只在教科书里出现过。我刚开始学设计模式那会儿也是这个感觉直到后来接手了一个规则引擎相关的需求才真正理解它存在的意义。解释器模式的核心思想其实一句话就能说清楚给定一个语言定义它的文法的一种表示并定义一个解释器这个解释器使用该表示来解释语言中的句子。这话是GoF那本经典著作里的原话翻译过来就是——你需要自己设计一套小小的“语言”然后写一个程序去读懂它、执行它。举个生活中的例子。你家里买了一个智能音箱你对它说“打开客厅的灯”它听懂了并且执行了。这个过程中音箱内部就做了一件事把你说的这句话按照它理解的语法规则翻译成一条条可执行的指令。解释器模式干的就是这个活儿——把一串有结构的文本变成程序能执行的动作。那它和正则表达式、JSON解析器有什么区别区别在于正则和JSON解析器是别人写好的、通用的。而解释器模式解决的是你自己定义的、特定领域的小语言。比如你公司有一套自定义的促销规则配置运营人员写的是“满300减50且仅限新用户”你需要把这串文字变成代码逻辑去执行——这就是解释器模式的典型战场。适合学这个模式的人我总结了三类一是正在做规则引擎、工作流引擎、表达式计算的开发者二是准备面试、需要把23种设计模式都吃透的同学三是对编译器、DSL领域特定语言感兴趣想从零理解“程序怎么读懂程序”的人。不管你属于哪一类接下来的内容都会从实际编码的角度把解释器模式拆得明明白白。2. 核心角色拆解与文法设计思路2.1 五个核心角色的职责划分解释器模式的类图看起来简单但每个角色的职责边界很容易搞混。我用一个“数学表达式计算器”的例子来串讲这个例子后面会一直用下去。抽象表达式AbstractExpression所有表达式的父接口或抽象类通常只声明一个interpret方法。这个方法接收一个上下文对象返回解释结果。它是整个模式的基石所有节点都实现它。终结符表达式TerminalExpression对应文法中的最小单位不能再拆分。比如数字、变量名。在数学表达式里5、x就是终结符。它直接返回具体的值不依赖其他表达式。非终结符表达式NonterminalExpression对应文法中的组合规则它内部持有其他表达式的引用。比如加法、减法、乘法。5 3这个表达式里就是非终结符它左边是5右边是3。上下文Context存放解释过程中需要的全局信息。比如变量名到值的映射表。在数学表达式里如果表达式是x y上下文就负责告诉解释器x等于多少、y等于多少。客户端Client负责构建抽象语法树AST然后调用interpret方法触发解释。客户端通常还会配合一个解析器把文本转成AST。这五个角色里最容易出问题的是上下文的设计。我见过不少初学者把上下文当成“万能袋子”什么都往里塞结果上下文越来越臃肿最后变成了一个全局变量。正确的做法是上下文只存放解释过程中真正需要共享的状态比如变量表、函数表其他东西一律不放。2.2 文法规则怎么设计才合理设计文法这件事说难也难说简单也简单。核心原则只有一条从最小可用开始逐步扩展。还是拿数学表达式举例。第一版文法可以只支持加法和数字expression : number | expression expression number : [0-9]这个文法简单到不能再简单但它已经能表达12、345这样的式子了。等你把第一版跑通再逐步加入减法、乘法、括号、变量。每加一个规则就新增一个非终结符表达式类。这里有个经验之谈文法规则的数量和类的数量是一一对应的。一个产生式规则通常对应一个表达式类。所以如果你的文法有20条规则那就会有20个类。这也是解释器模式最大的缺点——文法一复杂类的数量就会爆炸式增长。那怎么控制类的数量两个思路。第一把功能相近的规则合并比如加法和减法可以用同一个类通过一个标志位区分。第二对于特别复杂的文法考虑用其他技术替代比如语法分析器生成工具。解释器模式适合的是文法规则在10条以内的场景超过这个数量维护成本会急剧上升。2.3 为什么不用switch-case硬编码有人可能会问我直接用一堆if-else或者switch-case判断字符串不也能实现吗为什么非要搞这么复杂的类结构这个问题问到点子上了。用switch-case确实能实现同样的功能但有几个致命问题。第一扩展性差。每次新增一条文法规则你都要去修改那个巨大的switch-case块违反了开闭原则。而解释器模式新增一个规则只需要新增一个类不用动老代码。第二可读性差。当文法规则超过5条switch-case就会变得又长又臭嵌套层级深到让人头晕。而解释器模式把每条规则封装成独立的类每个类的逻辑都很清晰。第三无法复用。switch-case里的逻辑是写死的你没法把某个子表达式单独拿出来复用。而解释器模式构建的是树形结构每个节点都是独立对象可以自由组合。当然解释器模式也不是银弹。如果你的文法规则只有两三条而且永远不会扩展那用switch-case反而更简单直接。技术选型永远要看具体场景不要为了用模式而用模式。3. 从零实现一个表达式解释器3.1 抽象表达式与上下文定义理论说再多不如动手写一遍。下面用Java来实现一个支持加减乘除和变量的表达式解释器。选Java是因为它的类型系统清晰能把每个角色的职责表达得很明确。你用Python、C#、TypeScript也完全一样思路是通用的。先定义上下文。上下文的作用是存储变量名到值的映射import java.util.HashMap; import java.util.Map; public class Context { private MapString, Integer variables new HashMap(); public void setVariable(String name, int value) { variables.put(name, value); } public int getVariable(String name) { if (!variables.containsKey(name)) { throw new IllegalArgumentException(未定义的变量: name); } return variables.get(name); } }这个上下文类很简单就是一个Map的包装。注意getVariable方法里做了校验如果变量未定义就抛异常。这个校验很重要否则解释器遇到未定义变量时会返回0或者null导致难以排查的bug。接下来定义抽象表达式接口public interface Expression { int interpret(Context context); }就一个方法接收上下文返回整数结果。这里返回int是因为我们的表达式只做整数运算。如果要支持浮点数改成double即可。3.2 终结符表达式的实现终结符表达式有两个数字表达式和变量表达式。数字表达式直接返回字面值public class NumberExpression implements Expression { private int number; public NumberExpression(int number) { this.number number; } Override public int interpret(Context context) { return number; } }变量表达式从上下文里取值public class VariableExpression implements Expression { private String name; public VariableExpression(String name) { this.name name; } Override public int interpret(Context context) { return context.getVariable(name); } }这两个类都很简单但它们是整个解释器的基石。所有的复杂表达式最终都会递归到这两个类上。3.3 非终结符表达式的实现非终结符表达式是重点。我们实现加法、减法、乘法、除法四个类。先看加法public class AddExpression implements Expression { private Expression left; private Expression right; public AddExpression(Expression left, Expression right) { this.left left; this.right right; } Override public int interpret(Context context) { return left.interpret(context) right.interpret(context); } }减法和加法几乎一样只是把换成-public class SubtractExpression implements Expression { private Expression left; private Expression right; public SubtractExpression(Expression left, Expression right) { this.left left; this.right right; } Override public int interpret(Context context) { return left.interpret(context) - right.interpret(context); } }乘法和除法同理这里就不重复贴代码了。你会发现这四个类的结构完全一致只是运算符号不同。这时候你可能会想能不能合并成一个类当然可以加一个枚举字段区分运算类型就行。但合并之后interpret方法里就要写switch-case了反而失去了解释器模式“每个类职责单一”的优势。是否合并取决于你的文法规则数量和团队偏好没有绝对的对错。3.4 构建抽象语法树并执行现在到了客户端环节。客户端需要做两件事构建AST然后调用interpret。假设我们要计算(a b) * c - 10其中a5b3c2。手工构建AST的代码如下public class Client { public static void main(String[] args) { Context context new Context(); context.setVariable(a, 5); context.setVariable(b, 3); context.setVariable(c, 2); // 构建 (a b) * c - 10 Expression a new VariableExpression(a); Expression b new VariableExpression(b); Expression c new VariableExpression(c); Expression ten new NumberExpression(10); Expression add new AddExpression(a, b); Expression multiply new MultiplyExpression(add, c); Expression subtract new SubtractExpression(multiply, ten); int result subtract.interpret(context); System.out.println(计算结果: result); // 输出 6 } }手工构建AST虽然可行但太麻烦了。实际项目中你肯定需要一个解析器把字符串(a b) * c - 10自动转成AST。这个解析器可以用递归下降算法实现也可以用现成的语法分析工具。解析器的实现超出了本文的范围但你要知道解释器模式负责的是“执行”解析器负责的是“理解”两者是配合关系。3.5 递归下降解析器的核心思路既然提到了解析器我简单说一下递归下降的核心思路方便你后续扩展。递归下降解析器的本质是为文法中的每个非终结符写一个解析函数。比如对于表达式文法expression : term (( | -) term)* term : factor ((* | /) factor)* factor : number | variable | ( expression )你需要写三个函数parseExpression、parseTerm、parseFactor。每个函数负责解析对应层级的语法遇到更高优先级的运算就递归调用下一层。这样自然就实现了运算符优先级——乘除在term层加减在expression层括号在factor层。这个解析器大概100行代码就能搞定但它是整个表达式计算器的“入口”。没有它用户就得手工构建AST体验极差。在实际项目中解析器和解释器是黄金搭档缺一不可。4. 实战中的典型应用场景4.1 规则引擎中的条件表达式解释器模式最经典的应用场景就是规则引擎。我做过一个电商促销系统运营人员需要配置各种复杂的促销规则比如“满300减50且仅限新用户”或者“购买A商品且购买数量大于2件打8折”。这些规则如果用硬编码实现每加一条规则就要改代码、重新部署运营人员根本等不及。用解释器模式之后我们把规则定义成一套简单的DSLrule : condition - action condition : expression (AND | OR) expression expression : field operator value operator : | | | | 然后为每个语法规则写一个表达式类。运营人员写的规则文本经过解析器转成AST再由解释器执行。新增规则只需要在数据库里加一条配置完全不用改代码。这个场景下解释器模式的价值体现得淋漓尽致把变化的逻辑从代码中抽离出来变成可配置的数据。4.2 工作流引擎中的条件分支工作流引擎里的条件分支也是解释器模式的天下。比如一个审批流程需要根据申请金额、申请人部门、申请类型等条件决定走哪条审批路径。条件可以写成amount 10000 AND department finance这样的表达式。工作流引擎在运行到分支节点时调用解释器计算条件表达式的值根据结果决定下一步走向。这种场景的特点是条件表达式由业务人员配置而不是开发人员写死。解释器模式让业务人员能够用接近自然语言的方式表达业务规则大大降低了沟通成本。4.3 自定义查询语言的实现有些系统需要支持用户自定义查询比如日志分析系统用户想查“最近7天内状态码为500且响应时间大于1秒的请求”。这种查询如果用SQL实现用户得学SQL如果用界面点选又不够灵活。折中方案就是定义一套简单的查询DSL用解释器模式实现。用户写status 500 AND responseTime 1000 AND time now() - 7d解释器把它翻译成底层查询。这样既灵活又易用。4.4 不适合使用解释器模式的场景说了这么多适用场景也得说说什么时候不该用。第一文法规则超过20条。类的数量会爆炸维护成本极高。这时候应该考虑用ANTLR、JavaCC这类语法分析器生成工具。第二性能要求极高的场景。解释器模式是解释执行比编译执行慢很多。如果每秒要处理几十万次表达式计算解释器模式可能扛不住。这时候可以考虑把AST编译成字节码或者用JIT技术优化。第三文法规则几乎不会变化的场景。如果规则写死之后几年都不改那直接用硬编码反而更简单高效。提示判断是否使用解释器模式问自己三个问题——文法规则会频繁变化吗规则数量在10条以内吗性能要求不是极端苛刻吗三个都是“是”那就放心用。5. 常见问题与避坑经验实录5.1 循环依赖与无限递归解释器模式最容易踩的坑就是无限递归。我遇到过一次表达式是a a 1解释器在计算a的时候又去计算a 1结果栈溢出。这个问题的根源在于变量表达式的值依赖于另一个表达式而那个表达式又依赖于变量本身。解决方法是加一个“正在计算中”的标记如果发现循环依赖就直接报错。public class Context { private MapString, Integer variables new HashMap(); private SetString calculating new HashSet(); public int getVariable(String name) { if (calculating.contains(name)) { throw new IllegalStateException(检测到循环依赖: name); } if (!variables.containsKey(name)) { throw new IllegalArgumentException(未定义的变量: name); } return variables.get(name); } public void setVariableWithExpression(String name, Expression expr) { calculating.add(name); try { variables.put(name, expr.interpret(this)); } finally { calculating.remove(name); } } }这个坑我在实际项目中踩过排查了大半天才发现是循环依赖。如果你的表达式支持变量引用其他表达式一定要加循环检测。5.2 运算符优先级处理运算符优先级是另一个高频问题。1 2 * 3应该等于7而不是9。如果解析器写得不对很容易算成9。解决方法是分层解析。把表达式分成多个优先级层级高优先级的运算符先结合。前面提到的递归下降解析器就是标准解法。如果你不想自己写解析器可以用调度场算法Shunting Yard Algorithm它能把中缀表达式转成后缀表达式天然处理了优先级问题。这个算法大概50行代码网上有很多现成的实现可以参考。5.3 上下文状态污染上下文是共享的如果解释器在计算过程中修改了上下文可能会影响后续计算。我见过一个案例某个表达式在计算时往上下文里写了一个临时变量结果另一个表达式读到了这个临时变量导致结果错误。避免这个问题的原则是上下文只读不写。解释器只从上下文读取变量值不往上下文写入任何东西。如果确实需要临时存储用方法内的局部变量不要污染上下文。5.4 性能优化技巧解释器模式的性能瓶颈通常在递归调用上。每次interpret都要递归到叶子节点深度越大越慢。优化思路有三个。第一缓存计算结果。如果同一个子表达式被多次计算可以缓存起来。第二把AST编译成更高效的形式。比如把树形结构转成逆波兰表达式用栈来执行避免递归。第三预编译常量表达式。如果某个子表达式的所有变量都是常量可以在构建AST时就计算好运行时直接返回结果。5.5 常见问题速查表问题现象可能原因排查思路解决方案栈溢出无限递归或循环依赖检查变量引用链加循环检测标记计算结果错误运算符优先级处理不当用简单表达式验证分层解析或调度场算法变量未定义异常上下文缺少变量打印上下文内容补全变量或加默认值性能随表达式变长急剧下降递归深度过大用性能分析工具定位缓存结果或转后缀表达式新增规则后老规则失效类之间耦合过紧检查是否有共享状态确保每个表达式类无状态5.6 一个容易被忽略的细节空表达式处理空表达式或者null表达式是另一个容易翻车的地方。如果客户端传了一个null进来interpret方法直接空指针异常。稳妥的做法是在抽象表达式里加一个空对象实现public class NullExpression implements Expression { Override public int interpret(Context context) { return 0; } }这样即使传了null也不会崩溃而是返回0。当然更好的做法是在客户端就做好校验不让null流入解释器。防御性编程在解释器模式里特别重要因为表达式是用户输入的你永远不知道用户会输入什么。6. 与其他设计模式的配合使用6.1 组合模式解释器模式的天然搭档解释器模式和组合模式的关系用“天生一对”来形容一点不为过。抽象表达式接口就是组合模式里的Component终结符表达式是Leaf非终结符表达式是Composite。整个AST就是一棵组合树。理解了这一点你就能明白为什么解释器模式能递归执行——因为它本质上就是在遍历一棵组合树。组合模式负责“树的结构”解释器模式负责“树的执行”两者各司其职。6.2 享元模式共享终结符对象如果表达式里有大量重复的终结符比如1 1 1 1每个1都创建一个NumberExpression对象就太浪费了。这时候可以用享元模式把终结符对象缓存起来。public class ExpressionFactory { private static MapInteger, Expression cache new HashMap(); public static Expression getNumberExpression(int number) { return cache.computeIfAbsent(number, NumberExpression::new); } }这样相同的数字只会创建一个对象大大节省内存。当表达式里终结符重复率很高时享元模式能带来明显的性能提升。6.3 访问者模式分离解释逻辑解释器模式把“执行逻辑”放在了表达式类里。但有时候你可能想在不修改表达式类的前提下新增一种“遍历”操作比如打印AST、统计节点数量、做静态检查。这时候访问者模式就派上用场了。访问者模式把操作从对象结构中分离出来让你可以在不改变表达式类的情况下定义新的操作。解释器模式负责“怎么执行”访问者模式负责“怎么遍历”两者配合能实现非常灵活的功能扩展。6.4 模式组合的注意事项模式组合虽然强大但不要过度设计。我见过一个项目为了一个只有三条规则的表达式硬生生套了组合模式、享元模式、访问者模式代码量翻了三倍可读性急剧下降。模式是工具不是目的。先用最简单的方式实现功能等确实遇到扩展性或性能问题时再引入相应的模式。过早优化是万恶之源过早套模式同样如此。7. 我个人的实操体会与建议解释器模式是我学过的23种设计模式里理解起来最费劲、但用起来最爽的一个。费劲是因为它涉及编译原理的知识爽是因为一旦你掌握了它就能解决很多看似复杂的问题。我最大的体会是不要为了学模式而学模式要为了解决问题而学模式。我当初学解释器模式的时候看了一堆理论但真正理解它是在做规则引擎项目的时候。当你面对一堆需要动态配置的业务规则硬编码改到崩溃的时候解释器模式的价值就自然显现出来了。另一个体会是解析器和解释器要一起学。很多人只学了解释器模式不知道怎么把字符串转成AST结果只能手工构建AST实用性大打折扣。花点时间学一下递归下降解析器或者调度场算法你的解释器模式才算真正完整。最后分享一个小技巧如果你要实现的表达式文法比较复杂可以先在纸上画出AST的样子再反推需要哪些表达式类。比如(a b) * c的AST根节点是乘法左子节点是加法右子节点是变量c。画出来之后类结构就一目了然了。这个方法帮我省了很多设计时间推荐你也试试。这个内容后续还可以这样扩展把解释器模式应用到实际的工作流引擎中支持更复杂的条件组合或者研究一下如何把AST编译成字节码提升执行性能。如果你对编译原理感兴趣解释器模式是一个非常好的切入点从这里出发可以一路学到词法分析、语法分析、语义分析、代码生成最终写出一个完整的编译器。