接手老项目的最初两周,我几乎每天都要在同一段代码里跟嵌套的 if 搏斗。那是促销规则判断模块,规则长这样:用户等级大于等于 3 且 最近 30 天下单金额超过 1000。业务方每提一个新维度,我就要在现有分支上再叠一层判断。有一天半夜排查线上问题,光看那段几十行的 if-else 就花了两个小时。后来我才意识到,真正需要的是一个解释器模式——把表达式当成一种小型语言,先解析成语法树,再让每一种节点自己负责解释自己。这篇文章就从我遇到的现场问题说起,讲清楚解释器模式的核心角色、适用边界,并给出一套可以直接跑起来的四则运算解释器代码。如果你正在写规则引擎、用户自定义公式、配置表达式解析,或者单纯想弄明白 AST 和设计模式的关系,那么这篇内容会很对胃口。
1. 解释器模式到底在解决什么问题
1.1 一个让我决定重构的现场
当时的做法很常见:规则存成 JSON,里面有个 type 字段,代码拿到 type 后走不同的 if 分支,再取出对应参数做比较。比如“大于等于”是一个分支,“小于”是另一个分支,多个条件组合就再套一层循环或者递归。一开始规则少,这样写没问题。等规则多起来,问题就暴露了。
最大的问题是,if-else 把“怎么解析表达式”和“怎么执行判断”混在了一起。你每增加一种运算符、一种变量类型,就要找到所有相关分支,逐个加判断。漏掉任何一个位置,线上就会出现诡异的结果。更麻烦的是组合嵌套。促销规则里经常有“满足 A 或(B 且 C)”这种结构,用 if-else 直接表达会让代码变成一棵隐形的树,但这棵树只存在于你的脑子里,代码里完全看不出来。
我当时真正需要的,是让代码结构去显式地表达“规则本身就是一棵树”。解释器模式正是做这件事的:它把一个表达式语言的文法映射成一组类,然后用递归调用去解释整棵树。每个节点类型只负责自己的解释逻辑,新增规则的时候,只需要新增一个节点类,再让解析器认识它,老代码基本不用动。
1.2 解释器模式里的四个核心角色
解释器模式有四个角色,你可以把它们类比成一张考卷的答题机制:
- AbstractExpression(抽象表达式):所有语法树节点的共同接口,定义
interpret方法。相当于“所有题型都要答题”的公共约束。 - TerminalExpression(终结符表达式):语法树中的叶子节点,不再包含子节点,比如常量
3、变量amount。它没有子题,直接返回答案。 - NonterminalExpression(非终结符表达式):语法树中的组合节点,比如加法、乘法。它包含子节点,interpret 时要先解释子节点,再组合结果,相当于“需要先做子题,再汇总答案的大题”。
- Context(上下文):保存解释过程中需要共享的信息,比如变量值、错误列表、日志开关。相当于草稿纸上写好的已知条件。
为什么需要 Context 而不是把变量直接塞给每个节点?因为语法树的结构应该是无状态的,同一个 AST 可以被不同上下文复用。我解释同一个公式“单价 * 数量”,这次给 A 客户的单价,下次给 B 客户的单价,语法树完全一样,变的只是 Context 里的数据。让每个节点自己持有一份变量值,反而会让节点变得臃肿,也没法共享。
1.3 执行流程:从句子到树,再从树回到结果
解释器模式不会帮你做“字符串分词”和“语法分析”这两件事,它关心的是“树已经搭好之后,怎么计算”。完整流程是:
- 把原始表达式字符串拆成 token。比如
3 + 5 * (2 - 1)变成数字、运算符、括号等 token。 - 按照文法规则把 token 组织成抽象语法树。
3 + 5 * (2 - 1)对应的树长这样:
Add ├── Const(3) └── Multiply ├── Const(5) └── Subtract ├── Const(2) └── Const(1)- 调用根节点的
interpret,节点递归调用子节点的interpret,逐层向上返回结果。
运算顺序是靠树的结构来保证的。乘法和除法在树下层,所以它们会先被计算,这正好把“先乘除后加减”的规则固化到了数据结构里,而不是写在某个if op == '*'里。每次看到这种递归求值过程,我都会觉得,解释器模式最大的魅力就是:语法规则成了一种显式的、可以被构造和遍历的对象结构。
2. 从零手写一个可用的四则运算解释器
2.1 定义抽象表达式与上下文
我先用 Python 写一个最精炼的版本,支持数字、变量、加减乘除和括号。第一步是定义抽象表达式接口和上下文。
from abc import ABC, abstractmethod class Expression(ABC): @abstractmethod def interpret(self, context): pass class Context: def __init__(self): self._variables = {} def set_variable(self, name, value): self._variables[name] = value def get_variable(self, name): if name not in self._variables: raise KeyError(f"变量未定义: {name}") return self._variables[name]这里的关键是interpret(context)统一接收一个 Context。不管叶子节点还是组合节点,签名一致,递归才能顺畅。你可能觉得只传一个 Context 有点绕,但在真正的项目里,Context 还可以塞进错误收集器、日志对象、甚至自定义函数表。如果每个节点的 interpret 都单独定义参数列表,加一个共享参数就要改整棵树,那就违背了解释器模式的初衷。
2.2 终结符与非终结符节点实现
下面写具体节点。Constant和Variable是终结符,Add、Subtract、Multiply、Divide是非终结符。
class Constant(Expression): def __init__(self, value): self.value = value def interpret(self, context): return self.value class Variable(Expression): def __init__(self, name): self.name = name def interpret(self, context): return context.get_variable(self.name) class BinaryExpression(Expression): def __init__(self, left, right): self.left = left self.right = right class Add(BinaryExpression): def interpret(self, context): return self.left.interpret(context) + self.right.interpret(context) class Subtract(BinaryExpression): def interpret(self, context): return self.left.interpret(context) - self.right.interpret(context) class Multiply(BinaryExpression): def interpret(self, context): return self.left.interpret(context) * self.right.interpret(context) class Divide(BinaryExpression): def interpret(self, context): divisor = self.right.interpret(context) if divisor == 0: raise ZeroDivisionError("除零错误") return self.left.interpret(context) / divisor注意看这里,每个节点只做一件事。加法节点不关心子节点是常量还是变量,反正孩子只要实现了interpret,自己就能拿到结果。这就是多态带来的好处。新增加一种运算,比如幂运算,只需要写一个Power类,然后在解析器里让**生成它,不用动Add,也不用动Multiply。
除法的防御逻辑我放在了节点的interpret里,而不是留给上层。因为解释器是递归调用,错误在哪个节点发生最好就在哪个节点附近处理。这样上层捕获异常时,还能结合节点自身的信息给出更准确的提示。
2.3 词法分析与语法分析
有了节点类,还需要把字符串变成 AST。这里我实现一个简单的 tokenizer 和递归下降解析器。首先是 tokenizer:
import re def tokenize(expr): token_spec = [ ("NUMBER", r"\d+(\.\d+)?"), ("NAME", r"[a-zA-Z_][a-zA-Z0-9_]*"), ("OP", r"[+\-*/]"), ("LPAREN", r"\("), ("RPAREN", r"\)"), ("WHITESPACE", r"\s+"), ] token_re = re.compile("|".join( f"(?P<{name}>{pattern})" for name, pattern in token_spec )) tokens = [] for match in token_re.finditer(expr): kind = match.lastgroup value = match.group() if kind == "WHITESPACE": continue tokens.append((kind, value)) if not tokens: raise SyntaxError("空表达式") return tokens然后是基于文法的递归下降解析器。四则运算的文法可以表达为:
expr -> term (("+" | "-") term)* term -> factor (("*" | "/") factor)* factor -> NUMBER | NAME | "(" expr ")"class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): if self.pos < len(self.tokens): return self.tokens[self.pos] return (None, None) def consume(self, expected_kind=None): kind, value = self.peek() if expected_kind and kind != expected_kind: raise SyntaxError(f"期望 {expected_kind},实际 {kind}({value})") self.pos += 1 return value def parse(self): node = self.expr() if self.pos != len(self.tokens): raise SyntaxError("无法解析的剩余 token") return node def expr(self): node = self.term() while self.peek()[0] == "OP" and self.peek()[1] in ("+", "-"): op = self.consume() right = self.term() node = Add(node, right) if op == "+" else Subtract(node, right) return node def term(self): node = self.factor() while self.peek()[0] == "OP" and self.peek()[1] in ("*", "/"): op = self.consume() right = self.factor() node = Multiply(node, right) if op == "*" else Divide(node, right) return node def factor(self): kind, value = self.peek() if kind == "NUMBER": self.consume() return Constant(float(value)) if kind == "NAME": self.consume() return Variable(value) if kind == "LPAREN": self.consume() node = self.expr() self.consume("RPAREN") return node raise SyntaxError(f"无法识别的 token: {value}")这段代码是解释器模式落地中最容易出错的部分。很多初学者会把 tokenizer、parser、interpret 全塞进一个类,结果几百行下来谁都看不懂。把词法分析和文法分析拆开,再单独交给解释器,逻辑会清晰非常多。我建议你写代码的时候,一定遵循这个分层,后面做扩展才不痛苦。
2.4 完整求值函数与调用示例
把上面所有部分拼起来,提供一个统一的入口:
def evaluate(expr, variables=None): context = Context() if variables: for name, value in variables.items(): context.set_variable(name, value) tokens = tokenize(expr) parser = Parser(tokens) ast = parser.parse() return ast.interpret(context)测试一下:
print(evaluate("3 + 5 * (2 - 1)")) # 输出:8.0 print(evaluate("price * quantity", {"price": 15.5, "quantity": 3})) # 输出:46.5第一个表达式验证了乘法和括号优先级,第二个表达式验证了变量从 Context 取值。到这里,一个满足基本运算的解释器已经可以用了。你可以在这个骨架上继续扩展比较运算、逻辑运算符、函数调用等能力。
2.5 让 AST 可视化,方便调试
写解释器最难受的场景,就是你知道结果不对,但不知道是哪个节点算错了。这时候与其在脑里推演,不如直接把 AST 打印出来。
def dump(node, indent=0): print(" " * indent + node.__class__.__name__) if hasattr(node, "value"): print(" " * indent + f" value: {node.value}") if hasattr(node, "name"): print(" " * indent + f" name: {node.name}") if hasattr(node, "left"): dump(node.left, indent + 1) if hasattr(node, "right"): dump(node.right, indent + 1)调用dump(ast),输出会清楚展示树的层次:
Add Constant value: 3.0 Multiply Constant value: 5.0 Subtract Constant value: 2.0 Constant value: 1.0我实际排查优先级问题时,靠这个 dump 函数一眼就能看出树是不是搭错了。比如把2 + 3 * 4错误地解析成先加后乘,AST 的结构会立刻暴露问题。记住:解释器模式的 bug 通常不在单个节点的interpret里,而在解析器构造的树结构上。
3. 解释器模式的应用场景与选型判断
3.1 哪些业务真正适合用解释器模式
解释器模式最适合的场景,是“业务上有一种小语言需要被反复解析和执行”。在我的项目经验里,下面几类场景都遇到过:
- 规则引擎:比如优惠券使用条件、风控策略、权限校验。表达式通常是一个布尔组合,支持用户配置,需要动态生效。
- 配置表达式:灰度开关、发布策略、A/B 测试分组。比如“用户 ID 取模 100 小于 10 且城市在上海”。
- 自定义公式:报表指标、绩效考核、运费计算。业务人员会在线配置公式,系统需要把字符串变成可计算结果。
- 内存数据过滤:在内存集合上执行类似
age >= 18 and score > 90的过滤条件,避免把表达式送到外部计算引擎。
这些场景有一个共同特征:语法规模是有限且可控的。你可能只需要十几个节点类型,加了括号、运算符、变量和函数调用就差不多了。此时手写解释器不仅维护成本低,还能让业务逻辑完全脱离底层存储。
3.2 什么时候使用它是过度设计
解释器模式不是银弹,很多项目用它是纯粹为了“显得有设计感”,结果反而把简单的判断搞得复杂。
如果规则只有两三种固定类型,而且几乎没有组合嵌套,那用两个 if 或者一个 map 就好。解释器模式的最小单位是 AST,哪怕只有两个节点,你也得建立表达式接口、命令解析器、上下文,这套复杂度对简单场景是负担。
如果表达式语法复杂到需要多遍语法检查、类型推导、变量作用域、函数重载,那手写解释器等于给自己挖坑。比如你要做一个类似 SQL 的查询语言,里面还有嵌套子查询,真的不建议从零去写。这种复杂度更适合选择成熟的解析器生成工具,或者直接接一个开源表达式引擎。
如果性能要求非常极端,解释器模式也未必合适。递归遍历 AST 天然有调用开销,尤其表达式很长或调用非常频繁时,性能损耗会被放大。后面我会提到预编译和缓存,但那也只能解决部分问题。
我给自己定过一条经验法则:如果一段判断逻辑用普通条件语句写起来不超过 20 行,就不要引入解释器;如果业务方明确告诉我“规则会越来越多,我们希望配置化”,再考虑也不迟。
3.3 与组合模式、策略模式的分工
很多人在学解释器模式时,会好奇它和组合模式是不是一回事。它们确实很接近,但侧重点不同。
组合模式致力于让“单个对象”和“组合对象”被一视同仁地处理,就像文件与文件夹都支持打开、删除操作。解释器模式则是在组合结构上增加了“解释”这个行为。你可以说解释器模式通常基于组合模式来构建语法树,但组合模式并不规定节点必须能“求值”。
策略模式是算法替换,解释器模式是语法解析。如果面对同一个表达式,不同场景需要不同计算逻辑,比如电商场景里普通会员和 VIP 会员使用不同的折扣解释器,那可以让两个解释器实现同一个表达式接口,外层再用策略模式去选择。这三种模式不是竞争关系,经常配合使用。
4. 进阶:让解释器模式真正落地可用
4.1 解析与解释分离,AST 复用
初学者最容易犯的错误,是让解析器在解释时去重新分析字符串。比如在interpret里调用eval或者再次对子串做分词,这样既慢又乱。
正确的做法是让解析器只负责构建 AST,解释器只负责遍历 AST。解析一次得到的语法树,可以在不同上下文里反复interpret。这样同一个公式“单价 * 数量”,只要解析一次,A 客户来了用 A 的上下文算一遍,B 客户来了用 B 的上下文再算一遍,AST 不会变。
把解析和解释分离还有一个好处:同一个 AST 可以支持多种操作。除了求值,你还可以写一个CheckExpression校验表达式是否合法,写一个ExplainExpression把表达式转成自然语言。这些操作本质上都是对同一棵树遍历,不改动树本身。
4.2 预编译与缓存
如果一个系统里表达式不算太多,但执行得很频繁,比如每秒处理上千个请求,每个请求都要把同样的字符串重新 parse 一遍,那会很浪费。常见的优化是缓存 AST。
_ast_cache = {} def get_ast(expr): if expr not in _ast_cache: _ast_cache[expr] = Parser(tokenize(expr)).parse() return _ast_cache[expr] def evaluate_cached(expr, variables=None): context = Context() if variables: for name, value in variables.items(): context.set_variable(name, value) ast = get_ast(expr) return ast.interpret(context)这段代码里,_ast_cache保存的是字符串到 AST 的映射。因为 AST 设计成无状态的,所以可以安全地被多线程共用。要注意的是,如果表达式数量无限,缓存会撑爆内存,需要加 LRU 淘汰或者数量上限。
还有一种思路是缓存最终结果。如果一个表达式加上一套完全相同的变量值,短期内结果不会变,那连 interpret 都不需要执行。但缓存最终结果要格外小心,因为变量值一旦变化,缓存必须失效。我通常只缓存 AST,不会轻易缓存最终结果,除非业务上能明确接受延迟生效。
4.3 错误定位与用户友好提示
解释器模式写起来容易,但让用户能看懂报错信息很难。真实场景里,表达式往往不是开发者自己写的,而是业务人员填写的。比如页面输入框里填了quantity * price / 0,你不能直接抛一个ZeroDivisionError给用户看。
我的做法是让 Context 记录错误,也记录当前节点位置信息。这个位置信息可以从 tokenizer 阶段就保留下来,每个 token 记录起止字符位置,解析成节点时也把位置带进去。这样当解释器执行失败,抛出的异常就能包含“第 8 个字符附近存在除零错误”这种信息,而不是一句冷冰冰的栈堆。
尽量遵循“一次收集多种错误”的思路。比如变量名拼写错误、类型不匹配、除零错误,如果能在一次解释过程中全部收集上报,用户体验会好很多。不过这也意味着每个节点的 interpret 要更谨慎,出错时不一定立刻中断,而是先记录错误再返回默认值。
4.4 从算术到比较和逻辑表达式
四则运算只是一个起点。大部分生产场景还需要支持比较和逻辑组合。可以按优先级扩展文法:
expr -> or_expr or_expr -> and_expr ("or" and_expr)* and_expr -> comparison ("and" comparison)* comparison -> additive ((">" | ">=" | "<" | "<=" | "==" | "!=") additive)* additive -> multiplicative (("+" | "-") multiplicative)* multiplicative -> factor (("*" | "/") factor)* factor -> NUMBER | NAME | "(" expr ")" | "-" factor新增一个Greater节点很直观:
class Greater(BinaryExpression): def interpret(self, context): return self.left.interpret(context) > self.right.interpret(context)类似地,AndExpression和OrExpression只是对布尔值做组合。这样一条促销规则userLevel >= 3 and amount > 1000就能被解析成一棵逻辑树,然后被正常求值。扩展的关键点不在新增节点,而在文法层级的调整。你需要在factor里处理一元负号,需要把比较逻辑插到加减乘除之上。文法顺序一旦错了,优先级就会跟着错。
5. 常见问题与排查实录
5.1 高频问题与排查方向速查
我在做解释器相关需求时,最常碰到下面这些问题:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 运算结果和预期不符 | 解析器文法优先级不对 | 先 dump AST,检查树结构 |
| 变量取值报 KeyError | Context 没有注入对应值 | 检查 evaluate 入口是否填充变量 |
| 表达式里有除零 | 右子树算出来是 0 | 在 Divide 节点加防御逻辑 |
| 解析器卡住或死循环 | tokenizer 丢了 token 或位置没前进 | 检查 consume 后 pos 是否自增 |
| 缓存后数据串了 | AST 或 Context 被错误共享 | 确保 AST 无状态,Context 每次重建 |
| 新增运算符要改很多地方 | 节点创建逻辑分散 | 把运算符到节点的映射集中到 Parser 内部 |
| 递归层次太深导致栈溢出 | 表达式嵌套太重 | 在解析器里限制深度或改用显式栈 |
这张表算是我踩坑经验的浓缩版。很多时候问题不在解释器模式本身,而在外围的解析器实现和上下文管理。
5.2 两个真实项目中的坑
第一个坑和 Context 并发有关。某个风控系统把规则表达式解析成 AST 后缓存起来,变量值放在 Context 里。最初我把 Context 也顺手放进了缓存,结果高并发下一部分请求读取到了其他用户的信息,变量互相串场。因为同一个 AST 被多个请求共用,而 Context 却只有一个。修复方式很简单:AST 可以全局缓存,但 Context 必须在每次请求里重新创建,绝不进缓存。
第二个坑是负数表达式。业务方在配置里填了-5 + 3,系统直接报解析错误。原因是最初的文法里没有一元负号,因子只能是数字、变量或被括号包住的表达式。修复需要在factor里判断当前 token 是不是负号。如果碰到负号,就生成一个Negate节点,把负号当作对子表达式的取反而不是立即求值。这个修改看起来小,但如果不理解 AST 的含义,很容易在interpret里去改计算逻辑,导致负数嵌套时再出错。
5.3 一点个人经验杂谈
我个人的建议是,不要为了设计模式而引入设计模式。在重构促销规则模块时,我的第一版只实现了加法和简单的比较,后来业务需求多了,才补上乘除、括号、一元负号和逻辑组合。解释器模式的优雅之处,是它允许你把语法切分成独立的节点,后续扩展不需要推翻已有结构。但前提是你愿意为它准备一个像样的 Parser,而不是在业务代码里手工拼接 AST。基础打牢之后,后面加多少规则都会变得很从容。