
如果你在C项目里处理过那种“规则经常变、每次条件组合都不太一样”的需求大概率遇到过这样的场景需求方说下次活动佣金比例改成“订单金额大于5000并且未退款时按1.5倍计算”于是你在代码里加一个if-else过了两周又说“还得看用户等级”又加一层嵌套。改了十几版之后谁也不敢动了因为任何一个分支变化都可能影响之前的规则。我就是在这种情况下认真研究起C中的解释器模式的。这篇文章不打算讲教科书上的抽象概念堆砌而是会从实际需求出发先讲清楚这个模式到底解决了什么问题再手把手写一个完整可编译的四则运算解释器然后告诉你C实现里那些文档不会写、踩了才知道的坑最后用一个真实发生过的项目复盘来说明它该在什么边界内使用。适合对设计模式有基本了解、想在C工程里真正落地解释器模式的开发者参考。1. 为什么需要解释器模式从一坨if-else说起1.1 一个每天都在变的业务规则我接到过一个内部工单系统的需求审批流转规则特别复杂比如“金额超过1000元且申请人是部门主管时必须走二级审批”“项目级别为A或者金额低于500元时自动通过”。第一版实现理所当然地写成了一串硬编码判断每个判断分支都是一个if。if (amount 1000 level 3) { goToApproval(2); } else if (amount 500 || projectLevel A) { autoPass(); }这种写法在一两个规则的时候问题不大可一旦规则变成几十条问题就全出来了每加一条新规则都必须改代码、重新编译、重新发布测试冗余且效率极低。规则之间的优先级和冲突肉眼难查两个条件都满足时走哪个分支只有跑起来才知道。业务人员想自己调规则完全做不到永远要排队等开发。这个场景其实非常经典程序里存在一种“语言”它的句子是金额 1000 级别 3语法结构是固定的但句子的组合方式几乎无穷无尽。如果每来一种组合就写一个处理逻辑代码爆炸是迟早的事。解释器模式正是为这种问题而生的。1.2 解释器模式的本质把“行为”变成“数据”解释器模式的核心思想可以用一句话概括为语言中的每一种文法规则提供一个类然后用对象组合去表达一条具体的“句子”最后让这些对象互相递归调用完成解释执行。这样说太抽象。我换个生活化的比喻。想象一张做菜的配方单上面写着“先热锅再放油最后下菜”。配方单本身是数据不是厨师的肌肉记忆。厨师拿到不同的配方单就可以按不同顺序做出不同菜。解释器模式就是把业务规则的“做法”写进配置化的配方单代码只负责“按配方执行”。对应到设计模式里就是建立一棵抽象语法树AST。树的叶子节点对应最简单的“终结符表达式”比如一个具体的数字、一个变量名树的内部节点对应“非终结符表达式”比如一次加法运算、一个大于比较。整棵树的根节点就是一条完整的业务规则。程序要做的事分为两步先把规则字符串解析成这棵树然后对树执行递归求值。1.3 逐帧拆解四个角色上下文、抽象表达式、终结符、非终结符这个模式在经典书里会讲四个参与者理解这四个角色代码怎么组织基本就清楚了角色职责C中通常的体现AbstractExpression定义统一的解释接口一个抽象基类包含virtual int eval(const Context) constTerminalExpression语法中的最小单元不再包含子表达式数字、变量、常量对应的类NonterminalExpression包含一个或多个子表达式通过组合表达文法规则加法类、比较类、逻辑运算类Context保存解释器执行时需要的环境信息变量名到值的映射表、全局参数等“上下文”这个概念很多人会忽略但它在真实场景里特别重要。同样是age 18这条表达式不同的人去解释age的值不一样。变量值不能写死在表达式对象里得在执行时通过Context传入这样同一个解析结果才能被反复复用。2. 手写一个C四则运算解释器完整可运行的代码四则运算带括号看起来简单却几乎覆盖了解释器模式的全部要点词法分析、终结符、非终结符、递归下降、优先级处理。把它写通你就有能力扩展到任何自定义业务规则。2.1 第一步词法分析把字符串变成Token流解释器要做的第一件事不是建对象而是把输入字符串拆成一个个“单词”也就是Token。比如输入3 4 * (2 - 1)我们要得到数字3、加号、数字4、乘号、左括号、数字2、减号、数字1、右括号、结束符这样的序列。这一步我习惯叫“切词”。它的价值在于把解析逻辑和字符串处理解耦后续解析器只关心Token流不用再处理空格和拼接问题。#include cctype #include stdexcept #include string #include vector enum class TokenType { Number, Operator, LeftParen, RightParen, End }; struct Token { TokenType type TokenType::End; char op 0; int value 0; }; std::vectorToken tokenize(const std::string text) { std::vectorToken tokens; size_t i 0; while (i text.size()) { char c text[i]; if (std::isspace(static_castunsigned char(c))) { i; continue; } if (std::isdigit(static_castunsigned char(c))) { int v 0; while (i text.size() std::isdigit(static_castunsigned char(text[i]))) { v v * 10 (text[i] - 0); i; } tokens.push_back({TokenType::Number, 0, v}); continue; } if (std::string(-*/()).find(c) ! std::string::npos) { TokenType t (c () ? TokenType::LeftParen : (c )) ? TokenType::RightParen : TokenType::Operator; tokens.push_back({t, c, 0}); i; continue; } throw std::runtime_error(std::string(无法识别的字符: ) c); } tokens.push_back({TokenType::End, 0, 0}); return tokens; }这里有几个设计点值得说明。第一Token只负责“切”不负责“算”它不判断操作符优先级优先级的活留给解析器。第二数字是多位拼接比如123会循环读取每个字符逐步累加。第三最后强制塞一个EndToken解析器看到它就知道输入已经结束这个哨兵值能省掉大量pos_ tokens.size()的边界判断。2.2 第二步表达式类层次终结符和非终结符如何设计现在需要建立抽象的表达式树节点。基类提供eval接口终结符和非终结符各自实现自己的解释逻辑。#include memory // 前向声明Context在下一节完整实现 class Context; class Expression { public: virtual ~Expression() default; virtual int eval(const Context ctx) const 0; };数字表达式是最典型的终结符它的eval不需要访问任何子节点直接返回自身保存的值。class NumberExpr final : public Expression { public: explicit NumberExpr(int v) : value_(v) {} int eval(const Context) const override { return value_; } private: int value_; };加法、减法、乘法、除法是非终结符。它们内部各持有左右两个子表达式。eval的语义是先递归求左子节点再递归求右子节点然后执行具体运算。这就是解释器模式里那句“让对象互相递归调用”的实际落点。class AddExpr final : public Expression { public: AddExpr(std::unique_ptrExpression l, std::unique_ptrExpression r) : left_(std::move(l)), right_(std::move(r)) {} int eval(const Context ctx) const override { return left_-eval(ctx) right_-eval(ctx); } private: std::unique_ptrExpression left_; std::unique_ptrExpression right_; };乘法和减法的类结构与加法完全一致只是运算符不同写代码时复制改一下运算符就行。这里我用四个独立类而不是用一个BinaryOpExpr类加op成员去区分符号一是严格贴合解释器模式“一类对应一条文法规则”的思想二是在后续扩展时每个类都能独立增加自己的行为比如除法类要检查除零加法类永远不用。用四个独立类违规操作只可能出现在它自己的类里逻辑隔离更干净。2.3 第三步递归下降解析器处理优先级和括号的两种武器有了类层次还缺一个关键环节怎么从Token流构建这棵树。我采用递归下降法这是手写解析器最实用的方案不用引入任何第三方库。优先级处理依赖一个核心技巧把优先级变成一个函数调用层级。加法减法一级乘法除法一级括号和数字是最底层。解析3 4 * 2时parseExpr只负责处理加减法它调用的parseTerm负责处理乘除法parseTerm调用的parseFactor负责取数字或括号表达式。这样*的运算自然会被包在的子表达式里求值时优先级就正确了。class Parser { public: explicit Parser(std::vectorToken tokens) : tokens_(std::move(tokens)) {} std::unique_ptrExpression parse() { if (tokens_.empty()) { throw std::runtime_error(空表达式); } auto expr parseExpr(); if (peek().type ! TokenType::End) { throw std::runtime_error(表达式结束后还有多余内容); } return expr; } private: std::vectorToken tokens_; size_t pos_ 0; const Token peek() const { return tokens_[pos_]; } const Token advance() { const Token t tokens_[pos_]; if (t.type ! TokenType::End) { pos_; } return t; } bool matchOp(char op) { if (peek().type TokenType::Operator peek().op op) { advance(); return true; } return false; } std::unique_ptrExpression parseExpr() { auto left parseTerm(); while (true) { if (matchOp()) { left std::make_uniqueAddExpr(std::move(left), parseTerm()); } else if (matchOp(-)) { left std::make_uniqueSubtractExpr(std::move(left), parseTerm()); } else { break; } } return left; } std::unique_ptrExpression parseTerm() { auto left parseFactor(); while (true) { if (matchOp(*)) { left std::make_uniqueMultiplyExpr(std::move(left), parseFactor()); } else if (matchOp(/)) { left std::make_uniqueDivideExpr(std::move(left), parseFactor()); } else { break; } } return left; } std::unique_ptrExpression parseFactor() { if (peek().type TokenType::Number) { Token t advance(); return std::make_uniqueNumberExpr(t.value); } if (peek().type TokenType::LeftParen) { advance(); auto expr parseExpr(); if (peek().type ! TokenType::RightParen) { throw std::runtime_error(缺少右括号); } advance(); return expr; } throw std::runtime_error(无法解析的表达式); } };这里最容易出错的地方是while循环的处理。我见过不少新手把parseExpr写成先parseTerm再if (matchOp())这样只能处理单个遇到1 2 3就会在第二次加号上报错。用while (true)不断尝试匹配只有匹配不上时才break才能处理连续的同优先级运算。括号的递归调用的妙处在于左括号出现时直接把整个表达式的解析权交回parseExpr右括号是它的终止信号。这很好地体现了递归下降“同一个函数处理不同嵌套层”的天然特性说白了就是自己调自己每层括号对应一层新的调用栈。2.4 完整代码串起来跑一个结果看看我把Context先定义成空类四则运算用不到变量但保留它能让后续扩展不用改接口。class Context { public: // 四则运算暂时用不到先保留空实现 };加上除法类检查除零、减法和乘法类再补一个evaluate入口函数class DivideExpr final : public Expression { public: DivideExpr(std::unique_ptrExpression l, std::unique_ptrExpression r) : left_(std::move(l)), right_(std::move(r)) {} int eval(const Context ctx) const override { int divisor right_-eval(ctx); if (divisor 0) { throw std::runtime_error(除零错误); } return left_-eval(ctx) / divisor; } private: std::unique_ptrExpression left_; std::unique_ptrExpression right_; }; int evaluate(const std::string text) { auto tokens tokenize(text); Parser parser(std::move(tokens)); auto expr parser.parse(); Context ctx; return expr-eval(ctx); }测试一下3 4 * 2 - (1 2) / 3手算结果是3 8 - 1 10代码输出也是10。整个解释器只用了一百多行代码就已经支持任意长度的加减乘除混合运算、任意层括号嵌套、运算符优先级、除零检测。这就是解释器模式“小语法低成本”的典型表现。3. C实现中教科书不会讲的六个坑代码跑通只是开始。我在实际使用中踩过不少坑有些是C语言本身的有些是解释器模式跟C特性结合后特有的逐个说一下。3.1 内存管理裸指针和unique_ptr之间的选择教科书里的解释器模式示例通常用裸new创建表达式对象然后要求调用方层层手动delete。问题在于解析过程中任何一步抛出异常比如缺少右括号前面已经new出来的节点就会泄漏。表达式稍微复杂一点内存泄漏点会多到你不想数。用std::unique_ptr是更稳妥的做法。每个表达式节点持有子节点的unique_ptr节点析构时自动释放子树。解析时用std::make_unique创建节点用std::move在不同变量间转移所有权。引入unique_ptr后会连带一个限制表达式类不能拷贝只能移动。这其实不算坏消息因为表达式树本身就是“一条路径一个所有权”的结构希望它可拷贝反而意味着你要处理深拷贝。如果真要拷贝一颗树给每个类写一个clone方法逐层复制子树明确表达意图。3.2 递归深度与栈溢出1000层括号能崩掉程序解释器的求值和解析都极度依赖递归而C的函数调用栈是有上限的。我写了一个测试表达式由1000层括号包着一个数字组成解析时parseFactor调用parseExprparseExpr又调回parseFactor每层左括号贡献至少两次函数调用结果在解析阶段就直接栈溢出了。这个隐患在四则运算里可能不明显但在业务规则场景中很容易出现规则由拼接字符串自动生成嵌套的括号数量不可控一旦超过几百层程序表现就是毫无征兆地崩溃。处理办法有三个层面。最直接的是在解析器里维护一个深度计数器超过阈值比如200就抛出明确异常避免栈溢出悄悄发生。其次是尝试把尾部递归改成循环但表达式解析并不总能容易地改造成尾递归。最根本的办法是控制产生表达式的上游逻辑比如禁止自动生成无意义括号。我的建议是至少把深度检查加上用一次显式的异常替代静默崩溃对线上系统来说是质的区别。3.3 左递归是最大的假朋友你可能会想既然3 4 5可以被解析成“先算34再和5相加”那文法就写成expr : expr number好了。如果真这样写递归下降解析器会立刻死循环parseExpr第一件事就是调用自己永远到不了结束条件。// 错误的写法会导致无限递归 std::unique_ptrExpression parseExpr() { auto left parseExpr(); // 一直在调自己 // ... }正确做法是把左递归文法改写成右侧递归也就是我在2.3里用的循环结构先解析一个完整的左操作数再用while循环反复匹配后续操作符和新的右操作数。这个改写本质上和文法expr : term ( term)*等价。理解这一点能帮你避免“为什么我的解析器一跑就爆栈”这类问题。3.4 负数运算一个减号两种身份我最初的四则运算代码不支持-3 5这种以负数开头的表达式。词法分析看到-就无条件标记为Operator解析器在parseFactor里遇到Operator直接报“无法解析”。一元负号的处理方式是在parseFactor里增加一个分支如果当前Token是-消费掉它然后递归解析后面真正的因子最后把结果取反。这实际上是给文法增加了一条新的终结符规则factor : - factor | ...。这里有个容易犯的错误一元负号的优先级。-3 * 2按数学直觉应该是(-3) * 2 -6而不是-(3 * 2) -6。两种写法在这条式子上结果碰巧一样但如果换成-3 2优先级处理错就会得到-(3 2) -5而正确结果是-1。把一元负号放在parseFactor这一层只作用于单个因子就能保证它比乘除法更紧密不会吞掉整个加法表达式。3.5 除零和错误提示解释器要把错误说得像人话除零检查很容易做难的是给出有用的报错信息。如果表达式是100 / (price - price)运行到深层节点才发现除零这时抛出的异常如果不带任何上下文使用者完全不知道是哪一部分表达式出了问题。我的做法是给每个表达式节点增加一个describe()方法返回它能表示的原文片段。异常消息至少包含“除零错误”和表达式描述比如100 / (price - price)。另一种思路是在解析阶段就维护Token位置信息把“哪个Token附近的运算出错”记录在节点上。做得越细使用方定位配置问题的速度就越快。3.6 整数溢出和隐藏的精读问题四则运算示例用的是int现实中的金额计算分分钟超范围。单价、数量、折扣乘起来结果可能超过2的31次方。这个问题我建议提前认清解释器模式的eval返回类型要根据领域选不是无脑int。金额用long long涉及小数用double精确计算用整数分单位。如果混合了多种数值类型接口设计会复杂一些但总比上线后数据溢出好。4. 更进一步带变量和比较运算的规则表达式四则运算演示了模式骨架但业务规则里更常见的表达式是age 18 status 2这种带变量、比较和逻辑运算的形式。这一节就是那些需要把解释器模式真正落地到业务代码的人最关心的部分。4.1 引入Context变量值在执行期注入之前的Context是空的现在它要有真正的功能维护变量名到数值的映射。所有变量表达式的eval从Context里查值查不到就抛出“未定义变量”异常。#include unordered_map class Context { public: void setValue(const std::string name, int v) { vars_[name] v; } int getValue(const std::string name) const { auto it vars_.find(name); if (it vars_.end()) { throw std::runtime_error(未定义的变量: name); } return it-second; } private: std::unordered_mapstd::string, int vars_; }; class VariableExpr final : public Expression { public: explicit VariableExpr(std::string name) : name_(std::move(name)) {} int eval(const Context ctx) const override { return ctx.getValue(name_); } private: std::string name_; };这段代码的精髓在于表达式树解析一次可以反复执行无数次每次执行只需要把新的变量值放进Context。规则就是配置数据就是上下文逻辑彻底分离。4.2 扩展文法比较运算和逻辑运算的类设计新增语法只需要干两件事第一定义新的表达式类第二在解析器里增加对应的解析函数。比较运算是一个典型带两个子表达式的非终结符。比如LessExpr它的eval先递归求左右子节点的值再返回布尔的整型表示。class GteExpr final : public Expression { public: GteExpr(std::unique_ptrExpression l, std::unique_ptrExpression r) : left_(std::move(l)), right_(std::move(r)) {} int eval(const Context ctx) const override { return left_-eval(ctx) right_-eval(ctx) ? 1 : 0; } private: std::unique_ptrExpression left_; std::unique_ptrExpression right_; }; class AndExpr final : public Expression { public: AndExpr(std::unique_ptrExpression l, std::unique_ptrExpression r) : left_(std::move(l)), right_(std::move(r)) {} int eval(const Context ctx) const override { return left_-eval(ctx) right_-eval(ctx) ? 1 : 0; } private: std::unique_ptrExpression left_; std::unique_ptrExpression right_; };解析层级比原来多了一层顺序从上到下决定优先级从低到高parseOrExpr - parseAndExpr - parseCompareExpr - parseAddExpr - parseTerm - parseFactor。的优先级低于所以status 2 age 18能被正确拆成(status2) (age18)。当然词法分析也要扩展识别字母和下划线开头的标识符作为变量名。这几百行代码写下来一个能用变量做条件判断的迷你规则引擎就成型了。4.3 让“新增语法”的成本降到最低把解释器模式用在业务里最实际的衡量标准是下个需求要求新增一个in运算判断某个值是否在集合里你要花多长时间按我现在的代码结构过程是固定的写一个InExpr类构造函数接收变量表达式和一组常量表达式在词法分析时让in成为关键字Token在parseCompareExpr里加一个匹配in的分支。三处改动互相隔离不影响其他运算。这就是解释器模式对“开闭原则”的兑现方式——新增文法规则系统原有部分一个都不用动。如果你发现自己每加一种运算都要去动已经稳定运行的表达式类那说明你的类层次分解出了问题检查一下是不是把太多职责塞进了同一个类里。5. 别滥用解释器模式的适用边界和替代方案把解释器模式神化是不对的我已经见过不止一个团队在不需要的地方硬上解释器最后维护成本比if-else还高。什么时候该用什么时候该换我归纳成三个判断维度。5.1 能用的信号语法固定、规则量大、需求方要自主配置符合下面几条解释器模式就是恰当选择语言本身很小文法规则能够稳定维持不会隔三差五变语法。规则的数量多且变化频繁配置化带来的收益大于解析器的开发成本。业务人员有自主调整规则的需求规则需要存成字符串放到数据库或配置中心。规则需要跨语言复用比如后端用C解析前端也要做同样的展示字符串式的表达式天然便于传输。我那个工单系统就符合这些条件。审批规则是典型的“小语法大规则量”语法只有金额、级别、部门、状态的比较和逻辑组合但具体规则条目有成百上千条而且每周都在调。配置化几乎是唯一可持续的解法。5.2 别用的信号语法复杂、性能敏感、一次性反过来下面这些情况我会强烈反对上解释器模式语法规则本身还在剧烈演进今天加减乘除明天就要支持数组、函数、循环这已经不是解释器模式的能力范围你需要的是一个通用脚本引擎。对性能极度敏感表达式每秒执行百万次以上AST递归求值的开销会变得可观。解释器模式慢在虚函数调用和动态内存分配这是结构性代价。规则总共就三五条写进配置和写进代码没有本质区别那还不如直接if-else省掉一整套解析和调试工具栈。团队里其他成员对递归和指针操作不熟代码维护会变成少数人的独门手艺风险极高。5.3 替代方案横向对比方案优点缺点适合场景硬编码if-else简单直接调试容易规则一多就膨胀变更要发版规则极少或根本不变化手写递归下降解释器模式轻量无依赖类职责清晰扩展可控需要维护解析逻辑语法太大会hold不住中型DSL规则配置化传统语法分析工具文法描述能力强自动生成解析器引入工具链学习成本高生成代码难调语法复杂且稳定的语言前端集成现成脚本引擎表达能力强现成生态重量级依赖性能开销大需要完整编程能力策略模式每个规则一个策略类类型安全类数量随规则组合爆炸规则种类固定、组合不复杂表格里没有绝对的“最好”只有“当前场景下的最合适”。解释器模式的位置恰好是中间地带比硬编码灵活比脚本引擎轻量代价是你自己要承担解析器的开发和维护。6. 一次真实复盘我把解释器模式用歪又掰正的过程6.1 项目背景和第一版实现回到那个工单系统。第一版的审批规则判断是写在服务端代码里的不同的审批环节嵌了十几处if-else负责维护的同事每次改规则都战战兢兢。后来需求方提出规则要支持“配置化调整”我才开始做规则翻译层。第一版我过度设计得很厉害。我不仅把表达式解析做了还顺手设计了一套规则配置界面想做到在网页上拖拽组件生成表达式。结果网页端和解析器端对表达式结构的理解开始出现偏差前后端联调浪费了大量时间。这是我第一次意识到解释器模式解决的是“解析和求值”问题它不负责解决“表达式怎么写”的问题后者的复杂度被我想小了。6.2 用了解释器模式后的正面效果把规则全部迁移到表达式字符串之后效果立竿见影。几百个硬编码if-else变成了几百行配置数据每一条配置对应一棵运行时解析出来的AST。新增规则不再需要发版需求方在配置后台填一条amount 1000 level 3就能生效。系统里那段最核心的审批判断函数从300多行瘦身到了不到30行只剩“从配置读表达式、解析、求值、走分支”四件事。这种从“改代码”到“改数据”的转变才是解释器模式真正值钱的地方。代码的可测试性也大幅提升因为表达式是纯字符串可以写一批固定的测试用例覆盖全部文法不用再为了测一条规则去构造一堆对象。6.3 被新需求打脸与最终改造风平浪静一个月后新需求来了规则里需要支持日期段判断、多值集合判断比如sign_date in [2024-01-01, 2024-03-31]。这个语法扩展本身不复杂麻烦在于配置后台的联动逻辑和错误提示都开始绷不住。配置人员填错一个括号解析器只抛出一句“语法错误”对方完全不知道错在哪里。最后的改造没有推翻原设计而是做了三处修整第一解析错误信息带上Token位置格式变成“第12个字符附近未闭合括号”配置人员能对着字符串自查第二解析AST后增加一次性静态校验不等到求值阶段才发现缺变量第三对高频规则做了AST缓存相同的表达式字符串不再重复解析直接复用树结构。经过这样一轮系统才算达到了“业务人员可自主维护”的状态。6.4 我给后来者的三条经验整个项目走下来我最想强调的经验有三条。第一条解释器模式的上限由表达式的可调试性决定。功能上能解析只是及格让配置规则的人能独立排查问题才是成功。从一开始就要设计友好的错误信息否则模式带来的“可配置”红利会被“不可维护”的代价吃掉。第二条AST缓存是不起眼的巨大优化。业务规则往往只有几十上百条模板但每条规则每天被执行上千次。规则引擎里最贵的其实是字符串解析而不是AST求值。把“解析结果”缓存起来性能可以提升一个数量级代码改动却非常小。第三条语法之外的东西先想清楚再动手。配置从哪来、配置如何校验、配置如何回显、错误如何追踪这些周边问题决定了解释器模式项目是一帆风顺还是深陷泥潭。模式本身只是发动机你得先确认车壳、变速箱和方向盘都准备好了再把它装上去。用过一轮之后我现在看解释器模式的心态平和了很多。它不是什么高深莫测的魔法本质就是“给一组固定文法建对象树然后递归执行”。真正决定成败的是你有没有在动手前想清楚规则的边界、错误处理的方式和长期的维护成本。把这几点想透C里的解释器模式就是一个非常顺手且可靠的工具。