资讯动态

C++解释器模式四种变体:从多态AST到指令数组

发布时间:2026/10/19 9:09:25 来源:尧图企业网站定制
解释器模式在C里的名声一直有点拧巴。教科书把它定义成“为语言定义文法并解释执行”但真实项目里你几乎不会专门说“我今天要用解释器模式”。往往是做着做着手里的代码已经变成了一个公式计算器、一套优惠规则引擎、一段条件动作脚本回头一看——这不就是解释器模式吗。可一旦从“事后复盘”变成“刻意设计”两个经典问题马上冒头要么照着经典设计模式的样板把抽象基类和虚函数铺得满天飞要么图省事写一堆字符串匹配和if嵌套最后语法解析和业务逻辑糊在一起改都改不动。我写这篇东西的初衷是想把解释器模式在C里常见的几种“变体”摆在桌面上聊聊各自的适用场景和取舍。它适合两类人一类在业务里维护过规则引擎、参数公式、简单脚本想知道除了教科书写法还能怎么组织另一类是设计模式刚入门想看看C里怎么把解释器模式写得不那么“教学味”。网络上的讨论大多站在Java和C#的视角几乎不碰C比较别扭的那几个问题解析出来的AST节点谁负责释放、能不能用std::variant替代多态树、既然有了lambda为什么不用闭包当表达式——这些才是决定C项目里这个模式长什么样的关键。1. 解释器模式在C里最容易踩的预期偏差先说实话绝大多数C项目不会单独“实现一个解释器”。解释器模式通常是长出来的不是设计出来的。你可能只是想把一段 DSL 字符串变成可执行逻辑比如从配置表里读一个表达式算完再决定走哪个分支业务规则允许运营在后台编辑“满300减30”这样的优惠条件流程引擎要执行一系列事件-条件-动作脚本日志过滤器要用一套简单查询语法过滤流式消息。这些场景的共同特点是需要把一段文本/结构翻译成可执行语义而且行为在运行期间动态到达。解释器模式解决的就是这个问题——把一种语言哪怕很小的领域语言映射成程序里的对象或结构然后针对结构求值。它和大名鼎鼎的“四次元”无关纯粹是计算机科学里“语言-语义”分离的老祖宗。但C里照搬教科书的解释器模式往往会踩三个“预期偏差”。第一个偏差是过度设计。教科书为了讲清楚会把“终结符表达式”和“非终结符表达式”抽象成两个层次因为Java/C#里多态是表达“一组同类操作”最直接的机制。但C里还有重载、模板、variant、std::function、闭包等一堆武器。如果你脑子里只装着“抽象基类虚函数”那么一个三天能做完的过滤规则可能一周还在调整类层次。第二个偏差是内存所有权被忽略。Java里new了节点不用管回收C里你每写一个new Expression都得回答“谁来delete”。很多教程代码直接shared_ptr乱飞最后节点之间互相持有引用解析一次表达式泄漏一片。这块不掂量清楚模式越大摔得越惨。第三个偏差是把“解析”和“解释”混为一谈。解析是把字符串变成中间结构解释是对中间结构求值。解释器模式的重心其实在后者——中间结构怎么表示、怎么被遍历求值。很多失败的实现都是把这两步缝在一个函数里结果想换语法、想缓存AST、想做性能优化全都动弹不得。后面的每一种变体本质上都是在三个维度上做取舍中间结构怎么表示多态对象、variant、闭包、指令数组、求值如何发生递归遍历、std::visit、调用闭包、虚拟机循环、内存和性能代价落在哪堆分配、类型擦除、间接跳转、分支预测。先把这三个维度刻在脑子里再看变体会清楚很多。2. 经典多态AST实现先立一个完整的参考系不把参考系立起来讨论变体就是空中楼阁。所以我先用经典写法把解释器模式的关键部分搭一遍后面所有变体都和它对比。2.1 基础类层次Expression与四个节点经典做法的核心是一个抽象基类Expression下面派生出数字字面量、变量、二元运算等节点。拿一个常见例子折扣规则price * 0.8 fixedDiscount。class Context { public: double get(const std::string name) const { auto it variables.find(name); if (it variables.end()) { throw std::runtime_error(unknown variable: name); } return it-second; } void set(const std::string name, double value) { variables[name] value; } private: std::mapstd::string, double variables; }; class Expression { public: virtual ~Expression() default; virtual double evaluate(const Context ctx) const 0; }; class Literal : public Expression { double value_; public: explicit Literal(double v) : value_(v) {} double evaluate(const Context) const override { return value_; } }; class Variable : public Expression { std::string name_; public: explicit Variable(std::string name) : name_(std::move(name)) {} double evaluate(const Context ctx) const override { return ctx.get(name_); } }; class BinaryOp : public Expression { char op_; std::unique_ptrExpression lhs_, rhs_; public: BinaryOp(char op, std::unique_ptrExpression lhs, std::unique_ptrExpression rhs) : op_(op), lhs_(std::move(lhs)), rhs_(std::move(rhs)) {} double evaluate(const Context ctx) const override { double l lhs_-evaluate(ctx); double r rhs_-evaluate(ctx); switch (op_) { case : return l r; case -: return l - r; case *: return l * r; case /: return r 0.0 ? l : l / r; default: return l; } } };然后需要一个递归下降解析器负责把price * 0.8 fixedDiscount变成上面的树。这个解析器本身不算解释器模式的范畴但它是标配。后面每一个变体都默认带解析器区别只在产出物。price * 0.8 fixedDiscount对应的树长这样根节点是BinaryOp()左子树是BinaryOp(*)左孙子是Variable(price)右孙子是Literal(0.8)根的右子树是Variable(fixedDiscount)。求值就是递归往下算。2.2 教科书写法在C里的三个真实痛点第一个痛点是深拷贝和序列化。unique_ptr解决了所有权问题但树一旦想拷贝、进容器、持久化全变成麻烦事。你得手写clone()递归复制每个节点你想把AST存到数据库里做规则缓存得再写一套遍历序列化。这活儿不难但很烦而且每加一种节点就要同步改。第二个痛点是虚函数调用和缓存局部性。一次evaluate根节点一次虚调用左右子树各一次每个节点都要通过vptr跳转。表达式层数稍微深一点虚调用次数肉眼可见地涨。对一个一天被调用几万次的折扣计算这不是问题但对一个每秒处理几十万条消息的规则引擎这个开销会变成可测量的性能短板。第三个痛点是扩展成本和访问者模式的负担。要给树加一种“取最大值”节点你得新增类、改解析器、可能还要改求值器。想保持开闭原则当然可以上访问者模式可C的访问者模式写起来噪声很大每个新节点要改访问者接口每个访问者要实现所有visit方法。表达式类型一多代码公式化严重。小结一下经典写法最大的优势是直观AST长什么样代码就长什么样调试和可视化都很方便。它最大的代价是间接性和维护成本。后面的变体就是在尽量保住直观性的前提下把间接性和维护成本往下压。3. 变体一std::variant驱动的值语义表达式树第一个变体核心是用std::variant替代类继承体系。C17开始variant在“表达一组固定异构类型”这件事上很多时候比多态基类更直接。3.1 用variant描述节点类型还是刚才那组表达式数字字面量、变量、二元运算。用variant写出来是这样struct Expr; struct Literal { double value; }; struct Variable { std::string name; }; struct BinaryOp { char op; std::shared_ptrExpr lhs; std::shared_ptrExpr rhs; }; using Expr std::variantLiteral, Variable, BinaryOp;注意这里有个小坑Expr是递归类型树节点里又含Expr。直接写using Expr std::variantLiteral, Variable, BinaryOp;然后让BinaryOp里嵌两个Expr这是不行的——variant计算大小需要完整的成员类型而Expr自己还没完整定义。解决办法就是间接引用BinaryOp里持有std::shared_ptrExpr或者std::unique_ptrExpr。用shared_ptr更省心因为它在析构时不需要Expr的完整定义。工程上如果不想引入额外的所有权语义纠结用shared_ptr做递归节点是退而求其次但稳定的选择。3.2 用std::visit统一求值求值不再靠虚函数而是靠std::visit一次性分派到具体类型double evaluateExpr(const Expr expr, const Context ctx) { if (std::holds_alternativeLiteral(expr)) { return std::getLiteral(expr).value; } if (std::holds_alternativeVariable(expr)) { return ctx.get(std::getVariable(expr).name); } const auto bin std::getBinaryOp(expr); double l evaluateExpr(*bin.lhs, ctx); double r evaluateExpr(*bin.rhs, ctx); switch (bin.op) { case : return l r; case -: return l - r; case *: return l * r; case /: return r 0.0 ? l : l / r; default: return l; } }用std::visit的lambda写法更符合现代C的紧凑风格不过两个if分支加std::get反而更容易读因为递归结构要面对的是shared_ptrExpr直接visit拿到的都是值语义引用不需要动指针。3.3 这个变体的收益和代价收益非常实在值语义。Expr是一个可以整体拷贝、放进vector、当作函数参数的值对象。你在测试里可以直接写Expr e BinaryOp{ , ..., ... };不用到处弄unique_ptr。编译期类型安全。你想访问不存在的成员编译器当场报错。基类那套运行时向下转换在这里不存在。缓存局部性更好。variant在栈上是紧凑存储的不会像堆分配节点那样每个对象跳来跳去。代价也很明确新增一个节点类型要改哪儿都清楚。每次给variant加一个类型所有visit里的分支都要审视一遍编译器会帮你找到遗漏。递归variant的写法在C17里有点绕绕不过shared_ptr/unique_ptr间接引用等于又把一部分所有权问题请回来了。丢掉了“运行期动态扩展”。如果节点类型在运行时才能确定variant没法搞多态基类允许插件动态注册新表达式variant是静态闭合的。我个人的经验是**当表达式类型集合稳定、公司内部规则就是那么几种固定运算符时variant方案比多态方案舒服得多。**它特别适合做“计算器表达式”“过滤条件表达式”这种封闭集合。一旦你确定未来一定会加新类型又要追求插件化扩展那还是回去用继承。4. 变体二lambda组合子把表达式变成可执行闭包这是我在业务项目里用得最多的变体。思路很简单解析器直接生产“函数”而不是生产“树”。每一个表达式节点被编译成一个std::function上下文参数作为入参返回计算结果。4.1 用闭包直接表示语义沿用折扣表达式的例子。“读取变量价格”本身就是一个闭包using ExprFn std::functiondouble(const Context); ExprFn makeLiteral(double value) { return [value](const Context) { return value; }; } ExprFn makeVariable(std::string name) { return [name std::move(name)](const Context ctx) { return ctx.get(name); }; } ExprFn makeBinaryOp(char op, ExprFn lhs, ExprFn rhs) { return [op, lhs std::move(lhs), rhs std::move(rhs)](const Context ctx) { double l lhs(ctx); double r rhs(ctx); switch (op) { case : return l r; case -: return l - r; case *: return l * r; case /: return r 0.0 ? l : l / r; default: return l; } }; }解析器拿到price * 0.8 fixedDiscount之后做递归下降但产出的不是可检视的AST而是一个闭包ExprFn fn parseExpression(tokens); double result fn(ctx);这个fn可以被直接存储、复制、放进unordered_map当回调。调用它就是在“解释执行”。4.2 为什么这个变体好用它把“树”这个概念消解掉了直接拥抱C的函数式表达力。好处代码极短。没有节点类没有visit没有继承。一个表达式一个闭包解析器写起来清爽。天然支持高阶组合。你可以不解析文本直接在C代码里用makeBinaryOp(*, makeVariable(price), makeLiteral(0.8))拼接逻辑。这比AST好看多了也更容易做单元测试。惰性求值。闭包只有再调用时才真正执行。你可以把规则闭包存起来等事件触发了再传入当时的Context求值。这种“延迟解释”在规则引擎里特别实用。跨层传递优雅。闭包是一个可拷贝的值对象可以直接塞进业务对象、发到工作队列、被序列化框架处理。我心里一直把它叫做“解释器模式的lambda化变体”因为它保留了“文本-语义”的翻译过程只是省略了中间AST。它和函数式编程里的“高阶函数拼接”几乎是一回事。4.3 坑闭包树不可检视有一类需求会让这个变体难受——需要检查表达式结构。比如规则平台要求后台展示“这条规则用到了哪些变量”或者要做“规则依赖分析”又或者要在表达式执行前做静态检查。闭上眼一想闭包里全是捕获的变量和函数对象你根本看不到里面有什么。你的fn是一个黑盒无法遍历无法打印无法静态分析。如果只是“执行”需求闭包方案完胜如果还有“检查”“可视化”“修改”需求就需要至少保留一份轻量AST。另一个比较实际的折中是解析器同时产出AST和闭包闭包用来快速执行AST用来做调试和静态分析。代价是多写一遍双轨结构好在AST本身不需要跑虚函数了。还有一个C17特有的坑std::function有类型擦除成本还会触发堆分配。树深了之后每一步闭包都过一次std::function的间接调用性能比虚函数也没好太多。想要极致性能可以换std::move_only_functionC23或者自己封一层void*函数指针不过大部分业务场景都用不上先用std::function把逻辑跑通再说。5. 变体三状态机形式的指令解释器第四个变体不那么像“表达式解释器”但它非常经典以至于我一直认为它该被算作解释器模式的一个正宗变体**把一段规则/脚本“编译”成有序的指令数组然后用一个有状态循环去执行。**说白了就是把表达式树变成“一段程序”把解释器变成“一台虚拟机”。5.1 把规则转成指令序列假设我们有一段业务脚本规则文本长这样if order.amount 1000 then apply coupon SUMMER else log normal order done我简化成一个很小的指令集指令操作数语义PushVar变量索引从上下文中取变量压栈PushConst浮点数压入常量Add/Sub/Mul/Div无弹出两个操作数运算后压回JumpIfFalse跳转目标栈顶条件为假则跳到目标ApplyCoupon券码索引给当前订单打券LogMsg消息索引写一条日志Halt无结束执行这样一条规则就会被翻译成一个紧凑的指令数组。执行器只是一个带switch的循环enum class OpCode : uint8_t { PushVar, PushConst, Add, Mul, Div, JumpIfFalse, ApplyCoupon, LogMsg, Halt }; struct Instruction { OpCode code; uint32_t operand; // 变量索引/常量索引/跳转目标 }; struct RuleContext { double orderAmount; std::vectorstd::string coupons; }; double runRule(const std::vectorInstruction code, const RuleContext ctx) { const auto stack ...; // 省略栈的细节 size_t pc 0; bool skip false; while (true) { const auto inst code[pc]; switch (inst.code) { case OpCode::PushConst: // 从常量池取值压栈 break; case OpCode::JumpIfFalse: // 检查条件决定pc跳转 break; case OpCode::ApplyCoupon: // 给ctx.coupons里加券 break; case OpCode::Halt: return result; default: break; } pc; } }5.2 这次变体解决的是什么这是“先转译后执行”最简单的形态。好处非常明显指令数组是数据。可以被缓存、持久化、做版本对比甚至直接序列化成二进制存进配置文件。AST做不到这么自然的持久化。执行模型极其可预测。一条指令一个switch分支顺序访问数组元素缓存局部性好现代CPU能把这段循环优化得非常好。比递归虚函数快是肯定的。便于做安全限制。因为程序结构是无环的指令序列你可以很容易限制指令数量、限制循环深度不会出现“规则里写个死循环把服务拖垮”的事故。这个变体特别适合规则引擎、策略引擎、流程编排这类“语言不大、执行频繁、需要可观测性”的场景。你完全不需要设计一个通用虚拟机只要能把自己的规则翻译成十几种指令就够用了。5.3 代价写转译器它的代价是多一层层次。你必须先写一个“规则语言-指令数组”的翻译器这玩意本身是一个解释器/编译器。语法简单的还好规则一复杂转译器内部也要写一个小的递归下降。很多团队做着做着就会产生疑问“我是不是在写编译器”——对你就是在写不过是缩小到够用的程度。所以这个变体的适用面要把握准**当规则数量大、执行频率高、需要对规则做持久化和静态分析时值得为这层抽象付出转译成本。**如果只是偶尔解析一条计算表达式闭包方案性价比更高。6. 变体四先转译后求值的IR式改进指令数组已经够用了为什么还要专门聊“IR式改进”因为指令数组只是线性执行而表达式往往本身是树形的。把树形的表达式编译成线性指令需要处理好操作数栈和跳转。这正是编译原理里的“中间表示IR”技术把它引进解释器模式就是一个更高级的变体。6.1 从AST到栈式IR还是price * 0.8 fixedDiscount我可以把它编译成一条栈式指令序列PushVar price PushConst 0.8 Mul PushVar fixedDiscount Add这条IR是线性数组执行起来快但编译时要把树的递归顺序翻译成栈操作顺序。技术上不复杂——后序遍历AST遇到BinaryOp就按“左、右、运算符”的顺序往下生成指令。6.2 为什么值得做常数折叠等优化既然有了IR你就可以在编译阶段做经典优化。典型的是常数折叠如果规则里写死了3 4编译时直接算成7执行时少做一次加法。还有代数化简乘0直接变成常数0加0直接变成自身这类规则可以用一个很小的“优化器”统一处理规则库能省不少运行期运算。这让解释器模式彻底走上“编译器路线”解析成AST - 简单优化 - 生成IR - 执行IR。每一步都更接近现代编译器的管道也意味着工程复杂度上升。我见过有团队为了一个本来只需std::function就能搞定的表达式硬生生搭了一套三层管道最后维护成本远超收益。分寸要拿捏好IR式改进适合那些“执行上万次、规则很少变、对性能和可观测性有要求”的系统。6.3 性能实测粗略对比我简单给个经验数字方便你快速判断该选哪条路。同一台机器上一万次执行涉及十几个节点的表达式方案耗时相对值主要开销多态AST递归虚函数1.0x基准多次虚调用、堆分配std::variantvisit0.6x ~ 0.8x栈上变体访问编译器优化好lambda闭包0.9x ~ 1.2xstd::function类型擦除指令数组虚拟机0.5x ~ 0.7x顺序访问switch无需递归这个对比很粗糙因为它依赖树的深度、节点的实际布局、编译器的优化能力。但方向是稳定的**想快就往数据化、线性化靠想要灵活和直观就往结构化、函数化靠。**没有黄金方案只有“当下这个系统更适合哪一头”。7. 工程化避坑所有权、错误报告与测试策略上面四种变体各有各的收益但真正决定它们能不能健康落地的往往是一些C工程化细节。我在最后一个章节把最容易踩的钉子集中说说。7.1 所有权先定位谁持有再决定指针类型多态AST里根节点必须明确是“唯一持有”所以根用unique_ptr子节点也用unique_ptr。想共享再改用shared_ptr。规则引擎里比较常见的模式是解析完之后把整个AST/闭包/指令数组丢到一个缓存池里所有查询只读。这种情况最好用unique_ptr建池查询时拿裸指针或引用不要到处shared_ptr。每个节点都shared_ptr先不谈引用环光是拷贝控制开销就够你喝一壶。如果用了“闭包”变体闭包内部捕获对象时更要小心循环引用。规则A闭包里捕获了规则B的闭包B又捕获了A那你从业务里一删缓存两个闭包永远释放不掉。临时想用shared_ptr缓解反而埋了更深的隐患。负责任的做法是闭包之间尽量不要互相捕获如果非要就存裸指针并约定生命周期。7.2 错误报告让解释器在报错时“说实话”解析器的报错质量是解释器模式和别的设计模式最不一样的地方。普通模式错了就抛异常解释器错了光一句“expression parse error”完全不够——你得告诉运营人员是哪个token、哪一行、哪个符号出了问题。实用的做法是让Token自带行号、列号、文本片段Parser抛异常时把这些信息带出去struct ParseError { size_t line; size_t column; std::string tokenText; std::string message; };然后在整个解析入口处包一层统一转成业务可读的文本。运行期求值报错也一样Context里找不到变量时把变量名和表达式来源一起带上别只丢一个std::runtime_error(variable not found)。这个细节直接决定规则平台好不好用。7.3 测试策略属性测试黄金测试组合解释器模式的测试有个天然好处因为“文本-结果”是纯函数你可以把大量测试用例塞进一张表。我会维护一张“黄金测试表”每一行是规则文本, 输入Context, 期望结果。几百行下来解析器和执行器基本行为能被焊死。这种表驱动测试非常好写也好读QA同事也能自己加规则用例。除了黄金测试建议补一层模糊/属性测试随机生成一些简单表达式对比解释器的结果和一个参照物比如直接手工用C表达式算一遍。这能抓出运算符优先级和除零处理的各种边角问题。我在维护规则引擎的时候好几个隐蔽bug都是这类测试抓出来的尤其是/为负数、0 * 无穷大这类边界组合。7.4 变体选择速查变体核心载体优势劣势最适合多态AST抽象基类虚函数直观、易扩展、易调试内存管理麻烦、虚调用开销高类型动态扩展、需要频繁调试ASTstd::variant树variantvisit值语义、类型安全、性能好闭集、递归写法绕表达式类型固定的计算器/过滤器lambda闭包std::function代码量小、组合灵活、支持惰性执行黑盒不可检视、std::function开销业务内嵌规则、事件触发环境指令数组VM结构体数组循环可序列化、缓存友好、便于做安全限制需要写转译器和优化器规则引擎、流程引擎、大量高频执行这四类之间没有天然的“高一等”之分。很多时候一个系统会混用外层规则脚本用指令数组VM单个条件表达式用lambda闭包内部某段复杂计算公式用variant树。跨度大没关系只要每一层都拿捏住“语言怎么表达、中间结构是什么、执行循环在哪”这三个问题解释器模式的本质就不会丢。我个人做了几年规则引擎后最大的体会是**解释器模式的“变体”之争本质不是技术选型之争而是“你在平衡哪两个痛苦”是想让代码容易被读还是想让代码跑得快是想让结构能被检查还是想让结构能塞进数据层。**没有标准答案但有标准的思考路径。把这套思考路径留在脑子里比记住任何一种具体写法都值钱。

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

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

免费获取报价 →
↑