资讯动态

解释器模式实战:从嵌套if到AST语法树,手写四则运算解释器

发布时间:2026/10/21 3:40:19 来源:尧图企业网站定制
接手老项目的最初两周我几乎每天都要在同一段代码里跟嵌套的 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 和递归下降解析器。首先是 tokenizerimport 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_kindNone): 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, variablesNone): 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, indent0): 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, variablesNone): 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检查树结构变量取值报 KeyErrorContext 没有注入对应值检查 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。基础打牢之后后面加多少规则都会变得很从容。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑