大概两年前,我在写一个内部公式计算模块的时候,碰到了一个典型的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_ptr<Expr> lhs, rhs; Binary(char o, std::unique_ptr<Expr> l, std::unique_ptr<Expr> 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_ptr<Expr>持有左右子树,求值时递归向下。如果只是处理“1 + 2 * 3”这种简单公式,这套代码能用,而且逻辑非常直观。
1.2 类爆炸与继承的心智负担
一旦语法规则变多,继承路线的第一个问题就浮出来了:每种语法规则都要有一个类。加减乘除虽然可以合并到Binary里靠op区分,但一元负号呢?括号可以靠解析阶段处理掉,那函数调用sin(x)呢?条件表达式a > b ? x : y呢?如果是规则引擎里那种“AND、OR、NOT、比较、取属性”的语法,类数量会迅速膨胀到十几个。每加一个语法节点,就要写一个类的构造、求值、打印、类型检查,这种重复劳动很容易让人对“面向对象”产生怀疑。
第二个躲不开的麻烦是:如果你想让这棵树支持不止一种操作,经典的补救方案是引入Visitor模式,但Visitor在C++里同样需要大量样板代码——基类要加accept虚函数,每个子类都要实现accept,Visitor接口里要为每种节点写一个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_ptr<Expr> lhs; std::unique_ptr<Expr> rhs; }; struct Expr : std::variant<Number, Binary> { using base = std::variant<Number, Binary>; using base::base; };这里有个C++新手很容易踩的坑:std::variant不能直接包含自身,因为它在实例化时要求类型完整,而递归类型在定义阶段天然不完整。解决办法是用std::unique_ptr<Expr>做间接层,因为它允许在类型不完整时声明成员。我把Expr写成继承而不是using Expr = std::variant<Number, Binary>,是为了让前面的Binary里能先声明unique_ptr<Expr>,等Expr完整化后这套关系才闭合。
构造表达式树也顺很多:
std::unique_ptr<Expr> make_number(double v) { return std::make_unique<Expr>(Number{v}); } std::unique_ptr<Expr> make_binary(char op, std::unique_ptr<Expr> lhs, std::unique_ptr<Expr> rhs) { return std::make_unique<Expr>(Binary{op, std::move(lhs), std::move(rhs)}); }因为Expr本身是值类型,make_unique<Expr>(Number{...})构造出的对象里直接嵌着Number节点,不存在“基类指针指向派生类”的转型问题。整棵树的节点既可以是值,又通过unique_ptr串起来,这种混合看起来很奇特,用熟了以后非常顺手。
3.2 用std::visit替代虚分派
求值过程用std::visit写起来非常直白。C++17以后我喜欢配合overloaded惯用法,把多个lambda重载打包成一个访问器:
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>; 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是一个实实在在的布局已知的类型,可以放进vector<Expr>,可以按值返回,也可以直接作为map的value。调试时在gdb里展开一个Expr对象,你能清清楚楚看到它是Number还是Binary。而多态版本里你拿到的只是一个Expr*,要看实际类型往往得跑动态类型识别。
它也不是没有代价。std::visit的分派速度通常比虚函数快不少,因为type index就是个整数,访问器可以在编译期为每种类型实例化好,不存在跳表;但访问器的代码体积比虚函数大,如果你有几万个节点类型还可以,几十种节点类型时编译时间和二进制体积会涨得比较明显。另外,overloaded这个惯用法本身算不上多复杂,但如果团队里有C++14老项目,就没法直接用,得退一步用struct手动写operator(),样板代码会多回来一点。整体来说,这套变体适合“语法节点适中、操作频繁增加、想要值语义和可调试性”的项目,是我目前在新代码里的首选。
4. 变体二:lambda 组合子风格的迷你解释器
4.1 把文法折叠成函数对象
如果一种语言的求值操作是唯一目标,那连AST对象都不需要保留——直接把每个语法元素变成一个可调用对象,解释器退化成函数组合。使用std::function<double()>表示一个“已经可以求值的表达式片段”:
#include <functional> using Expr = std::function<double()>; 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::function<bool()>,业务代码只负责调用它,语法树结构本身完全被丢掉了。
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> AddExpr<L, 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}返回的是AddExpr<Var, Constant>,并不是立即算出一个数,而是构建了一个“编译期知道结构的表达式对象”。如果再加上乘法、减法、sin等操作的重载,就能写出auto f = (Var{} + Constant{3}) * Var{};这样的北境式表达式。求f(5)时,模板层层展开,等价于手写(5 + 3) * 5。整个过程没有运行时分派、没有堆分配、没有动态类型信息。
5.2 constexpr的边界与适用条件
表达式模板的方式在C++17之前就已经被广泛使用了,很多数值计算库的延迟求值就是这么优化的。C++14以后的constexpr函数又提供了另一个角度:如果一个表达式在功能上能写成普通函数,可以直接声明成constexpr,让它在编译期被解释掉。C++20的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_ptr<Expr>支持不完整类型,这个细节一旦写错,编译器会报各种晦涩的“incomplete type”错误。实际上我最早写递归variant也绕了好一阵,建议不要用using Expr = std::variant<Number, Binary>再让Binary持unique_ptr<Expr>——因为Expr在这个位置还不是完整类型。继承写法虽然绕,但它是能同时满足“variant自身完整”和“递归成员可声明”的最小方案。
std::visit的编译错误是另一个重灾区。如果访问器里的lambda没有覆盖所有variant类型,编译器会输出几十上百行模板错误。我的建议是始终给lambda写显式返回类型,并且把访问器拆成独立的函数对象而不是巨型overloaded{},这样至少报错时能定位到具体哪个operator()没写对。
7.3 从能跑到可维护的工程包装
实际项目里,解析器和表达式树通常不会单独使用,外面还要包一层。我常用一个Parser类负责从字符串构造AST,它内部维护词法位置、运算符优先级、左递归消除这些琐碎状态。AST节点类保持纯数据,而把所有操作写成独立的自由函数,比如evaluate、serialize、constantFold。这样树的形态稳定,操作是增量的,新的操作不需要修改节点定义,测试也方便——每个操作函数都可以单独构造假的AST注入测试。
工程上还有一些细节值得注意:如果要支持变量,我需要让节点持有变量名而非变量值,求值的时候传一个Context进去,这个Context可以是std::unordered_map<std::string, double>。这虽然只是一层薄薄的设计,但能保证表达式对象本身不依赖外部状态,需要的读者可以试一下。
7.4 调试与验证技巧
最后分享几个验证方法。语法树构建完成后,第一件事不是求值,而是打印。我写过一个to_string函数,把整棵树递归打印成带缩进的文本,肉眼核对和预期是否一致。求值正确性则从最小用例开始:先测单个数字,再测单层二元运算,再测多层嵌套,再测除零异常和变量替换。每个用例都放到测试框架里固定下来,这样后面换更高效的求值实现时,回归测试能兜底。
如果发现某个复杂表达式求值错误,我会在std::visit的Binary分支里临时打印操作符和左右子表达式的值,缩小范围之后再移除打印代码。这套方法在我替换求值后端时救了好多次——多态版和variant版可以同时实现、同时跑同一组golden数据,逐条对输出,基本能保证重构无感。
我在实际项目里把同一套算术引擎从多态版重构成variant版之后,最直观的感受是:新增操作变得像“往工具箱里加一把新扳手”,而不是“改动所有旧扳手的形状”。如果你也在纠结解释器模式在C++里的落地方式,建议别急着写代码,先盘点一下手里的场景到底属于哪一类,再挑对应的变体。如果只是需要快速求值,lambda组合子能让你半天写完;如果要长期维护、打印审计,variant路线绝对值得投入;而经典继承版虽然没有那么酷,在既有面向对象体系里依然有一席之地——它最坏也只是不够高效,绝不至于不能用。