解释器模式在C++里是个很有意思的“少数派”。大部分开发者听到它的第一反应是“我知道,GoF的二十三个模式之一”,然后就没有然后了——因为日常业务代码里,你真的很少会去写一个自己的语言或规则引擎。但偏偏,凡是用到这个模式的场景,基本都是硬骨头:配置规则、脚本引擎、表达式计算、模板渲染,甚至编译器前端。
我最早认真研究解释器模式,是接手一个遗留的规则引擎。那个项目用继承树写了上千个节点类,跑起来性能一般,但维护成本极高,加一种语法要动很多地方。后来我重构的时候,没有走教科书路线,而是用了好几种“变体”,彻底把架构盘活了。所以这篇不是给你念GoF的定义,而是想聊聊,C++里解释器模式到底有哪些设计变体、每个变体适合什么场景、踩坑点在哪。
1. 解释器模式的整体设计与思路拆解
1.1 解释器模式到底在解决什么问题
先抛开术语,聊聊本质。解释器模式解决的是一个很特殊的痛点:你有一个频繁变化、或者无法穷举的语法规则组合,并且需要在这个规则之上执行某种计算。比如“价格=基础价(折扣, 会员等级)”这种促销规则,你不能每次改规则都发版,所以实现层必须把“表达式的解析与计算”抽象成数据,运行时动态执行。
这个模式的核心思想,就是把“一句话(表达式)”拆成抽象的语法树:一个表达式由更小的子表达式组成,而“计算”就是在这棵树上递归求值。教科书里把这个树上的每个节点叫做“表达式(Expression)”,叶子是字面量,分支是运算、条件、函数调用。解释器模式本身不关心你怎么把字符串转成语法树(那是解析器的工作),它只负责树的表达和求值。
C++里聊这个模式,难点从来不是“怎么写抽象类”,而是:
- 节点对象怎么管理生命周期(谁拥有谁,谁负责delete);
- 多态分发怎么做得高效(虚函数性能,或者不用虚函数的替代方案);
- 怎么保证可扩展性(加一种运算,需要改几个文件);
- 怎么处理异常和安全(非法输入、递归深度过大)。
1.2 为什么C++里的解释器模式值得单独写一篇
网上讲解释器模式的文章,十有八九是Java写四则运算器,把代码贴一遍就完了。C++不一样的地方在于,它的资源管理模型、值语义、模板元编程能力,让这个模式的实现维度比Java丰富得多。
举个最简单的例子:Java里你new一个节点对象,GC帮你收拾残局,所以递归AST可以随便造。C++里你就得认真考虑用裸指针还是unique_ptr,要不要做引用计数,节点是放堆上还是栈上,甚至用不用inline storage来避免内存碎片。这些细节直接决定了你的引擎是能跑还是跑不动。
再比如,Java里多态就是虚函数,C++里你还有constexpr(编译期求值)、std::variant(类型安全联合体)、CRTP(静态多态)这些工具。同样是“表达式求值”,你可以做成运行期动态的,也可以做成编译期常量计算,还可以用variant把继承体系整个干掉——这些都是我在实际项目里用过的变体,每一种都有它的存在理由。
1.3 变体的总体分类
我们可以把C++里的解释器模式变体大致分成四条路线:
| 变体方向 | 核心机制 | 典型场景 |
|---|---|---|
| 经典继承 + 虚函数 | 多态递归调用 | 规则引擎、AST解释器骨架 |
| std::variant + visit | 静态类型表驱动的遍历,避免继承体系 | 表达式树重建、高性能计算 |
| constexpr 求值 | 编译期模板递归 | 常量表达式计算、编译期校验 |
| CRTP 静态多态 | 模板基类注入行为,零虚函数开销 | 小型嵌入式脚本、性能敏感的DSL |
这四条路线不是互斥的,一个成熟的引擎往往同时用两三种。下面我按从教科书到工程实践的路径,逐个拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 教科书版本:经典继承树与虚函数
2.1 标准AST节点设计
先看一个最常见的实现结构。假设我们要做一个四则运算+变量的解释器,定义AST节点:
cpp复制class Expr {
public:
virtual ~Expr() = default;
virtual double eval(const std::map<std::string, double>& vars) const = 0;
};
class Num : public Expr {
double value;
public:
explicit Num(double v) : value(v) {}
double eval(const std::map<std::string, double>&) const override {
return value;
}
};
class Var : public Expr {
std::string name;
public:
explicit Var(std::string n) : name(std::move(n)) {}
double eval(const std::map<std::string, double>& vars) const override {
auto it = vars.find(name);
if (it == vars.end()) {
throw std::runtime_error("unknown variable: " + name);
}
return it->second;
}
};
class BinOp : public Expr {
public:
enum class Op { ADD, SUB, MUL, DIV };
private:
Op op;
std::unique_ptr<Expr> lhs, rhs;
public:
BinOp(Op o, std::unique_ptr<Expr> l, std::unique_ptr<Expr> r)
: op(o), lhs(std::move(l)), rhs(std::move(r)) {}
double eval(const std::map<std::string, double>& vars) const override {
double l = lhs->eval(vars);
double r = rhs->eval(vars);
switch (op) {
case Op::ADD: return l + r;
case Op::SUB: return l - r;
case Op::MUL: return l * r;
case Op:
