☰
现代C++解释器模式四变体:variant、CRTP、组合子与经典多态
2026/10/15 22:44:29 网站建设 项目流程

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++ 里有哪几条路可以走,各自要付出什么代价。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询