C++解释器模式四种变体:从继承到std::variant与表达式模板
大概两年前我在写一个内部公式计算模块的时候碰到了一个典型的C选择难题需求本身不复杂就是把用户输入的购买公式解析成一棵树然后求值。翻出经典设计模式教程找到解释器模式照着写了多态版本的Expression基类、Number节点、Add和Sub等子类跑通之后反而越想越别扭——每个运算符一个类加一种运算就要加一个新类想打印这棵树又得动一遍所有子类表达式一多堆分配和虚函数调用带来的开销也让人心里没底。后来我把这套理解推倒重来才发现解释器模式在C里其实不是一个“模式类”而是一个“模式家族”。这篇文章把我实际见过的四种解释器模式变体整理出来教科书继承版、std::variant版、lambda组合子版、编译期模板版并且写清楚各自背后的代价和适用边界给正在做解析、规则引擎、表达式求值的同学一个可以直接参考的路线图。1. 先看经典形态教科书解释器在C里的完整骨架与别扭之处1.1 教科书实现长什么样解释器模式的核心思想很简单把“语言”里的每个语法规则映射成一种对象然后让这些对象互相嵌套组合成抽象语法树。对于算术表达式这种最经典的教学案例C的多态写法通常长下面这样#include memory #include stdexcept struct Expr { virtual ~Expr() default; virtual double eval() const 0; }; struct Number : Expr { double value; explicit Number(double v) : value(v) {} double eval() const override { return value; } }; struct Binary : Expr { char op; std::unique_ptrExpr lhs, rhs; Binary(char o, std::unique_ptrExpr l, std::unique_ptrExpr r) : op(o), lhs(std::move(l)), rhs(std::move(r)) {} double eval() const override { double l lhs-eval(); double r rhs-eval(); switch (op) { case : return l r; case -: return l - r; case *: return l * r; case /: if (r 0.0) throw std::runtime_error(division by zero); return l / r; default: throw std::runtime_error(unknown operator); } } };这个结构看起来很标准抽象语法树由Number和Binary两种节点组成Binary通过unique_ptrExpr持有左右子树求值时递归向下。如果只是处理“1 2 * 3”这种简单公式这套代码能用而且逻辑非常直观。1.2 类爆炸与继承的心智负担一旦语法规则变多继承路线的第一个问题就浮出来了每种语法规则都要有一个类。加减乘除虽然可以合并到Binary里靠op区分但一元负号呢括号可以靠解析阶段处理掉那函数调用sin(x)呢条件表达式a b ? x : y呢如果是规则引擎里那种“AND、OR、NOT、比较、取属性”的语法类数量会迅速膨胀到十几个。每加一个语法节点就要写一个类的构造、求值、打印、类型检查这种重复劳动很容易让人对“面向对象”产生怀疑。第二个躲不开的麻烦是如果你想让这棵树支持不止一种操作经典的补救方案是引入Visitor模式但Visitor在C里同样需要大量样板代码——基类要加accept虚函数每个子类都要实现acceptVisitor接口里要为每种节点写一个visit重载。新增一种操作等于新增一个Visitor类新增一个节点等于修改所有Visitor。用继承组织语法树本质上把“语法种类的扩展”和“操作种类的扩展”压在了同一个正交轴上这跟C的值语义和自由函数审美的冲突越来越大。1.3 隐藏的运行时代价还有一个经常被忽略的问题性能。一个多态表达式树里每个节点都是独立的堆分配访问eval要走虚函数表遍历树还容易触发缓存缺失。如果这个表达式模板会在游戏引擎里对每个像素执行一次或者在大数据管道里对每行数据执行几百万次这些开销就完全不能忽略了。我见过一个项目用多态表达式树做计费公式计算压测时发现求值本身占了57%的CPU时间换成扁平化实现后直接降到11%。这不是说多态写法“错”而是它默认的场景是“表达式数量少、求值频率低、语法规模稳定”一旦偏离这个预设就应该考虑换变体。2. 为什么要变体程序里的“语言”不止一种2.1 从算术到领域语言引发变体的几种场景把不同需求并排放在一起你就能看出“解释器模式”其实在解决好几件不太一样的事。第一种场景是纯算术计算器表达式规模小、语法固定、运行频率特别高。这种场景下最高优先级是求值速度语法树的“可读性”“可拓展性”反而是次要的。第二种场景是规则引擎或者小型DSL比如“当订单金额大于100并且用户等级为VIP时折扣8%”。这种语法的节点类型比较多而且后续一定会加新的规则节点但求值频率通常不高更看重的是“新增规则”的成本以及这类表达式能不能被序列化保存、打印审计。第三种场景是公式系统比如电子表格或参数化设计里的公式。公式被用户反复编辑、重算几千次而且经常要做符号层面的处理求导、化简、表达式比较。这要求表达式树不仅能求值还能被遍历、重写。第四种场景是数值计算里的模板表达式比如某类矩阵运算。语法在编译期就固定下来但运行时求值执行了无数次要求的是零抽象开销。这四类需求在经典解释器模式里都被塞进“递归树 运行时求值”这一个答案里自然会有大量错配。所谓“解释器模式变体”本质上就是在语法结构、操作类型、执行频率、扩展方向这四者之间做取舍。2.2 “解释”本身不是目的后续操作才是另一个让我想明白变体问题的关键点是解释器真正的工作不只是eval()。生产环境里你还需要打印表达式、校验类型、优化常量折叠、做变量替换。不同场景下“哪种操作最常发生”直接决定了数据结构的形态。如果eval是唯一操作lambda组合子是最省的如果打印和改写是高频操作std::variant加std::visit能让你把每种操作写成独立函数爽快得多如果表达式对象会在函数间传来传去值语义的variant比继承指针更让人安心如果运行频率高到必须省掉运行时开销那就只能让编译器在编译期把表达式“解释完”。看清这个之后再回看经典解释器模式它的真正问题不是“抽象语法树”这个概念而是C把多态继承和堆分配默认绑在了这个概念上。变体的目标不是抛弃AST而是让AST的存储方式和分派机制更贴合实际场景。3. 变体一std::variant std::visit 的可求和表达式解释器3.1 用variant表达递归语法现代C里最常见的解释器变体是放弃继承层级改用std::variant存节点用std::visit替代虚函数分派。还是以算术表达式为例先定义好节点类型再让表达式类型成为一个特殊的std::variant#include variant #include memory #include stdexcept struct Expr; struct Number { double value; }; struct Binary { char op; std::unique_ptrExpr lhs; std::unique_ptrExpr rhs; }; struct Expr : std::variantNumber, Binary { using base std::variantNumber, Binary; using base::base; };这里有个C新手很容易踩的坑std::variant不能直接包含自身因为它在实例化时要求类型完整而递归类型在定义阶段天然不完整。解决办法是用std::unique_ptrExpr做间接层因为它允许在类型不完整时声明成员。我把Expr写成继承而不是using Expr std::variantNumber, Binary是为了让前面的Binary里能先声明unique_ptrExpr等Expr完整化后这套关系才闭合。构造表达式树也顺很多std::unique_ptrExpr make_number(double v) { return std::make_uniqueExpr(Number{v}); } std::unique_ptrExpr make_binary(char op, std::unique_ptrExpr lhs, std::unique_ptrExpr rhs) { return std::make_uniqueExpr(Binary{op, std::move(lhs), std::move(rhs)}); }因为Expr本身是值类型make_uniqueExpr(Number{...})构造出的对象里直接嵌着Number节点不存在“基类指针指向派生类”的转型问题。整棵树的节点既可以是值又通过unique_ptr串起来这种混合看起来很奇特用熟了以后非常顺手。3.2 用std::visit替代虚分派求值过程用std::visit写起来非常直白。C17以后我喜欢配合overloaded惯用法把多个lambda重载打包成一个访问器template class... Ts struct overloaded : Ts... { using Ts::operator()...; }; template class... Ts overloaded(Ts...) - overloadedTs...; double eval(const Expr e) { return std::visit(overloaded{ [](const Number n) - double { return n.value; }, [](const Binary b) - double { double l eval(*b.lhs); double r eval(*b.rhs); switch (b.op) { case : return l r; case -: return l - r; case *: return l * r; case /: if (r 0.0) throw std::runtime_error(division by zero); return l / r; default: throw std::runtime_error(unknown operator); } } }, e); }对比一下继承版本虚函数是“对象自己知道怎么求值”std::visit是“外部函数知道怎么求值”控制权完全颠倒过来。这带来一个很实际的好处——新增操作不用碰节点类了。想打印树写一个to_string函数里面再挂一个std::visit节点的类型完全没变。这种“操作集合天然聚集在同一个函数里”的形态在工程上会让代码更容易审计和单测。3.3 这种变体为什么更“C”我认为这个版本比经典继承版更贴合C的现代风格原因在于它的值语义。Expr是一个实实在在的布局已知的类型可以放进vectorExpr可以按值返回也可以直接作为map的value。调试时在gdb里展开一个Expr对象你能清清楚楚看到它是Number还是Binary。而多态版本里你拿到的只是一个Expr*要看实际类型往往得跑动态类型识别。它也不是没有代价。std::visit的分派速度通常比虚函数快不少因为type index就是个整数访问器可以在编译期为每种类型实例化好不存在跳表但访问器的代码体积比虚函数大如果你有几万个节点类型还可以几十种节点类型时编译时间和二进制体积会涨得比较明显。另外overloaded这个惯用法本身算不上多复杂但如果团队里有C14老项目就没法直接用得退一步用struct手动写operator()样板代码会多回来一点。整体来说这套变体适合“语法节点适中、操作频繁增加、想要值语义和可调试性”的项目是我目前在新代码里的首选。4. 变体二lambda 组合子风格的迷你解释器4.1 把文法折叠成函数对象如果一种语言的求值操作是唯一目标那连AST对象都不需要保留——直接把每个语法元素变成一个可调用对象解释器退化成函数组合。使用std::functiondouble()表示一个“已经可以求值的表达式片段”#include functional using Expr std::functiondouble(); Expr num(double v) { return [v] { return v; }; } Expr bin(char op, Expr lhs, Expr rhs) { return [op, lhs std::move(lhs), rhs std::move(rhs)] { double l lhs(); double r rhs(); switch (op) { case : return l r; case -: return l - r; case *: return l * r; case /: return l / r; default: return 0.0; } }; }构造过程和之前的版本长得差不多区别在于每个Expr捕获的不是“数据”而是“计算”。给x求值只是expr()一次调用不再需要递归自然也没有访问器、虚函数这些东西。我甚至见过有人用这套方式写了一个短小的配置文件解析器配置文件里的条件表达式被解析成std::functionbool()业务代码只负责调用它语法树结构本身完全被丢掉了。4.2 生命周期与可观测性是两道坎lambda组合子最舒服的地方是写起来快但代价也相当隐蔽。第一道坎是生命周期lambda默认按引用捕获要非常小心如果一个表达式引用了一个即将析构的函数参数或局部变量调用时机稍晚就是悬空引用。为了省心闭包内部最好尽量用值捕获或者用shared_ptr把共享状态包一层。不过值捕获也有坑——捕获整个表达式对象时std::function自身的拷贝开销不小闭包里的递归也可能因为拷贝产生意想不到的循环引用。第二道坎是可观测性几乎为零。std::function只暴露一个调用接口你没法打印它没法统计它有多少节点没法做常量折叠优化更没法把它序列化到磁盘。我遇到的一个真实案例是某模块用lambda组合子实现了折扣规则上线后业务想“把这个规则存下来下次直接用”结果模块只能存原始字符串每次启动重新解析一遍因为函数对象内部的结构已经完全不可见了。所以我的判断是lambda组合子适合嵌入式、脚本绑定、测试辅助这类“表达式生命周期短、只求值不分析”的场景只要出现“要把表达式对象保存下来”“要打印调试”“要做规则审计”这三个需求里的任何一个就该回到显式的AST形态。5. 变体三让编译器去“解释”——编译期表达式模板5.1 表达式模板的基本思路这个变体最极端是解释器模式的“反向利用”不写一个运行时求值的解释器而是把“表达式”编码成C类型本身让编译器在编译期完成模板展开和求值。核心工具是运算符重载为表达式构建一个模板递归类型最终调用时一次性算完。template typename L, typename R struct AddExpr { L lhs; R rhs; double operator()(double x) const { return lhs(x) rhs(x); } }; template typename L, typename R AddExprL, R operator(const L l, const R r) { return {l, r}; } struct Var { double operator()(double x) const { return x; } }; struct Constant { double v; double operator()(double) const { return v; } };这样Var{} Constant{3}返回的是AddExprVar, Constant并不是立即算出一个数而是构建了一个“编译期知道结构的表达式对象”。如果再加上乘法、减法、sin等操作的重载就能写出auto f (Var{} Constant{3}) * Var{};这样的北境式表达式。求f(5)时模板层层展开等价于手写(5 3) * 5。整个过程没有运行时分派、没有堆分配、没有动态类型信息。5.2 constexpr的边界与适用条件表达式模板的方式在C17之前就已经被广泛使用了很多数值计算库的延迟求值就是这么优化的。C14以后的constexpr函数又提供了另一个角度如果一个表达式在功能上能写成普通函数可以直接声明成constexpr让它在编译期被解释掉。C20的consteval更进一步强制在编译期求值。但要冷静看待这个变体的边界。第一它要求的语法规模很小、结构固定一旦表达式里出现运行时才知道的运算符优先级、用户自定义函数名这套机制就撑不住了。第二编译期递归深度有限制模板爆炸时错误信息会非常抽象我曾在一个复杂公式里嵌套了二十几层模板编译器直接报了“递归实例化超限”排查很久才发现是某个运算符重载写成了左递归。第三表达式模板和auto推导让对象类型变得晦涩跨编译单元使用非常难受二进制体积和编译时间也可能爆炸。因此编译期解释器不是常规解释器的替代品而是针对“高频求值、语法固定、追求零开销”场景的专用变体。如果你的引擎里有一段固定公式要被调用百万次值得考虑如果用户输入什么你根本不知道就别碰它。6. 选型地图四类变体怎么用以及它们的边界在哪里6.1 一张表看四个方案把四个变体放在一起对比例子是最直观的每个方案的取舍一目了然。方案表示方式分派机制典型场景主要代价经典多态抽象基类 子类虚函数语法稳定、类型层次深、带继承的既有代码类较多、堆分配多、操作扩展麻烦std::variant值语义标签联合std::visit语法节点适中、操作频繁、要打印审计递归定义复杂、编译期模板噪声lambda组合子std::function闭包函数调用只求值、不分拆、快速原型不可自省、生命周期易错表达式模板模板递归类型编译期展开固定语法、高频计算、数值库类型晦涩、可变性差、只能静态语法这个表不需要每条都背下来关键是形成一条决策主线先问表达式树是否需要被观察和重写再问语法结构是静态还是动态最后问运行频率有多高。三个问题的答案组合起来基本能定位到某一类变体上。6.2 决策逻辑和我的选择顺序我个人的习惯是先列一个“操作清单”需求里到底有哪几种操作要对表达式执行如果只有求值我会倾向lambda组合子因为代码最少如果还有打印、类型检查、序列化我马上切到std::variant如果项目已经有一堆以基类为中心的接口不得不跟外部系统交互我只能留在多态版本里如果性能目标要求极致并且语法静态才去尝试表达式模板。一个很常见的错误是“既要又要”有人一开始用lambda组合子写完后来要加打印功能硬生生在std::function外面包一层模拟类型判断或者本来语法就三个节点非要上模板元编程最后陷入编译错误海洋。解释器模式变体的意义正在于让实现复杂度跟实际场景匹配而不是追求某个方案听起来更“现代”。7. 实践过程中踩过的坑和验证方法7.1 虚析构和类型识别继承版的两道经典坑经典多态版几乎必然遇到两个坑第一个是忘记给Expr加virtual析构函数导致delete派生类对象时只析构了基类部分子类的内部资源全部泄漏。内存泄漏不会立刻崩但积累起来很难查。第二个是类型识别很多人会用dynamic_cast判断节点实际类型一旦Expr不是多态类型没有虚函数dynamic_cast直接编译失败。结论是多态版里virtual析构函数不是可选项是强制项。7.2 variant的递归定义与visit编译错误variant版本的坑集中在类型完整性上。前面展示的继承写法依赖unique_ptrExpr支持不完整类型这个细节一旦写错编译器会报各种晦涩的“incomplete type”错误。实际上我最早写递归variant也绕了好一阵建议不要用using Expr std::variantNumber, Binary再让Binary持unique_ptrExpr——因为Expr在这个位置还不是完整类型。继承写法虽然绕但它是能同时满足“variant自身完整”和“递归成员可声明”的最小方案。std::visit的编译错误是另一个重灾区。如果访问器里的lambda没有覆盖所有variant类型编译器会输出几十上百行模板错误。我的建议是始终给lambda写显式返回类型并且把访问器拆成独立的函数对象而不是巨型overloaded{}这样至少报错时能定位到具体哪个operator()没写对。7.3 从能跑到可维护的工程包装实际项目里解析器和表达式树通常不会单独使用外面还要包一层。我常用一个Parser类负责从字符串构造AST它内部维护词法位置、运算符优先级、左递归消除这些琐碎状态。AST节点类保持纯数据而把所有操作写成独立的自由函数比如evaluate、serialize、constantFold。这样树的形态稳定操作是增量的新的操作不需要修改节点定义测试也方便——每个操作函数都可以单独构造假的AST注入测试。工程上还有一些细节值得注意如果要支持变量我需要让节点持有变量名而非变量值求值的时候传一个Context进去这个Context可以是std::unordered_mapstd::string, double。这虽然只是一层薄薄的设计但能保证表达式对象本身不依赖外部状态需要的读者可以试一下。7.4 调试与验证技巧最后分享几个验证方法。语法树构建完成后第一件事不是求值而是打印。我写过一个to_string函数把整棵树递归打印成带缩进的文本肉眼核对和预期是否一致。求值正确性则从最小用例开始先测单个数字再测单层二元运算再测多层嵌套再测除零异常和变量替换。每个用例都放到测试框架里固定下来这样后面换更高效的求值实现时回归测试能兜底。如果发现某个复杂表达式求值错误我会在std::visit的Binary分支里临时打印操作符和左右子表达式的值缩小范围之后再移除打印代码。这套方法在我替换求值后端时救了好多次——多态版和variant版可以同时实现、同时跑同一组golden数据逐条对输出基本能保证重构无感。我在实际项目里把同一套算术引擎从多态版重构成variant版之后最直观的感受是新增操作变得像“往工具箱里加一把新扳手”而不是“改动所有旧扳手的形状”。如果你也在纠结解释器模式在C里的落地方式建议别急着写代码先盘点一下手里的场景到底属于哪一类再挑对应的变体。如果只是需要快速求值lambda组合子能让你半天写完如果要长期维护、打印审计variant路线绝对值得投入而经典继承版虽然没有那么酷在既有面向对象体系里依然有一席之地——它最坏也只是不够高效绝不至于不能用。