写业务代码久了,你会发现一个很普遍的规律:大部分系统的复杂度不是来自某个高深算法,而是来自海量的、纠缠不清的“判断逻辑”。最常见的就是一堆if-else按条件分流,今天加一种促销类型,明天加一种渠道来源,后天再补一个黑名单校验。每次接新需求都小心翼翼,生怕在某条分支里多踩一个坑。直到有一天,产品跑过来说:“我们希望运营能在后台自己配置过滤条件,比如满足某些条件的用户才发券。”那一刻,就绕不开解释器模式了。
解释器模式不是一个高频使用的设计模式,但每次用到都是解决真正棘手的问题。SQL解析、正则表达式匹配、规则引擎、数学公式计算、模板语法渲染,底层都是解释器模式的思路。它核心就做一件事:定义一种语言(通常是一串有特定格式的字符串),然后把这种语言的句子“翻译”成程序能够执行的结构。这篇文章我会用业务场景里最常见的规则表达式做例子,从零手写一个可运行的表达式解释器,把解释器模式的组件、设计思路、性能优化和避坑经验全部摊开讲清楚,希望能帮那些正准备写自定义规则、可配置过滤条件的同学减少走弯路的时间。
1. 解释器模式到底在解决什么问题
1.1 一个让代码“活”起来的场景:运营要自己配过滤条件
我举个具体的例子。假设我们做一个用户运营系统,运营每次发优惠券之前都要筛选用户,筛选条件大概长这样:近30天订单金额大于100,并且累计订单数大于3;或者用户等级是VIP。
刚开始就这么三类固定条件,代码很好写:
if (conditionA && conditionB) { sendCoupon(user); }需求一变就麻烦了。运营提出来:我们想把“近30天订单金额大于100且累计订单数大于3,或用户等级是VIP”这条规则配置成文案,不同城市的运营可以自己组合条件。于是后台需要提供一个表达式编辑框,运营可以手动输入:
( amount > 100 && count > 3 ) || level == 6这时候你发现原来的硬编码判断逻辑突然不够用了。你需要让程序有能力去“读”这段字符串,并且还要“根据用户的实际数值去判断真假”。该怎么办?
最粗暴的做法是正则表达式解析字符串,再按运算符拆分支。但你会发现表达式一复杂,括号、与或非嵌套、优先级问题一起来,用正则完全扛不住。这时候,解释器模式就该登场了。它把表达式解析成一个“语法树”,然后在树上一路解释执行。每一个节点负责一小段判断,组合起来却表达出完整的规则体系。
1.2 解释器模式的准确定义与最通俗的理解
解释器模式的官方定义是:给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。
听着很绕。我用翻译做个类比。你把一句中文“我明天去上海”交给翻译,翻译会先做词法分析:主语“我”、时间“明天”、动作“去”、地点“上海”,然后根据英语语法组装成“I will go to Shanghai tomorrow”。解释器模式做的事就是:语法规则定义了怎么把一个句子拆解成短语树,解释器遍历这棵树并执行语义。
放在代码里,这里的“句子”就是用户输入的某段规则字符串,“文法”就是我们约定好的表达式规范(比如支持大于、小于、与、或,支持变量),而“解释器”就是那棵节点对象构成的语法树,树里每个节点都知道自己该怎么计算。
用设计模式的口吻说,解释器模式适合解决“某种固定文法的小型语言的解释”问题。注意关键词:固定文法、小型语言。如果需求里的语法有数十种运算符,且各种扩展千奇百怪,优先考虑成熟的解析器生成工具,而不是纯手工解释器。模式有它的边界,这个后面专门讲。
1.3 四个标准角色:上下文、抽象表达式、终结符与非终结符
解释器模式有四个核心角色,先认识它们,后面写代码你会直接对应上。
- 抽象表达式(Abstract Expression):声明一个抽象的interpret(上下文)接口,是所有表达式节点的统一入口。后面代码里的Expression类就是它。
- 终结符表达式(Terminal Expression):语法树里的叶子节点。它不包含其他子节点,比如表达式里的变量、数值常量。解释时直接返回一个结果。
- 非终结符表达式(Nonterminal Expression):语法树里的非叶子节点。比如“大于运算表达式”、“并且运算表达式”。它内部组合了子表达式,解释时先解释子表达式,再组合出结果。
- 上下文(Context):承载解释所需的外部信息,比如变量名对应的值、常量表,甚至共享的临时状态。很多实现里上下文就是普通的Map<String, Object>。
结构上就是一棵树,树根是一个非终结符表达式,往下层层派生,叶子是终结符表达式。解释时从根节点开始,递归调用interpret。到这里,模式骨架已经清晰了,接下来用一个完整项目把它落地。
2. 从零手写一个规则表达式解释器
2.1 先把需求说清楚
为了不让文章停在概念层,我们要实际做一个能在业务里跑的配置化过滤表达式解释器。功能设定如下:
支持数字和字符串变量,例如 amount、count、level、city。 比较运算符支持 >、>=、<、<=、==、!=。 逻辑运算符支持 &&、||、!。 优先级从高到低依次是 !、比较、&&、||,支持括号覆盖优先级。 能处理形如( amount > 100 && count > 3 ) || level == "VIP"的表达式。
这基本是业务规则引擎的常用子集。等写完这个,你把它扩展成JSON筛选、灰度策略规则、字段级权限规则都完全可行。
先看最终效果:
context = Context({"amount": 200, "count": 5, "level": "VIP", "city": "Beijing"}) expr = Parser("( amount > 100 && count > 3 ) || level == \"VIP\"").parse() print(expr.interpret(context)) # True这个实现拆分两部分:语法解析(字符串 -> 抽象语法树)和解释执行(遍历语法树,计算布尔结果)。这两部分在工程上最好分开,否则以后扩展一个新运算符,逻辑耦合在一起会乱得想哭。
2.2 建立上下文与表达式节点
先实现解释器模式最底层的两个角色:上下文Context与抽象表达式Expression。
class Context: def __init__(self, variables: dict): self.variables = variables def get(self, name: str): if name not in self.variables: raise KeyError(f"变量 {name} 未在上下文中定义") return self.variables[name]Context做一个薄封装即可。它的本意就是“解释时向外部取数”,不要在里面堆业务逻辑。
抽象表达式接口:
class Expression: def interpret(self, context: Context): pass接着写两个终结符表达式:变量表达式和常量表达式。
class VariableExpression(Expression): def __init__(self, name: str): self.name = name def interpret(self, context: Context): return context.get(self.name) class ConstantExpression(Expression): def __init__(self, value): self.value = value def interpret(self, context: Context): return self.value这段很简单,但它解释了一个关键点:为什么终结符表达式也是对象而不是直接取变量值?因为统一接口。非终结符在解释时不需要区分子节点是变量还是常量,只要调用interpret就能拿到值。这就是多态的价值。
2.3 比较与逻辑表达式的实现
接下来是非终结符表达式。比较表达式内部组合左右两个子表达式,解释时先取两个值,然后按运算符比较。
class CompareExpression(Expression): def __init__(self, op: str, left: Expression, right: Expression): self.op = op self.left = left self.right = right def interpret(self, context: Context): lv = self.left.interpret(context) rv = self.right.interpret(context) if self.op == ">": return lv > rv if self.op == ">=": return lv >= rv if self.op == "<": return lv < rv if self.op == "<=": return lv <= rv if self.op == "==": return lv == rv if self.op == "!=": return lv != rv raise RuntimeError(f"未支持比较运算符: {self.op}")逻辑运算表达式:
class AndExpression(Expression): def __init__(self, left: Expression, right: Expression): self.left = left self.right = right def interpret(self, context: Context): return self.left.interpret(context) and self.right.interpret(context) class OrExpression(Expression): def __init__(self, left: Expression, right: Expression): self.left = left self.right = right def interpret(self, context: Context): return self.left.interpret(context) or self.right.interpret(context) class NotExpression(Expression): def __init__(self, expr: Expression): self.expr = expr def interpret(self, context: Context): return not self.expr.interpret(context)看到没有,解释器模式的高级之处在于,每个节点只需要管自己的计算逻辑,节点之间的组合关系由语法树结构自然表达。以后要扩展一个新逻辑运算,比如“异或”,加一个XorExpression类即可,完全不影响已有节点。
2.4 词法分析与语法解析:把字符串变成语法树
光是表达式节点还不够,用户输入的是字符串,怎么变成节点对象?这需要两个步骤:词法分析(Tokenizer)和语法分析(Parser)。
词法分析把原始字符串拆成语义单元(Token),比如(,amount,>,100,&&。这里特别注意字符串常量的引号,性别容易在这里翻车。
import re def tokenize(s: str): spec = [ ("LPAREN", r"\("), ("RPAREN", r"\)"), ("OP", r">=|<=|==|!=|>|<|&&|\|\|"), ("NOT", r"!"), ("STRING", r'"[^"]*"'), ("NUMBER", r"\d+\.?\d*"), ("VAR", r"[a-zA-Z_][a-zA-Z0-9_]*"), ("SPACE", r"\s+"), ] tokens = [] pos = 0 while pos < len(s): for name, pattern in spec: m = re.match(pattern, s[pos:]) if m: if name != "SPACE": value = m.group(0) if name == "STRING": value = value[1:-1] tokens.append((name, value)) pos += m.end() break return tokens词法分析没太多高深内容,关键是Token类型要分清楚:运算符、变量、常量、括号。其中正则匹配的顺序会影响结果,比如>=必须放在>前面匹配,否则会把>=拆成>和=,这是新手常踩的坑。
语法分析用递归下降法实现。层次从上往下:并或优先级最低,对应OrExpression;下一层是“与”表达式AndExpression;下一层是非运算符NotExpression;最后一层是原子表达式和括号。
class Parser: def __init__(self, text: str): self.tokens = tokenize(text) self.pos = 0 def peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else None def consume(self, token_type=None): tk = self.peek() if token_type and tk and tk[0] != token_type: raise SyntaxError(f"期望 {token_type},实际 {tk[0]}") if tk: self.pos += 1 return tk def parse(self) -> Expression: if not self.tokens: raise SyntaxError("表达式为空") expr = self.parse_or() if self.peek() is not None: raise SyntaxError(f"存在无法解析的Token: {self.peek()}") return expr def parse_or(self) -> Expression: left = self.parse_and() while self.peek() and self.peek()[0] == "OP" and self.peek()[1] == "||": self.consume() right = self.parse_and() left = OrExpression(left, right) return left def parse_and(self) -> Expression: left = self.parse_not() while self.peek() and self.peek()[0] == "OP" and self.peek()[1] == "&&": self.consume() right = self.parse_not() left = AndExpression(left, right) return left def parse_not(self) -> Expression: if self.peek() and self.peek()[0] == "NOT": self.consume() inner = self.parse_not() return NotExpression(inner) return self.parse_atom() def parse_atom(self) -> Expression: tk = self.peek() if tk and tk[0] == "LPAREN": self.consume() expr = self.parse_or() self.consume("RPAREN") return expr left = self.parse_value() op_tk = self.consume() if op_tk[0] != "OP": raise SyntaxError(f"期望比较运算符,实际 {op_tk}") right = self.parse_value() return CompareExpression(op_tk[1], left, right) def parse_value(self) -> Expression: tk = self.consume() if tk[0] == "NUMBER": return ConstantExpression(float(tk[1]) if "." in tk[1] else int(tk[1])) if tk[0] == "STRING": return ConstantExpression(tk[1]) if tk[0] == "VAR": return VariableExpression(tk[1]) raise SyntaxError(f"无法识别值类型: {tk}")递归下降法本质上是把文法规则直接翻译成一组互相递归调用的函数,每个函数对应一个优先级层级。parse_or调用parse_and,parse_and调用parse_not,以此确保优先级顺序不会错乱。这个过程和解释器模式是绝配:语法分析阶段的每种文法规则,对应解释器阶段的一个表达式节点。
2.5 完整流程测试与踩坑记录
把上面所有类放一起,跑几个用例。
if __name__ == "__main__": cases = [ ("amount > 100 && count > 3", True), ("amount <= 50 || level == \"VIP\"", True), ("!(amount > 100) && level != \"normal\"", False), ("amount > 100 && (count > 5 || level == \"VIP\")", True), ("(amount > 100 && count > 3) || level == \"VIP\"", False), ] for text, expected in cases: ast = Parser(text).parse() result = ast.interpret(Context({"amount": 200, "count": 5, "level": "VIP"})) print(f"{text} => {result} | 期望 {expected}")跑下来结果全部符合预期。不过我在调试过程中碰到几个容易翻车的地方:
Token正则顺序不能乱,>=和<=必须排前面,否则会匹配成两个Token。我一开始把>写在>=前面,结果解析>=直接报语法错误。
字符串常量去引号要在词法层做掉,不要在解释层做。如果留在解释层,每次比较前都要判断类型,侵入性很强。词法层解决完,解释层可以完全无感。这一条建议养成习惯:能把语法细节越早消化掉越好,别把脏活留给解释器。
2.6 解析和解释一定要分家
很多人初次接触解释器模式,容易把解析和解释混在一起写:一边读字符一边算结果。这个做法短小的表达式勉强能跑,一旦表达式带括号、带优先级,马上失控。
我的经验是,哪怕是几十行的微型DSL,也要严格分成解析阶段和解释阶段。解析阶段返回语法树,解释阶段只接受语法树。这样带来三个直观好处:
语法树可以复用。同一份规则字符串,只需要解析一次,然后放在缓存里供不同上下文的用户重复执行,性能收益非常大。 调试友好。表达式算错时,把语法树打出来,一眼能看到节点组合关系,比在混乱的字符串指针里找bug高效太多。 扩展容易。新增一种运算符,只需要修改解析函数和表达式类,不需要动解释执行框架。而解释框架其实是稳定的,始终是“递归调用interpret”。
如果你把解释器模式理解为“一个能自己执行的语法树”,那就等同于把“解析和解释”绑死了。正确理解是:解释器模式关注的是解释阶段的对象组织方式,而解析阶段本质上是独立的前置流程。很多讲设计模式的书把两者混在一起讲,是造成大家难落地的主要原因。
3. 模式背后的设计玄机与取舍
3.1 为什么用类来表达文法规则
一门语言有文法规则,比如“一个或表达式由一个或两个与表达式通过逻辑或连接”。不用类的话,你用一堆if-else也能处理Token流。但一旦文法变丰富,分支会爆炸。
用类表达文法规则,本质上是把“文法规则的种类”变成了“类的种类”。每个节点类负责一种规则的解释行为,职责单一;组合关系就是对象引用。这是组合优于继承的经典应用。你不需要在一个长函数里维护一堆分支判断,而是让对象自己决定自己的行为。这类风格的代码,将来加规则时对已有代码的修改量趋近于零,只需要加新类和新解析逻辑。
3.2 终结符与非终结符的协作本质
终结符表达式是树上的叶子,它本身不会递归调用子节点,只直接返回一个结果。非终结符表达式是内部节点,它持有子节点的引用,解释时先让子节点各自解释,再汇集结果。
整棵树的执行过程是一个深度优先遍历。以amount > 100 && count > 3为例:
- 根是AndExpression
- 树的左子树是CompareExpression(a > 100)
- 树的右子树是CompareExpression(count > 3)
- 执行时先解释左子树:先解释amount变量得到200,解释常量100得到100,比较得到true
- 再解释右子树得到true
- 根节点做逻辑与,返回true
这个执行模型极度直观,可推导,可单测。如果树中任何节点的结果和预期不一致,只要单独执行该节点就能定位问题。
3.3 上下文把“外部世界”和“语法树”解耦
上下文是解释器模式里最容易被低估的角色。它本质上是语法树与外部数据之间的一层隔离。语法树里的VariableExpression不持有实际数据,只持有变量名,具体值从Context中按名获取。好处很明显:
同一个语法树可以作用于不同的Context,也就是同一套规则可以批量应用在成千上万条业务数据上。模板渲染引擎能复用编译后的模板正是这个道理。
语法树本身不依赖运行环境,可以序列化保存,下次启动直接加载AST,省去解析成本。
Context里理论上还能放一些共享中间结果,比如子表达式的缓存结果,以空间换时间。这个优化在某些递归重的场景特别有效。例如(a && b) || (a && c),如果Context记下子表达式结果,可以避免重复计算。
3.4 与组合模式的关系
学解释器模式时,一定要看组合模式。因为语法树本身就是一棵部分-整体的层次结构,这点和组合模式是重叠的。区别在视角:组合模式强调的是“部分与整体保持一致”,容器节点与叶子节点对客户端暴露同样的接口;解释器模式强调的是“通过解释接口来计算复杂表达式的值”。
换句话说,解释器模式是组合模式在特定语义领域中的应用。理解这一点能帮你把两个模式都掌握得更透。当你写一个解析器时,你构造的树本质上就是一个组合结构,而每一个子节点解释自身行为,就是组合模式里每个叶子执行特定操作。
3.5 模式的核心缺陷也要心里有数
解释器模式不是银弹。它最大的问题是:每个文法规则对应一个类,规则越多类越多。如果表达式有20种运算符、5种值类型,很容易膨胀出几十个类,项目瞬间变重。其次,递归解释可能带来性能瓶颈,尤其表达式很长、上下文数据量很大时。最后一个缺点是:如果没有清晰的分层,解析和解释逻辑纠缠在一起,整个模块会迅速腐烂。
所以选型最重要的问题不是“这个模式怎么用”,而是“这个场景该不该用”。接下来专门聊选型。
4. 什么时候用什么时候不用:做技术选型要清醒
4.1 适合使用解释器模式的信号
我做了几年业务系统的感受是,下列信号同时出现时,解释器模式是很好的选择:
需求里真的出现了一种“语言”。比如运营配置的表达式、审批流配置的条件、报表的过滤条件。这里的语言不一定是很严肃的自然语言,哪怕只是几张有限的句子格式,也构成了语言。 这种语言的规则频繁变化或高度组合。固定两个条件用if-else就好,但允许任意组合,并且未来会增加新运算符时,硬编码不堪重负。 表达式会被重复解析执行。例如一个规则要批量作用于数万条用户数据,一次性构造语法树,后续只解释不解析,收益很大。 你对语法的扩展方向有预期。如果明确知道三个月后会加入新的比较运算或逻辑运算,那么解释器模式的扩展模式非常契合。
4.2 什么情况千万别用
反过来,很多场景用解释器模式其实是过度设计。比如:
表达式形态固化成几种,没有组合需求,直接if-else就能解决。 写法上只是为了躲避一两个条件分支,你把模式硬套上去反而不如一段简单函数清晰。不要为了用设计模式而用设计模式,这句话相信每一个被维护祖传代码折磨过的人都懂。 没有技术团队能长期维护递归下降解析器。一套递归下降解析器需要对应的单元测试、边界case和文档积累。如果团队频繁换人又不写测试,手写解释器会成为后人的噩梦。 这时可以选一些成熟的表达式引擎库,或者干脆用现有的配置项组合。
下面这个表格可以帮你快速判断:
| 信号 | 倾向用解释器模式 | 倾向别用 |
|---|---|---|
| 表达式组合能力 | 需要任意嵌套组合 | 只有固定几种组合 |
| 语法规则变化频率 | 频繁新增运算/操作 | 半年都不变 |
| 执行重复度 | 一个规则要跑大量数据 | 每个规则只跑一次 |
| 团队维护能力 | 有完整测试和代码评审 | 流浪代码没人维护 |
| 工具链现状 | 无合适现成引擎 | 已有成熟规则引擎可用 |
4.3 与策略模式、模板方法、状态模式的区别
解释器模式经常被拿来做对比的还有策略模式、模板方法模式、状态模式。
策略模式解决的是“算法可以互换”,适合同一类行为不同实现,比如多种排序策略。它缺少“文法组合”能力。如果只需要在两到三种算法里挑一种,用策略模式就好,根本没有必要解释一个表达式。 模板方法模式解决的是“算法的骨架固定,部分步骤延迟到子类实现”,关注的是流程的复用,而非语言的解释。如果你说“我要套一组固定流程,但每步可以微调”,那用模板方法。 状态模式解决的是“对象状态不同,行为不同”,它强调的是状态驱动流转,适合状态机类业务。如果条件解析是基于当前状态的转移,状态模式会比解释器更顺手。
真正的解释器模式和策略模式的结合倒是常见的。例如一个复杂的组合规则里,某些叶子节点对应的行为是一个策略。这样的话,解释器负责把条件组合串起来,策略负责叶子节点的执行结果,双方各司其职。
4.4 我遇到的一个“不该用却用了”的教训
以前某公司有个需求:后端给前端传一段简单的过滤条件,前端根据条件渲染隐藏某些UI组件。条件只有三种:用户是内部员工、用户等级大于某个值、用户所在城市白名单。
那时我刚学完解释器模式,热血上头,写了一个完整的递归下降解析器,支持各种组合条件。结果呢?需求经过大半年,真的只用了三种固定条件,从来没有任何组合出现。维护者每次改需求,看着解析器里的一堆类都头疼。后来重构时把它换成了三行if-else。
这个经历给我的教训很真诚:模式是工具,不是装饰。一个if-else都写不明白,就不要硬上解释器。只有在语言成为产品功能的一部分时,才值得动用这套体系。
5. 工程化落地与性能优化
5.1 解释器模式的性能瓶颈在哪里
性能问题要从解析和解释两段分别看。
解析阶段的成本主要在词法分析和语法分析的字符遍历,以及为每个节点创建对象。表达式越长,Token越多,对象越多,解析时间越长,还伴随一定的内存占用。
解释阶段的成本主要是频繁的递归调用和节点对象的方法调用。相比直接写成一行if-else,这样间接跳转带来的性能开销肯定大。对于业务系统来说,单次表达式的解释往往是微秒级别,不是问题。但假如你用一套规则去过滤上百万条数据,每条数据都来一次语法树解释,性能就会被放大。
5.2 三个立竿见影的优化手段
一是缓存AST。同一段表达式文本只解析一次,后续任何上下文都复用这棵树。这是所有优化里最见效的一条。我曾经把一个报表系统做优化,原来每次查询都重新解析表达式,改成AST缓存后总体响应时间下降约40%,并且后续复用越多次收益越大。
二是解释器内部缓存上下文结果。比如同一棵语法树在处理大量数据时,某部分子表达式的结果可能在整个批次内稳定不变,就可以在第一次算完后缓存到Context里,后续命中直接返回。这个优化点要结合业务情况看,如果数据变化频繁,这个缓冲反而会成为脏数据根源,慎用。
三是共享节点。对于重复出现的相同结构,比如多个规则里都有amount > 100这段子树,可以缓存这个子树对象复用,减少内存占用。对于终结符表达式,甚至可以通过享元模式共享同一个变量表达式节点。
5.3 工程案例:把报表过滤条件做成可配置DSL
我们之前做一个数据分析平台,每个报表页支持用户自定义过滤条件,条件是字符串表达式,例如:
channel == "app" && (amount > 1000 || pay_count >= 3)下单时间戳,直接存字符串表达式,每次查询都现解析,性能惨不忍睹。后来按解释器模式重构,流程是:
后台配置页面编辑表达式 → 保存前调解析器校验并生成AST → AST序列化存入数据库 → 查询时加载AST直接解释执行。
这样最耗时的解析过程被前置到了配置保存阶段,查询阶段只做解释。用户体验从“改完配置要等一秒”变成“秒开”,效果非常明显。另一个收益是校验前置有效地拦截了大量非法表达式,运营再也不会因为配错规则导致数据全空。
这个案例可以给大家一个重要思路:解释器模式不一定非要在运行时解析字符串。只要能序列化语法树,就能把解析成本从热路径里踢出去。这在工程上价值极大。
5.4 自己写解释器还是引第三方规则引擎
每逢聊到解释器模式,肯定有人问:“现在都有很多成熟规则引擎了,为什么还要手写?”答案是看需求边界。
如果需要支持的语言很小、很定制,比如就三四种运算符,引一个重量级规则引擎,引入大量依赖和复杂配置,纯属杀鸡用牛刀。而如果你需要的表达能力接近SQL,还是趁早引一个成熟的解析库,手写一个完整SQL解释器不是闹着玩的。
我通常按这个原则做决策:
| 场景复杂度 | 建议方案 |
|---|---|
| 简单比较+逻辑,3~6个运算符 | 自己写解释器,核心代码200行内 |
| 中等复杂度,多类型操作,语法20种以上 | 结合成熟的表达式解析库 |
| 接近SQL、脚本语言 | 用专用语言引擎,不要自研 |
大多数业务判断其实都是配置化的布尔表达式,手写解释器就非常够用。太复杂的需求,团队的时间和精力应该花在业务本身上,而不是再造一个语言。
6. 常见问题与避坑经验
6.1 递归过深导致栈溢出
解释器模式天然依赖递归。表达式嵌套层数过深时,比如五百层括号的构造,Python或者Java的递归调用栈有可能会爆。实际业务里不太会出现五百层括号,但输入来自前端时尤其要注意有人故意提交恶意表达式把服务打挂。
解决办法有三层。词法分析或解析阶段就限制最大深度,超过阈值直接抛异常。这属于风控手段,非常重要。解析时限制Token总数,表达式长度限流。必要时可以把递归解析改成显式栈的迭代解析,这个做法复杂度较高,一般建议前两种先上。
6.2 上下文线程安全
解释器执行时如果上下文是多个线程共享的,并且有中间缓存,就要加锁或保证Context不可变。反过来,如果每次线程用独立Context,语法树本身只读不修改,就可以放心并发执行。这是解释器模式在并发环境里最需要注意的一点。
区分关键是什么。语法树可以共享,上下文不能共享。让上下文携带每次执行的独立状态,把全局共享信息全部放进不可变常量里。
6.3 异常信息定位难
语法解析时报错通常只会告诉人“第几行第几列语法错误”,对非开发者不友好。手写解释器不给足错误上下文,就成了灾难。
建议所有节点对象都在构造时带上原始Token的行列号或偏移量。解释执行报错时,不仅能提示“运行时错误”,还能直接指出是表达式里哪一段出错。对于运营系统来说,这种可读的报错能力比优化性能更提升体验。
调试的时候,把AST结构用缩进树打印出来是非常科学的办法。每个节点打印自己的类型、关键字和子节点。我经常靠这个一眼找到优先级或括号匹配问题。完整的系统里,我会给AST加一个to_tree_string方法,用字符串缩进展示层次结构,测试失败时也方便对比。
6.4 运算符优先级最容易翻车
优先级处理是手写解析器的高频事故区。核心原则是:每降低一个优先级层级,就多一层函数嵌套。!比比较优先级高,所以parse_not在parse_atom之前处理;&&比||优先级高,所以parse_and在parse_or里被调用。
调试优先级问题,最好的办法就是为每个层级写独立的单元测试。例如:
true || false && false应该结果为true,因为&&先算。!true && false应该结果为false,因为!优先级高。
如果结果和你心智模型对不上,打印AST树,看层级关系一目了然。
关于等号与比较运算,用==比较两个浮点数值时,浮点误差可能造成意外结果,业务上建议在词法阶段把常量归一化。如果规则里大量使用精确数值判断,用Decimal替代float。
6.5 综合检查清单
给你一份我实践中沉淀的检查清单,写解释器之前过一遍:
- 表达式文本是否需要进行长度与复杂度限制?限制措施在词法层还是网络层完成?
- 语法树是否可以序列化?能够从存储加载而非运行时解析?
- 上下文设计是否保证语法树无状态?能不能并发解释?
- 每个节点有没有完整错误信息(偏移量、节点类型、原始输入片段)?
- 涉及浮点数值比较时,是否已经做了精度归一化?
- 是否准备好递归深度限制或迭代版本,防止恶意表达式拖垮进程?
- 是否区分了解析阶段和解释阶段,让AST可复用?
- 新增运算符时,解析器和节点类都能独立扩展,完全不需要修改已有节点吗?
按这个清单走一遍,基本不会在工程项目里把解释器模式写残。
最后分享一个我调整过很多次的理解方式:解释器模式,其实是在为问题空间“发明一门很小的语言”,然后再把这门语言翻译成对象行为。真正的复杂度从来不在于那几十个表达式类,而在于你是否搞清楚了你自己定义的语言边界。规则越明确,边界越清晰,这个模式的威力越大。如果你能从自己的业务中察觉出“有人在用字符串表达某种规则”,那么恭喜你,你已经知道什么时候该掏出解释器模式了。