解释器模式在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::DIV:
if (r == 0.0) {
throw std::runtime_error("division by zero");
}
return l / r;
}
throw std::runtime_error("unknown op");
}
};
这是最典型也最直观的实现。每个节点负责两件事:存储自己的数据(数字、变量名、左右子树),实现求值逻辑。递归调用子节点的eval,最后得到根节点的值。这段逻辑如果让你手写“算 1+2*3”的过程,本质上就是这么个递归过程。
2.2 所有权管理:C++版必考题
这是C++新手和老手最容易拉开差距的地方。AST是递归的树形结构,父子节点有明确的所有权关系,推荐用 std::unique_ptr 表达“父节点独占子节点”的语义。不要用裸指针,因为一旦解析过程抛异常,裸指针的树很容易泄漏。也不建议上来就 shared_ptr,因为树结构里没有共享需求,引用计数的原子操作对性能是纯浪费。
实际操作中,常见的组合是工厂函数返回 std::unique_ptr<Expr>:
cpp复制std::unique_ptr<Expr> parseExpression(Parser& parser) {
auto left = parseTerm(parser);
while (parser.peek().type == Token::PLUS ||
parser.peek().type == Token::MINUS) {
auto op = parser.consume();
auto right = parseTerm(parser);
left = std::make_unique<BinOp>(
op.type == Token::PLUS ? BinOp::Op::ADD : BinOp::Op::SUB,
std::move(left), std::move(right));
}
return left;
}
用 std::make_unique 构造节点,用 std::move 转交所有权。这样整个AST的生命周期由根节点的unique_ptr链式管理,析构自动递归释放,不会泄漏。
注意:如果节点类里有 const 成员(比如 const double value),unique_ptr可以正常管理,但移动赋值不可用。所以平时给节点类设计成员时,尽量保持可移动(move)的属性,避免不必要的拷贝。
2.3 纯教科书写法的硬伤
教科书版本虽然概念清晰,但拿到生产环境有几个硬伤。
第一,加新语法要扩展所有分支。比如想加一个一元负号,你要么修改BinOp类加个标志位,要么新建UnaryOp子类和对应的工厂逻辑。整个体系是开环的,每加一撮语法,类数量就膨胀一层。
第二,虚函数有运行时开销。C++的虚函数调用是间接跳转,分支预测失败时很伤性能。表达式树一深,一个求值过程可能要触发几十上百次虚调用。如果只是业务规则,这无所谓;但要是在游戏引擎里跑战斗公式,或在高频交易的报价引擎里算指标,这个开销就不能忽视。
第三,AST节点的错误处理困难。一旦某一步求值出问题,异常信息可能只到某个子节点,不好定位出错位置。生产级引擎一般需要携带元信息(行号、列号、参数名),教科书版本里这些信息很难优雅地加进去。
3. C++里的关键变体(实践才是重头戏)
3.1 变体一:用std::variant取代继承体系
我在重构规则引擎时用的第一个变体,就是抛弃继承,改用 std::variant。核心思路:AST的节点类型是一个封闭集合,与其让一千个子类漂浮在继承体系里,不如把这些类型直接写在一个variant里,然后用 std::visit 做遍历。
cpp复制struct Num { double value; };
struct Var { std::string name; };
struct BinOp {
enum class Op { ADD, SUB, MUL, DIV };
Op op;
std::shared_ptr<ExprNode> lhs;
std::shared_ptr<ExprNode> rhs;
};
using ExprNode = std::variant<Num, Var, BinOp>;
再定义求值函数:
cpp复制struct Evaluator {
const std::map<std::string, double>& vars;
double operator()(const Num& n) const { return n.value; }
double operator()(const Var& v) const {
auto it = vars.find(v.name);
if (it == vars.end()) throw std::runtime_error("unknown var: " + v.name);
return it->second;
}
double operator()(const BinOp& b) const {
double l = std::visit(*this, *b.lhs);
double r = std::visit(*this, *b.rhs);
switch (b.op) {
case BinOp::Op::ADD: return l + r;
case BinOp::Op::SUB: return l - r;
case BinOp::Op::MUL: return l * r;
case BinOp::Op::DIV:
if (r == 0.0) throw std::runtime_error("div by zero");
return l / r;
}
throw std::runtime_error("bad op");
}
};
double eval(const ExprNode& expr, const std::map<std::string, double>& vars) {
return std::visit(Evaluator{vars}, expr);
}
这个变体的最大优点是:状态和行为彻底分离。数据结构只管存储(Num、Var、BinOp就是纯数据),操作通过 std::visit 以重载函数形式挂上去。以后要新增一种“打印AST”操作,完全不用动数据定义,只写一个新的visitor即可。
缺点也很明显:用 std::visit 要求类型集合封闭,也就是你不能再随意添加新的节点类型。如果你做的是编译器前端,语法在演进,那么继承树可能更合适;但如果是规则引擎,语法基本固定,std::variant 是更好的选择。
实操心得:
std::variant版本的AST,用shared_ptr还是unique_ptr取决于你是否需要在多个地方同时持有节点引用。计算过程中没有共享需求时,我仍然推荐把子节点包成unique_ptr,降低引用计数开销。需要注意的是,variant自身可能包含unique_ptr,这种情况下variant的拷贝构造被禁用,但你通常只需要移动构造。
性能方面,std::visit 在多数实现里会生成一张跳转表,开销比虚函数调用还低不少。我自己做的简单性能测试,同样规模的表达式树,variant方案比虚函数方案快大约20%到40%。这个数字在不同编译器和优化级别下会变,但趋势是一致的。
3.2 变体二:用constexpr把求值提前到编译期
规则引擎一旦允许用户输入任意表达式,求值基本就是运行期的事。但如果你是库的作者,允许用户在代码里写表达式,那就有一种更狠的玩法:编译期求值。
C++11引入了constexpr,C++14放开了函数体内的多数限制,C++17加入了if constexpr和constexpr lambda,这让“用C++写C++的DSL并在编译期计算”成为可能。我们可以在编译期构建一棵表达式树,然后强制编译器在编译期就算出结果。
cpp复制template<typename L, typename R>
struct Add {
L lhs;
R rhs;
constexpr auto eval() const {
return lhs.eval() + rhs.eval();
}
};
struct Lit {
double value;
constexpr double eval() const { return value; }
};
template<typename L, typename R>
constexpr Add<L, R> operator+(L lhs, R rhs) {
return Add<L, R>{lhs, rhs};
}
int main() {
constexpr auto expr = Lit{3.0} + Lit{4.0} + Lit{5.0};
static_assert(expr.eval() == 12.0);
}
这个变体的本质是:利用模板类型携带表达式结构,利用constexpr函数让编译器在编译期完成计算。传统解释器的“运行时解释AST”,在这里变成“编译期实例化模板+编译期折叠常量”。
使用场景是谁?游戏里的伤害公式如果固定不变,可以把它写成constexpr表达式,零运行时成本;配置校验代码里,如果你想确保某个配置项永远满足某些约束,编译期断言直接把它锁死。
但这套玩意的坑也不少:
- 无法处理运行期变量(变量必须作为模板参数或常量传入);
- 编译期递归深度有限,表达式太长会爆掉编译器的模板递归限制(默认900层,C++11之前是900,C++26有改进);
- 调试难度大,模板错误信息又臭又长,非战斗人员建议远离。
我自己的经验是:constexpr解释器适合有限范围内的常量表达式计算,别碰复杂控制流。你要是用它写带逻辑分支的规则引擎,得不偿失。
3.3 变体三:CRTP静态多态
虚函数的替代品中,除了variant,还有CRTP(Curiously Recurring Template Pattern,奇异递归模板模式)。这个模式的核心是:基类通过模板参数“知道”派生类的类型,从而在基类中静态调用派生类的方法,实现静态多态。
cpp复制template<typename Derived>
struct ExprBase {
double eval(const Context& ctx) const {
return static_cast<const Derived*>(this)->evalImpl(ctx);
}
};
struct LiteralExpr : ExprBase<LiteralExpr> {
double value;
double evalImpl(const Context&) const { return value; }
};
struct AddExpr : ExprBase<AddExpr> {
const ExprBase* lhs;
const ExprBase* rhs;
double evalImpl(const Context& ctx) const {
return lhs->eval(ctx) + rhs->eval(ctx);
}
};
CRTP的关键优势是完全消除虚函数调用。编译器可以在编译期解析到最终的evalImpl,内联展开,甚至把整个表达式树的计算全部展开成inline代码。在性能敏感的代码里(数据库表达式、实时控制算法),这比virtual快得多。
代价呢?首先是类型擦除问题。CRTP要求你在使用处知道具体类型,否则还是要用variant或继承兜底。上面代码里lhs和rhs用了ExprBase*,而ExprBase本身是模板类型,这就有问题了——模板基类不是一个统一的非模板类型,所以你不能直接用ExprBase*指向任意派生类型。你必须引入一个非模板基类,或者用variant再次包装:
cpp复制class IExpr {
public:
virtual ~IExpr() = default;
virtual double eval(const Context&) const = 0;
};
你看,绕了一圈又回到了虚函数。所以CRTP的实用方式通常是:在编译期知道表达式类型的情况下做静态整形,比如配合模板解析器,整个解析过程都在模板代码里完成,没有任何虚函数。这种风格在嵌入式领域和库代码里比较常见。
3.4 变体四:表达式模板(Expression Templates)
如果前面还是“求值解释器”,表达式模板就是一类更激进的变体。它不是用来解释语法树,而是把运算符重载变成一个“延迟构建”的机器。最经典的例子是Eigen线性代数库:你写 a = b + c * d,这个表达式不是一个一个地计算,而是重载operator+和operator*返回一个模板表达式对象,直到赋值时才一次性遍历合并,避免产生临时矩阵。
cpp复制template<typename L, typename R>
struct VecAdd {
const L& lhs;
const R& rhs;
auto operator[](std::size_t i) const {
return lhs[i] + rhs[i];
}
};
template<typename L, typename R>
auto operator+(const L& a, const R& b) {
return VecAdd<L, R>{a, b};
}
从解释器模式视角看,表达式模板就是“表达式不立刻执行,而是变成AST数据流”。这个变体在C++的数值计算领域极其重要,它让你既能写出自然的数学表达式,又能获得接近手写循环的性能。你不需要在运行时解释任何东西,因为表达式结构已经被编码成类型了。
这个变体跟前面CRTP的差异在于:CRTP侧重“静态多态分派”,表达式模板侧重“延迟计算与融合”。两者经常组合使用,在游戏引擎、物理引擎、线性代数库中遍地开花。
4. 实战:一个规则系统的完整变体架构
4.1 需求再定义
为了让上面的抽象落到地上,我们设计一个具体的场景:一个促销规则引擎。规则长这样:
text复制if (user.level == "gold" and total > 1000) then discount = 0.8 else discount = 1.0
规则可以存数据库,服务启动时加载并解析成AST,请求进来时用AST进行实时求值。
4.2 为什么最终选择了variant作为主干
如果沿用教科书继承+虚函数,这个场景会变成什么样子?每种条件(等于、大于、与、或、非)、每种动作(赋值、返回折扣)都是一个类,算下来十几个类。然后我还要处理它们的序列化和反序列化、JSON/XML映射、错误报告……这棵类树会让我越写越累。
换成variant版本之后,我把AST直接建模成一种简单的数据语言:
cpp复制struct ConditionEquals {
std::string field;
Value expected; // Value是另一个variant,存数字或字符串
};
struct ConditionAnd {
std::vector<Condition> conditions;
};
using Condition = std::variant<ConditionEquals, ConditionAnd, ConditionOr, ConditionNot>;
struct RuleAction {
std::string outputField;
Value value;
};
struct Rule {
Condition condition;
RuleAction action;
};
每次加载规则,用一个小递归parser把JSON(或自定义DSL)填进这些struct。求值时用visitor遍历condition,计算true/false,最后执行action。扩展新条件类型时,只需要加一个struct,往variant里塞一个类型,再给visitor加一个重载——不过要注意,variant加了新类型之后,所有std::visit的调用点都必须补这个重载,否则编译不过。
4.3 AST构建与求值分离
这就是变体方案最大的结构性好处:AST构建(解析)和AST求值完全解耦。
解析阶段把JSON/DSL变成纯数据,不涉及任何业务逻辑:
cpp复制Rule parseRule(const json& j) {
Condition cond = parseCondition(j["condition"]);
RuleAction act = parseAction(j["action"]);
return Rule{std::move(cond), std::move(act)};
}
求值阶段拿整个AST跑visitor,不关心AST是怎么来的:
cpp复制struct Evaluator {
const UserContext& user;
bool operator()(const ConditionEquals& cond) const {
return getField(user, cond.field) == cond.expected;
}
bool operator()(const ConditionAnd& cond) const {
for (const auto& c : cond.conditions) {
if (!std::visit(*this, c)) return false;
}
return true;
}
};
如果以后要支持“规则导出为可视化流程图”,你完全可以再写一个GraphvizVisitor,顺着AST遍历一遍,把节点关系输出成dot格式。所有这些都是add-on,不需要改动AST数据结构本身,符合开闭原则。
注意:variant + visitor有一个思维转换的成本。你要把“每个节点是什么类型”从虚表里的运行时信息,变成编译期的类型分发。一旦类型集合是封闭的,这个方案非常干净;一旦类型可能无限扩展,还是回到继承树更舒服。
4.4 性能调优的三个法宝
规则引擎跑到线上,性能问题迟早会暴露。针对variant版AST,我踩过的调优点有这几个:
一是避免recursive visit的重复分派。如果AST很深,每次std::visit都会做一次类型分发。C++标准库的visit实现通常很快,但高频调用下仍要注意。一个常见技巧是在解析阶段就把AST“降级”为扁平的执行指令序列(类似字节码),每条指令是一个简单的枚举类型,求值阶段直接从指令数组里顺序执行,不再递归visit。
cpp复制enum class Bytecode { CONST, LOAD_VAR, EQ, AND, OR, NOT, ... };
std::vector<Bytecode> compile(const Condition& cond);
编译一次,执行多次,这是解释器模式在工程里最重要的一种优化思路——实际上,从AST解释到字节码虚拟机,就是很多脚本引擎的演进路线。
二是小对象优化(SBO)。AST节点如果是离散的小对象,很多节点只是几个字节,用heap分配会带来大量碎片。可以用std::pmr::memory_resource配合std::pmr::vector/std::pmr::variant,把整个AST放进一个shared memory pool里,一次规则解析分配一块连续内存,请求处理时零分配。
三是消除shared_ptr。如果AST是临时构建、求值后立刻销毁,用unique_ptr和move,不要用shared_ptr。引用计数在多线程环境下的原子操作开销不小,对于变化频繁的规则引擎可能是瓶颈。
5. 常见问题与排查经验
5.1 坑一:递归深度爆炸
规则引擎一旦允许用户写复杂的嵌套表达式,递归解析、递归求值都会面临栈溢出问题。我遇到过线上一个规则的AST深度到了几万层,直接压垮了调用线程的栈。
解决方案:一是限制递归深度,在解析器里记录当前深度,超过阈值直接报错;二是对递归求值改为显式栈的迭代遍历。显式栈的方式是用std::vector<std::function<double()>>或std::stack模拟递归调用的帧栈,把“处理器栈”换成“堆内存”,彻底规避栈溢出。这个改造通常很有效,但要仔细设计访问顺序,不然容易把语义写错。
5.2 坑二:错误定位不清晰
线上发现某条规则不生效,怎么找问题?教科书版本异常信息大概是“unknown variable: x”,但x是哪条规则的哪个子表达式呢?不知道。生产级引擎,必须在AST节点里带上元数据,至少是source位置(行号、列号、字段路径)。
我的做法是在variant的每个分支struct里都保存sourceLoc:
cpp复制struct ConditionEquals {
std::string field;
Value expected;
int line;
int col;
};
解析阶段填充,求值阶段捕获异常时,把sourceLoc拼进错误信息。排查效率提升一个数量级。
5.3 坑三:值类型设计缺陷
很多新手在AST里直接用std::string存所有值,结果写比较逻辑时到处转类型,烦不胜烦。我的建议是尽早定义一个类型安全的Value variant:
cpp复制using Value = std::variant<std::nullptr_t, bool, int64_t, double, std::string>;
然后为它实现比较、算术、逻辑运算的辅助函数。注意整数和浮点数的隐式转换是重灾区——Value{1} == Value{1.0}在严格variant规则下是false,但用户预期是true。这个坑不做特殊处理,必踩。
5.4 坑四:递归删除的栈溢出
这其实是坑一的一个马甲:如果AST树很深,而节点用unique_ptr链式管理,析构的时候递归删除子节点,照样可能栈溢出。我见过某些长表达式规则,在析构阶段触发段错误,查了半天才发现是递归析构导致的。
解决方法是写一个迭代式的析构辅助函数,在析构函数里手动用栈遍历所有子节点并释放。或者干脆用std::pmr::memory_resource做一个arena,AST销毁时整块释放,完全跳过逐节点递归析构。
6. 解释器模式的选型判断和边界
6.1 什么情况下用解释器模式,什么情况下别用
拆开这个问题的本质,你应当先问自己三个问题:
- 语言/规则的复杂度到什么程度?
- 这个DSL是否会被频繁修改或扩展?
- 性能要求是分钟级、毫秒级,还是纳秒级?
如果你只是偶尔做一次字符串匹配、简单表达式计算,不要上解释器模式,用现成库(exprtk、muparser、jsonnet)更快更稳。如果规则语法极简(就是几个枚举项组合),普通状态机或规则表胜过一切“模式”。只有当规则语法真正复杂,并且需要频繁变更、组合、嵌套时,解释器模式才物有所值。
6.2 各变体选型速查
| 你遇到的情况 | 推荐变体 | 理由 |
|---|---|---|
| 语法可能持续演进,类型开放 | 经典继承+虚函数 | 扩展性好,加新子类即可,但注意所有权管理 |
| 语法稳定,追求性能 | std::variant+visitor | 类型封闭时最干净,性能优于虚函数 |
| 纯常量表达式,无运行期变量 | constexpr模板 | 零运行开销,编译期锁定错误 |
| 没有虚函数,嵌入式场景 | CRTP | 静态多态,完全内联,代码体积小 |
| 数值计算、线性代数 | 表达式模板 | 延迟计算,避免临时对象,矩阵运算提速明显 |
6.3 “解析器”是另一个大坑
最后提醒一句:解释器模式讲的是“表达式树和求值”,但你真正落地的第一个障碍往往是如何把字符串解析成AST。写parser不简单,但也不是非要从零开始。你可以用手写递归下降(本书不展开),也可以用ANTLR、flex/bison、ParseKit(C++版)、PEGTL这些库。不要把“解析”和“解释”混为一谈——我在很多项目里看到,解释器模式明明很稳,结果挂在parser的优先级处理、括号匹配这些基础问题上。
7. 后续扩展思路
7.1 从解释器到字节码虚拟机
AST解释最大的痛点是每次求值都要遍历树的节点,性能不够极致。把AST编译成字节码,然后用一个switch循环解释执行字节码,是工业界最成熟的一条路。Lua、Python、Ruby都是这么干的。我们的规则引擎如果走到了这一步,就可以把“解析规则”和“执行规则”彻底分开:启动时编译,请求时只跑字节码。这个升级路线是目前推荐的。
7.2 引入JIT热路径
字节码还是解释执行,还能更快吗?可以。对热规则做实时编译(生成机器码)就是JIT。C++里可以直接生成机器码并动态写进可执行内存,然后函数指针调用。这条路对绝大多数业务系统来说过了,工程复杂度太高,但如果你在做数据库、游戏脚本、科学计算库,了解这个方向没有坏处。
7.3 类型系统升级
如果规则引擎需要处理复杂类型(对象、数组、日期时间),最终你可能会做一个带类型检查的完整语言。此时解释器模式扮演的角色开始变小,而语义分析、作用域解析、类型推导这些编译原理知识会越来越多。解释器模式反而成为整个系统里最不起眼的一环——但它的骨架仍然在那里。
我自己当初从继承树一路走到variant版规则引擎,最大的体会是:设计模式是地图,不是路标。GoF给的解释器模式是一个起点,你要根据C++本身的特性,选择一个适合当前问题的变体。继承、variant、constexpr、CRTP,没有哪一个通吃所有场景,但理解了它们各自的取舍,你就能在下次遇到“解析一个表达式、执行它”的需求时,第一时间选出最合适的方案,而不是拿教科书硬套。
