1. 经典多态实现:理解解释器模式的"原配"底子
最近在给一个小的规则计算模块重写求值器,需求说简单也简单:把price > 100 ? price * 0.8 : price这类表达式存下来,运行的时候喂进上下文变量求值。按照《设计模式》里的解释器模式经典套路,我应该抽象一个 Expr 基类,再派生一堆终结符和非终结符子类,用一个虚函数 eval 走完递归。我还真这么写了,但写完总觉得哪里不对劲:类多了、样板代码多了,想加一个把表达式打印出来的功能,居然要把每个节点类都过一遍。
于是我把解释器模式在 C++ 里的几种变体认真试了一遍:基于std::variant的代数数据类型方案、基于表达式模板和 CRTP 的编译期方案、基于std::function组合子的函数式方案,再加上最初的经典多态方案,同一个求值器写了四遍。这篇文章就是这轮实践的完整记录,包括每种方案的完整代码、踩坑细节和选型建议,适合正在做小型规则引擎、配置 DSL 求值器,或者想理解现代 C++ 如何改造传统设计模式的读者。
1.1 四个角色的落位与代码骨架
解释器模式的核心意图是给一门语言定义语法表示,再提供一个解释器去处理这个语法对应的句子。在 C++ 里最自然的落地方式,就是用类层次结构表达抽象语法树(AST):一个抽象基类代表"表达式",终结符表达式代表叶子节点(数字、变量),非终结符表达式代表内部节点(加减乘除这类组合),外加一个上下文对象用来承载求值需要的外部状态,也就是变量的值。
代码长这样:
class Context { public: double lookup(const std::string& name) const { auto it = vars.find(name); if (it == vars.end()) { throw std::runtime_error("undefined variable: " + name); } return it->second; } void assign(const std::string& name, double value) { vars[name] = value; } private: std::map<std::string, double> vars; }; class Expr { public: virtual ~Expr() = default; virtual double eval(const Context& ctx) const = 0; }; class Literal final : public Expr { public: explicit Literal(double v) : value(v) {} double eval(const Context&) const override { return value; } private: double value; }; class Variable final : public Expr { public: explicit Variable(std::string n) : name(std::move(n)) {} double eval(const Context& ctx) const override { return ctx.lookup(name); } private: std::string name; }; class Add final : public Expr { public: Add(std::unique_ptr<Expr> l, std::unique_ptr<Expr> r) : lhs(std::move(l)), rhs(std::move(r)) {} double eval(const Context& ctx) const override { return lhs->eval(ctx) + rhs->eval(ctx); } private: std::unique_ptr<Expr> lhs, rhs; }; class Multiply final : public Expr { public: Multiply(std::unique_ptr<Expr> l, std::unique_ptr<Expr> r) : lhs(std::move(l)), rhs(std::move(r)) {} double eval(const Context& ctx) const override { return lhs->eval(ctx) * rhs->eval(ctx); } private: std::unique_ptr<Expr> lhs, rhs; };使用的时候把它们组合成一棵树,比如new Add(new Literal(3), new Variable("price"))。eval 的递归结构跟 AST 的递归结构一一对应,编译器视角下就是对树的递归下降求值,逻辑本身没毛病。我以前写过不少这种代码,跑起来很稳,但这次重新审视,发现工程上值得商榷的地方不少。
1.2 经典写法的三个痛点
第一个痛点是新操作意味着改所有类。想给表达式加一个print()或者optimize(),得在 Expr 基类里加纯虚函数,然后所有子类一个一个补实现。这就是所谓的"表达式问题":类层次结构天然方便新增节点类型,却对新增操作不友好。节点一旦超过五六个,每次加操作的改动量就很扎心。我见过一个老项目里表达式节点有十几个,加一个"类型检查"功能,代码评审 diff 能有上千行,全是机械式重复。
第二个痛点是样板代码密度高。每个节点几乎都是同样的骨架:构造函数、成员变量、eval 重写。我甚至一度想写个宏来生成这些类,后来想想还是算了。这类代码审查起来很无聊,还容易在拷贝粘贴时把rhs拼成lhs之类的手误带进去。
第三个痛点是运行期开销和对象生命周期管理绕不开。虚函数调用加上每个节点单独堆分配,在热路径上并不便宜。而且整棵树用 unique_ptr 串起来,拷贝就是深拷贝,得自己写 clone;表达式临时对象多了,所有权转移写起来也很啰嗦。如果你只是教学演示,或者团队明确要求"严格按照 GoF 来",这当然没问题。但到了工程里,我总觉得应该有更好的姿势。
2. std::variant 变体:把 AST 换成类型安全的可辨识联合
2.1 为什么要换掉继承体系
思路其实是换了个底层表示:AST 节点的类型集合在语法设计完成那一刻就是"已知且有穷"的(Literal、Variable、Add、Multiply……)。既然类型集合有穷,那就没必要用"开放"的继承体系,直接用std::variant把这几个类型包成一个联合体。这在函数式语言里叫代数数据类型(ADT),在 C++ 里就是 variant。
这个转变的意义在于:经典继承体系是"开放"的,任何人在任何地方都能派生一个新子类,代价是编译器永远不知道你到底有多少种节点;variant 是"封闭"的,编译器精确知道所有候选类型,于是能做穷尽性检查。对解释器这种"语法设计完就定了"的场景,封闭性反而是优势。
代码的重构方式很直接:把每个节点从"类"降级成"结构体",只保留数据成员,行为全部挪到外面的求值函数里。
2.2 递归 variant 的定义与 std::visit 求值
递归结构需要绕一下:variant 不能直接包含自己,所以节点之间通过指针连接。我的写法是这样的:
struct Node; using NodePtr = std::shared_ptr<Node>; struct Literal { double value; }; struct Variable { std::string name; }; struct Add { NodePtr lhs; NodePtr rhs; }; struct Multiply { NodePtr lhs; NodePtr rhs; }; struct Node : std::variant<Literal, Variable, Add, Multiply> { using variant::variant; };求值用std::visit,配合 C++17 的 overloaded 技巧写起来非常舒服:
template<typename... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template<typename... Ts> overloaded(Ts...) -> overloaded<Ts...>; double eval(const Node& node, const Context& ctx) { return std::visit(overloaded{ [](const Literal& lit) -> double { return lit.value; }, [&](const Variable& var) -> double { return ctx.lookup(var.name); }, [&](const Add& add) -> double { return eval(*add.lhs, ctx) + eval(*add.rhs, ctx); }, [&](const Multiply& mul) -> double { return eval(*mul.lhs, ctx) * eval(*mul.rhs, ctx); }, }, node); }注意几个细节:
overloaded是个人人都会抄的小工具,本质是"可变参数基类 + using 展开 operator()"。不想抄的话,也可以定义一个带状态的 visitor 结构体,重载多个 operator()。- 每个 lambda 的返回类型要能统一成
double。我习惯显式写-> double,避免某些编译器推导出不一致的类型。 - structured binding 可以直接取
Add里的成员,代码还能再简洁一点,比如const auto& [l, r] = add;。
2.3 穷尽性检查与性能特征
这个方案最打动我的地方是穷尽性检查:以后语法里加了一个新的节点类型(比如取模Modulo),Node的 variant 就多一个候选类型,只要哪个std::visit没处理它,编译器直接报错。多态方案里新增一个操作漏改某个子类是运行期才炸;variant 方案里漏改一个分支是编译期就炸,这个体验差距太关键了。
性能上也不差。std::visit在现代编译器上基本生成一张跳转表或者 switch,比虚函数调用还要稳一点;而且节点本身是值语义,放 vector 里连续存放的时候缓存友好得多。代价是sizeof(Node)等于最大的那个候选类型的大小,如果某个节点里放了std::string,所有节点都会按这个大小占空间;另外递归指针用shared_ptr会比unique_ptr多一些开销。想要极致性能,可以用 arena 分配器配合裸指针,这个后面再展开。
当然,代价也真实存在。节点的"身份"不再自带行为,求值逻辑全在 visit 的 lambda 里,一个大型解释器会把 visit 写得非常长;想让 visitor 带状态(比如记录求值步数)就得把状态 capture 进去,或者把整个 visit 拆成一个有状态的 visitor 类。类型集合有穷既是优势也是限制——语法之外的扩展点变少了,想加一个"语法之外"的节点,得动 Node 本体。
3. 编译期变体:表达式模板与 CRTP 把解释工作提前
3.1 用类型编码表达式结构
前两种方案都是在运行期"看树求值"。如果表达式结构在编译期就完全确定,或者语法是给编译器自己用的,那还有一个完全不同的方向:让类型本身编码表达式结构,解释变成编译期模板展开。这就是表达式模板(Expression Templates)的思路,C++ 生态里很多线性代数库(比如 Eigen 风格)都在用。
核心是 CRTP(Curiously Recurring Template Pattern)写一个表达式基类,派生类通过模板参数把自己的类型暴露给基类:
template<typename Derived> class ExprBase { public: double eval(const Context& ctx) const { return static_cast<const Derived*>(this)->eval(ctx); } }; class Literal : public ExprBase<Literal> { public: explicit Literal(double v) : value(v) {} double eval(const Context&) const { return value; } private: double value; }; class Variable : public ExprBase<Variable> { public: explicit Variable(std::string n) : name(std::move(n)) {} double eval(const Context& ctx) const { return ctx.lookup(name); } private: std::string name; }; template<typename L, typename R> class AddExpr : public ExprBase<AddExpr<L, R>> { public: AddExpr(const L& l, const R& r) : lhs(l), rhs(r) {} double eval(const Context& ctx) const { return lhs.eval(ctx) + rhs.eval(ctx); } private: const L& lhs; const R& rhs; };再配一个重载的operator+,让表达式能"看起来像普通算术"一样组合:
template<typename L, typename R> AddExpr<L, R> operator+(const ExprBase<L>& lhs, const ExprBase<R>& rhs) { return AddExpr<L, R>( static_cast<const L&>(lhs), static_cast<const R&>(rhs)); }用起来长这样:
Literal a(3.0); Variable x("price"); auto expr = a + x; // 类型是 AddExpr<Literal, Variable> double result = expr.eval(ctx); // 等价于 3.0 + ctx.lookup("price")这里没有虚函数、没有堆分配、没有 base class 指针。AddExpr<Literal, Variable>这个类型本身就是 AST,eval 的递归在编译期按类型展开,运行期就是线性内联的计算。如果字面量和上下文都是编译期常量,完全可以把 eval 标成 constexpr,让求值发生在编译期。
3.2 适用边界与引用生命周期问题
这个方案适合什么场景?表达式集合固定、类型已知、性能敏感、不追求动态扩展的场景。比如一个专用的量纲换算模板,或者一个在编译期检查的配置 DSL。我在实际里见过有人用它写编译期的单位换算和表达式常量折叠,效果都很好。
但它的坑也不少:
- 临时对象生命周期:
AddExpr<L, R>存的是const L&,如果a + x的结果传给一个函数,而函数内部持有这个引用超过实参的生存期,引用就悬空了。工程上常用std::reference_wrapper(std::cref)或者干脆按值存储小对象来规避。 - 类型爆炸:一个深一点的表达式,类型名会长到让人头皮发麻,编译报错时满屏的模板嵌套。我记得第一次写深一点的链式表达式,GCC 报错信息有三十多行,核心信息被一堆
AddExpr<AddExpr<...>>淹没了。 - 调试困难:断点打在模板展开的 eval 里,调用栈能把你绕晕,单步跟踪时一步跳好几个函数。
- 扩展操作要重写整坨模板逻辑:想加一个符号打印,不能像 variant 那样加一个 lambda,得重新写一套遍历。
所以说白了,这个变体更像"为固定语法生成高性能求值器",而不是留给用户动态组装 AST 的。
4. 函数式变体:std::function 组合子与解析器天然配合
4.1 闭包即求值器
如果说表达式模板是"过度设计但性能拉满",那std::function组合子就是"极简但够用"的另一个极端。
思路是:表达式节点不需要保有一个可检查的结构,求值器就是一个函数。把"节点"直接类型擦除成std::function<double(const Context&)>,每个节点用一个闭包表示:
using EvalFunc = std::function<double(const Context&)>; EvalFunc lit(double value) { return [value](const Context&) { return value; }; } EvalFunc var(std::string name) { return [name = std::move(name)](const Context& ctx) { return ctx.lookup(name); }; } EvalFunc add(EvalFunc lhs, EvalFunc rhs) { return [lhs = std::move(lhs), rhs = std::move(rhs)](const Context& ctx) { return lhs(ctx) + rhs(ctx); }; } EvalFunc mul(EvalFunc lhs, EvalFunc rhs) { return [lhs = std::move(lhs), rhs = std::move(rhs)](const Context& ctx) { return lhs(ctx) * rhs(ctx); }; }注意std::movecapture:闭包通过移动把子表达式"吞进"自己内部,组合过程就是闭包嵌套闭包,不存在生命周期问题。这个风格在函数式编程里叫组合子,每个 factory 函数本身也是一个组合子——输入两个求值器,输出一个新的求值器。
4.2 与递归下降解析器的衔接
这个方案和手写递归下降解析器是绝配。解析到 token 的时候直接构建对应闭包,解析完闭包就是最终的求值器:
// 省略词法部分,逻辑示意 EvalFunc parseAdditive() { auto lhs = parseMultiplicative(); while (peek() == "+" || peek() == "-") { char op = advance(); auto rhs = parseMultiplicative(); if (op == '+') { lhs = add(std::move(lhs), std::move(rhs)); } else { // 减法类似,这里略过 } } return lhs; }解析器不再需要构建一棵"可遍历的树",因为闭包已经把树的结构编码进了调用链。少掉一层 AST 抽象,代码量肉眼可见地降下来。
4.3 黑盒代价与适用准则
好处非常直观:样板代码几乎为零,新增一个二元运算符就是一个 factory 函数几行代码;组合子天然支持高阶变换,比如给某个闭包包一层缓存、包一层日志计数;求值逻辑和解析逻辑完全解耦。
代价也诚实讲:
- 不可自省:闭包内部是什么结构,外面完全看不见。想打印 AST、做静态检查、做死代码消除,这个方案都帮不上忙。
- 类型擦除有成本:每次调用
std::function都相当于一次间接调用,加上闭包自身的堆分配,热路径上比 variant 慢。 - 调试黑盒:你拿不到一棵"可以断点检查的树",只能看闭包行为,出错时定位问题更依赖日志和二分法。
所以我的使用准则是:表达式是一次性构建、多次求值、不关心内部结构的场景,直接上组合子。典型例子是配置文件里的表达式求值、规则引擎的单条规则编译。
5. 同题四种答法:求值器的横向对比与扩展推演
5.1 关键维度对比表
光有代码展示不够,我做了个对照表,把四套方案的关键差异列出来:
| 维度 | 经典多态 | std::variant | 表达式模板(CRTP) | std::function组合子 |
|---|---|---|---|---|
| AST 结构 | 继承层次、堆对象 | 值语义联合体+指针递归 | 类型本身即结构 | 闭包黑盒 |
| 派发机制 | 虚函数(运行期) | std::visit(编译期选择) | 模板展开(编译期) | std::function(运行期) |
| 新增节点类型 | 新增子类即可 | 改 variant 和所有 visit | 新增模板类型 | 新增 factory 函数 |
| 新增操作 | 改所有节点类 | 新增 visit lambda | 重写模板遍历 | 包一层变换函数 |
| 编译期检查 | 弱 | 强(穷尽性) | 强 | 弱 |
| 运行期开销 | vtable+堆分配 | 跳表+少量堆分配 | 无虚函数无分配 | 类型擦除间接调用 |
| 代码量 | 多 | 中 | 中 | 最少 |
| 调试友好度 | 高 | 中 | 低 | 中 |
这张表我是按自己实际写代码的感受填的,不是理论推演。比如"代码量"一栏,组合子方案我一个文件三十行搞定,经典多态方案光 Expr 派生类就写了七八十行。
5.2 三个"将来需求"推演选型
光有静态对比还不够,我用三个具体的"将来需求"来推演选型,这才是真正会影响你决策的地方。
需求一:加打印 AST 的操作。经典多态要往基类加 virtual print,再改所有子类;variant 加一个 visit lambda,完事;表达式模板要新写一套模板遍历或者改造 eval;std::function 组合子没法打——闭包不透明。所以在"需要看到树"的场景,variant 是赢家。
需求二:新增取模运算符%。经典多态新增一个 Mod 子类,但其他所有操作(比如打印、优化)都要跟着加分支;variant 扩展 Node 类型,编译器马上把没处理的 visit 全部指出来,这是最舒服的;表达式模板加一个 ModExpr 模板类型;组合子加一个 mod factory。这个需求下 variant 的"编译器帮你改代码"体验无可替代。
需求三:表达式对象被频繁拷贝、反复求值。经典多态和 CRTP 方案如果按值管理需要小心深拷贝;variant 树因为节点是指针,默认拷贝是浅拷贝、共享子树,一般够用;组合子 move 捕获,拷贝 std::function 本身也是引用计数,性能可以接受。真正要极致性能,CRTP 方案胜出,但前提是表达式结构编译期已知。
说到底没有银弹。我在项目里的默认决策顺序是:需要 AST 可视化或静态分析 → variant;表达式是配置里动态解析出来的 → 组合子或 variant;表达式是编译期固定的热点计算 → 表达式模板;团队只认 OO 风格且改动不频繁 → 经典多态也完全够用。
6. 实践中的坑:从递归类型到模板调试
6.1 递归 variant 的类型完整性问题
我最初图省事,直接在 variant 里放std::unique_ptr<Node>,结果在 Node 还没定义完整的时候就触碰了析构,编不过。换成shared_ptr倒是省心,因为 shared_ptr 可以在类型不完整时安全析构;但如果你在意性能,坚持用 unique_ptr,就得注意把 Node 的析构函数放到 Node 类型完整定义之后(比如在 .cpp 里 default 掉),或者用 pimpl 思路隔开。这个坑藏得很深,报错信息也不会直接告诉你是"不完整类型导致 unique_ptr 析构失败",我第一次遇到时排查了挺久。
6.2 版本兼容与模板调试
上面那套overloaded推导指引是 C++17 的 CTAD。如果项目还停留在 C++14,就得老老实实写一个带多个 operator() 的结构体。低版本编译器上还有一个土办法:不用 visit,改成switch (node.index())+std::get,可读性差一些,但兼容性最好。
写表达式模板时最容易怀疑人生的是:某个类型不满足 eval 的接口,编译器甩给你一屏嵌套模板错误。我的建议是给关键模板加static_assert把语义错误提前暴露,别等到 eval 内部炸;有 C++20 条件的话用 concept 约束AddExpr<L, R>里的 L、R 必须继承自 ExprBase。这样报错信息至少是"人话"。
6.3 Context 生命周期与错误处理
eval 接收const Context&,意味着 lookup 里面不能修改状态。但有些 DSL 的求值带副作用(比如计数、写日志、依赖当前时间),这时候要么把 Context 设计成非常量引用,要么引入一个 mutable 的求值会话对象。我在做规则引擎时就因为 Context 里放了个计数器,const 反复出问题,最后统一改成eval(Context&)才消停。
错误处理也别只知道抛异常。未定义变量的处理,抛std::runtime_error最简单,但如果你在一个批处理引擎里对几百条规则求值,一条规则炸了不应该把整个任务搞挂。用std::optional<double>或者 C++23 的std::expected<double, Error>把错误变成值,调用方自己决定怎么处理,工程上稳健得多。这个对四种变体都适用。
6.4 性能瓶颈的终极解法
另外有个通用经验:同一棵 AST 如果会被反复求值几千次且性能吃紧,别在解释器层面抠了,直接编译成一段扁平的字节码(操作数栈 + 指令数组),用一个 switch 循环执行,往往比任何 AST 解释器都快。解释器模式变体再花,也只是优雅地遍历,真要压榨性能还是得换执行模型。
我自己现在的默认选择是:小规则引擎里用组合子快速搞定;一旦规则开始需要可视化、需要 IDE 提示、需要别人维护,果断迁到 variant 方案;那些编译期就能定死的表达式,留一个 CRTP 模板专门服务性能热点。经典多态也不是不能碰,只是每次加操作时的叹气声,会提醒你当初为什么放弃它。这套组合拳用下来,解释器模式在 C++ 里确实比《设计模式》书上那版老套路好用多了——关键不是背模式,而是知道同一个意图在现代 C++ 里有哪几条路可以走,各自要付出什么代价。