1. 经典解释器模式的核心结构与“类爆炸”困境
1.1 教科书上的标准形态到底长什么样
要说解释器模式,得先回到GoF那本经典书。它的核心思想很朴素:给定一门语言,定义它的文法表示,再解释这个文法。用C++的经典写法就是:为语法树里的每个节点定义一个类,每个类实现一个interpret(Context&)方法,客户端把输入解析成一棵抽象语法树(AST),然后对着根节点调用interpret,整棵树会按着“终结符表达式”和“非终结符表达式”的规则逐层往下算。
举个例子,我们要解析并计算 price * quantity + discount 这样一个简单表达式,按经典解释器模式需要设计这些类:
AbstractExpression:虚基类,定义interpret接口VariableExpression:终结符表达式,对应变量名ConstantExpression:终结符表达式,对应常量数字AddExpression、MultiplyExpression:非终结符表达式,对应二元运算
每个类里都要实现interpret,代码其实不多,但项目一旦变大就麻烦了。
1.2 痛点一:每加一个语法规则就要加一个类
我早年在做一个内部报表工具时,用过这种纯正的解释器模式。最初只有加减乘除,四个运算类写得很快。后来需求方要求支持括号、一元负号、比较运算、逻辑与或非,后来又加了函数调用if(a, b, c)和字符串拼接。每一次加语法,我都要新建一个类,还要改AbstractExpression的接口,再改Context。到后面,十几个类挤在一起,每个类的代码都只有几十行,但整体维护成本却高得离谱。
这个问题的本质是:解释器模式把“语法规则”直接映射成了“类结构”,而类结构在C++里是一种静态的东西。你没法在运行期动态新增一个类,也没法在配置文件里声明一个新语法节点。所有语法都是编译期定死的。
1.3 痛点二:语法解析和语义执行被强行绑在了一起
经典写法里,一个表达式类既要负责描述“语法结构长什么样”,又要负责在interpret里完成“这个结构怎么计算”。这两个职责一旦耦合,就会让代码变得很难复用。
举个例子,同一个AST,我今天想用它做表达式求值,明天想用它做类型检查,后天想把它翻译成SQL。按经典写法,每一件事都要往每个类里加一个方法,比如evaluate、checkType、toSql,还是逃不过改所有类的问题。接口变了,全都要跟着动。后来我读到《设计模式》里讲到可以用Visitor模式来缓解这种情况,但那又是另一套类结构的复杂度。
踩过的坑多了之后,我开始尝试各种“变体”做法,不是推翻解释器模式,而是把它在不同的场景下改造成更适合实战的形态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 变体一:递归下降解析器直接内联语义动作
2.1 核心思路:解析的过程就是计算的过程
第一个变体是“去掉AST”。如果一门语言够简单,你根本不需要先构建一棵树再单独遍历,完全可以在递归下降解析的过程中,一边读token一边把结果算出来。这就是“解析即执行”的思路。
C++里实现递归下降并不难。每一个语法规则对应一个解析函数,函数解析完当前规则后,直接返回对应的结果。如果遇到子表达式,就调用子函数去解析,拿到返回值后用当前运算符做运算。
这样做的直接好处是:没有AST,就没有AST的类爆炸问题。你不必为每个节点定义一个类,语法规则就是一组函数,函数在C++里更便宜、更灵活。
2.2 代码实测:用lambda封装语义动作的迷你求值器
我写过一个支持变量和加减乘除的表达式的递归下降解析器,支撑了一次内部评分工具的前端规则配置。核心结构是这样的:
cpp复制#include <functional>
#include <string>
#include <unordered_map>
#include <cctype>
#include <stdexcept>
using Vars = std::unordered_map<std::string, double>;
using EvalFn = std::function<double(const Vars&)>;
class RecursiveDescentEvaluator {
public:
explicit RecursiveDescentEvaluator(std::string expr) : m_expr(std::move(expr)), m_pos(0) {}
EvalFn parse() {
auto fn = parseExpression();
skipWhitespace();
if (m_pos != m_expr.size()) {
throw std::runtime_error("Unexpected trailing characters at position " + std::to_string(m_pos));
}
return fn;
}
private:
std::string m_expr;
size_t m_pos = 0;
void skipWhitespace() {
while (m_pos < m_expr.size() && std::isspace(static_cast<unsigned char>(m_expr[m_pos]))) {
++m_pos;
}
}
EvalFn parseExpression() {
auto left = parseTerm();
skipWhitespace();
while (m_pos < m_expr.size() && (m_expr[m_pos] == '+' || m_expr[m_pos] == '-')) {
char op = m_expr[m_pos++];
auto right = parseTerm();
auto leftCopy = std::move(left);
if (op == '+') {
left = [leftCopy, right](const Vars& v) { return leftCopy(v) + right(v); };
} else {
left = [leftCopy, right](const Vars& v) { return leftCopy(v) - right(v); };
}
}
return left;
}
EvalFn parseTerm() {
auto left = parseFactor();
skipWhitespace();
while (m_pos < m_expr.size() && (m_expr[m_pos] == '*' || m_expr[m_pos] == '/')) {
char op = m_expr[m_pos++];
auto right = parseFactor();
auto leftCopy = std::move(left);
if (op == '*') {
left = [leftCopy, right](const Vars& v) { return leftCopy(v) * right(v); };
} else {
left = [leftCopy, right](const Vars& v) { return leftCopy(v) / right(v); };
}
}
return left;
}
EvalFn parseFactor() {
skipWhitespace();
if (m_pos >= m_expr.size()) {
throw std::runtime_error("Unexpected end of expression");
}
if (std::isalpha(static_cast<unsigned char>(m_expr[m_pos]))) {
std::string name;
while (m_pos < m_expr.size() && std::isalnum(static_cast<unsigned char>(m_expr[m_pos]))) {
name.push_back(m_expr[m_pos++]);
}
return [name](const Vars& v) {
auto it = v.find(name);
if (it == v.end()) throw std::runtime_error("Unknown variable: " + name);
return it->second;
};
}
if (std::isdigit(static_cast<unsigned char>(m_expr[m_pos])) || m_expr[m_pos] == '.') {
size_t end = 0;
double value = std::stod(m_expr.substr(m_pos), &end);
m_pos += end;
return [value](const Vars&) { return value; };
}
throw std::runtime_error(std::string("Unexpected character: ") + m_expr[m_pos]);
}
};
这里有个关键设计:每个解析函数返回的不是一个double,而是一个std::function。也就是说,解析器把表达式编译成了一组lambda的嵌套结构,真正执行时再传入变量表求值。这样同一个解析结果可以对不同变量表反复求值,而不用重新解析。
这种设计的思路可以类比成“把源代码编译成了闭包组合体”,而不是编译成AST再解释执行。
2.3 这个变体的适用边界与性能特征
这个变体适合的是语法规模可控、不需要频繁变更语法规则、也没有太多语义分析需求的场景。比如:
- 配置文件里的小型表达式求值(阈值判断、动态权重计算)
- 简单的条件规则解析(比如运营活动里的用户分层逻辑)
- 教程和面试题里的计算器实现
性能方面,它最大的特点是解析一次、执行多次的效率很好。lambda嵌套调用比遍历AST的虚函数调用更友好,因为std::function的调用虽然有一次间接跳转,但虚函数调用也是一次间接跳转,两者开销几乎在同一水平。而且lambda可以捕获变量,闭包结构内联程度更高,编译器有机会做更多优化。
但它的短板也很明显:没有AST,你就没法做语法层面的优化、没法在求值前做静态检查、没法在错误信息里打印“这条表达式来自哪个节点”。你要是想做复杂的功能,比如语句块、赋值、循环、函数定义,递归下降函数就会变得很庞大,这时候还是得回到“先建树再执行”的老路上。
3. 变体二:std::variant与std::visit的代数数据类型解法
3.1 为什么不建议继续用继承体系
到了C++17之后,我写解释器相关代码时,已经很少用“抽象基类+派生类”那一套了。std::variant是更贴合解释器场景的替代方案。
继承体系适合“类型是开放的,操作相对固定”的场景:你随时可以新增一个子类,但新增一个操作要改所有子类。解释器模式的经典写法恰恰反过来了——AST节点类型的集合往往是稳定的(表达式就是那几种),而针对这个集合的操作却是不断增加的(求值、类型检查、转AST、转SQL、打印调试信息)。所以用std::variant把类型集合先封死,再用std::visit为每个操作单独写逻辑,才是更合理的分工。
3.2 用std::variant定义表达式节点
我把前面那个求值器改成variant版本,节点定义会是这样:
cpp复制#include <variant>
#include <string>
#include <vector>
struct NumberExpr { double value; };
struct VariableExpr { std::string name; };
struct BinaryExpr {
char op;
std::unique_ptr<Expr> lhs;
std::unique_ptr<Expr> rhs;
};
struct CallExpr {
std::string funcName;
std::vector<Expr> args;
};
using Expr = std::variant<NumberExpr, VariableExpr, BinaryExpr, CallExpr>;
注意这里的技巧:BinaryExpr内部不能用Expr作为直接成员,因为Expr是一个variant,尺寸未知,直接持有会导致自引用和无限递归。所以必须用std::unique_ptr<Expr>来持有子节点。这在C++里是标准的做法——用指针打破递归定义,同时用unique_ptr管理生命周期。
3.3 用std::visit实现求值器
有了variant,求值操作就可以用一个独立的访问器结构体来完成:
cpp复制#include <variant>
#include <cmath>
#include <stdexcept>
#include <unordered_map>
using Vars = std::unordered_map<std::string, double>;
struct Evaluator {
const Vars& vars;
double operator()(const NumberExpr& n) const {
return n.value;
}
double operator()(const VariableExpr& v) const {
auto it = vars.find(v.name);
if (it == vars.end()) {
throw std::runtime_error("Unknown variable: " + v.name);
}
return it->second;
}
double operator()(const BinaryExpr& b) const {
double l = std::visit(*this, *b.lhs);
double r = std::visit(*this, *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("Divide by zero");
return l / r;
default:
throw std::runtime_error(std::string("Unknown operator: ") + b.op);
}
}
double operator()(const CallExpr& c) const {
if (c.funcName == "sqrt") {
if (c.args.size() != 1) throw std::runtime_error("sqrt expects 1 argument");
return std::sqrt(std::visit(*this, c.args[0]));
}
if (c.funcName == "max") {
if (c.args.empty()) throw std::runtime_error("max expects at least 1 argument");
double result = std::visit(*this, c.args[0]);
for (size_t i = 1; i < c.args.size(); ++i) {
result = std::max(result, std::visit(*this, c.args[i]));
}
return result;
}
throw std::runtime_error("Unknown function: " + c.funcName);
}
};
double evaluate(const Expr& expr, const Vars& vars) {
return std::visit(Evaluator{vars}, expr);
}
这里的优势在哪?你不需要在每个节点类里实现一个interpret虚函数,求值逻辑全部集中在这个Evaluator结构体里。以后要加一个“打印表达式树”的操作,只需要再写一个Printer结构体,用std::visit分发即可,不用改动任何节点定义。新增操作的成本从“改所有节点类”降到了“只写一个新结构体”。
3.4 新增语法节点时的扩展路径
当然,std::variant不是银弹。如果你真的需要频繁新增节点类型,variant的扩展就很麻烦——你得修改Expr的variant定义,加一个新类型,然后所有用std::visit的地方都会编译报错,提示你少处理了一个分支。
这既是约束也是保护。编译器的报错等于强行提醒你:“你加了一个新节点,所有操作都应该考虑一下怎么处理它。”这比继承体系里漏实现一个虚函数、运行期才崩掉要好得多。我在实际项目里很喜欢这种“编译器逼着你把所有分支补齐”的体验,它把很多潜在bug直接消灭在了编译阶段。
4. 变体三:字节码式表驱动规则引擎
4.1 场景痛点:表达式类型千变万化,没法硬编码
第三种变体是我在做一套风控规则引擎时摸索出来的。当时的场景是:产品经理希望不发布代码,就能在后台配置新的风控规则,比如“同一设备号10分钟内下单超过5次就拦截”“单笔金额大于阈值且IP地域命中黑名单就人工审核”。
这种需求下,表达式不再是固定的加减乘除,而是上百种条件判断的组合。如果每来一种新规则就硬编码一个解析函数,那会被需求的迭代拖死。规则引擎常见的做法是:把表达式编译成一组字节码,运行时用栈式虚拟机执行。
4.2 操作数栈与命令码表:把语法变成数据
我把表达式转换成一组指令序列,每个指令是一个操作码,可能带一个操作数。用C++定义大概长这样:
cpp复制#include <cstdint>
#include <string>
#include <vector>
#include <variant>
#include <unordered_map>
enum class OpCode : uint8_t {
PUSH_CONST,
PUSH_FIELD,
LOAD_VAR,
ADD,
SUB,
MUL,
DIV,
EQUAL,
GREATER,
LESS,
AND,
OR,
NOT,
CALL_FUNC,
POP
};
struct Instruction {
OpCode op;
std::variant<double, std::string, int> operand;
};
using Bytecode = std::vector<Instruction>;
表达式 amount > 1000 && riskLevel == "high" 会被编译成:
code复制PUSH_FIELD amount
PUSH_CONST 1000
GREATER
PUSH_FIELD riskLevel
PUSH_CONST "high"
EQUAL
AND
执行时,我会维护一个std::vector<double>或者std::vector<Value>作为操作数栈。遇到PUSH_*就把值压栈,遇到二元运算就弹出两个操作数,计算结果再压回去,最后栈顶就是表达式的值。
4.3 用代码演示运行时执行器
执行器核心循环是这样的:
cpp复制#include <iostream>
#include <stdexcept>
struct RuntimeContext {
std::unordered_map<std::string, double> numbers;
std::unordered_map<std::string, std::string> strings;
};
class StackMachine {
public:
explicit StackMachine(Bytecode code) : m_code(std::move(code)) {}
bool execute(const RuntimeContext& ctx) {
m_stack.clear();
for (const auto& instr : m_code) {
switch (instr.op) {
case OpCode::PUSH_CONST:
m_stack.push_back(std::get<double>(instr.operand));
break;
case OpCode::PUSH_FIELD: {
const auto& name = std::get<std::string>(instr.operand);
auto it = ctx.numbers.find(name);
if (it == ctx.numbers.end()) {
throw std::runtime_error("Field not found: " + name);
}
m_stack.push_back(it->second);
break;
}
case OpCode::ADD: {
auto rhs = popValue();
auto lhs = popValue();
m_stack.push_back(lhs + rhs);
break;
}
case OpCode::GREATER: {
auto rhs = popValue();
auto lhs = popValue();
m_stack.push_back(lhs > rhs ? 1.0 : 0.0);
break;
}
case OpCode::AND: {
auto rhs = popValue();
auto lhs = popValue();
m_stack.push_back((lhs != 0.0 && rhs != 0.0) ? 1.0 : 0.0);
break;
}
default:
throw std::runtime_error("Unsupported opcode");
}
}
if (m_stack.size() != 1) {
throw std::runtime_error("Stack size error");
}
return m_stack.back() != 0.0;
}
private:
Bytecode m_code;
std::vector<double> m_stack;
double popValue() {
if (m_stack.empty()) {
throw std::runtime_error("Stack underflow");
}
double v = m_stack.back();
m_stack.pop_back();
return v;
}
};
看到这里你应该能感觉到,这个结构已经很接近虚拟机的雏形了。编译器和执行器被彻底分开:编译器负责把表达式文本变成指令序列,执行器完全不关心表达式长什么样,只负责逐条执行指令。这种分层带来的好处是:
- 编译器可以单独测试,指令序列可以打印出来做调试
- 执行器可以优化:比如预分配栈空间、指令缓存、甚至用解释器生成器做JIT
- 新的运算类型只需要新增一个OpCode枚举值和执行分支,不需要改动AST类结构
这个变体与经典解释器模式最大的区别,就是把语法树换成了扁平指令序列,牺牲了一点可读性,换来了极大的灵活性和执行效率。
5. 变体四:constexpr与模板元编程的编译期解释器
5.1 为什么有人要在编译期做解释
前面三种变体都是运行期解释,但这个变体把“解释器”搬到了编译期。
C++的constexpr机制让函数可以在编译期被求值。C++11刚引入constexpr时限制很多,函数体只能有一条return语句;C++14放宽到可以写循环、变量、分支;C++20又允许constexpr函数使用std::vector、std::string等容器。也就是说,一个表达式解析器完全可以标记为constexpr,让编译器在编译阶段就把所有已知常量的表达式算出来。
这样做的一个实际应用场景是:系统里有一批根据配置生成的常量,你想在编译期就验证这些常量的计算结果是否符合预期,而不是等到运行期再去拍错。
5.2 一个constexpr表达式求值器实例
我自己写过一个很迷你的constexpr求值器,只支持整数加减乘除和括号。用C++17写出来大概是这个feel:
cpp复制#include <cstddef>
constexpr double parseNumber(const char* expr, size_t& pos) {
double result = 0.0;
while (expr[pos] >= '0' && expr[pos] <= '9') {
result = result * 10.0 + static_cast<double>(expr[pos] - '0');
++pos;
}
return result;
}
constexpr double parseTerm(const char* expr, size_t& pos);
constexpr double parseExpression(const char* expr, size_t& pos) {
double left = parseTerm(expr, pos);
while (expr[pos] == '+' || expr[pos] == '-') {
char op = expr[pos++];
double right = parseTerm(expr, pos);
left = (op == '+') ? left + right : left - right;
}
return left;
}
constexpr double parseFactor(const char* expr, size_t& pos) {
if (expr[pos] == '(') {
++pos;
double value = parseExpression(expr, pos);
if (expr[pos] != ')') throw "Missing closing parenthesis";
++pos;
return value;
}
return parseNumber(expr, pos);
}
constexpr double parseTerm(const char* expr, size_t& pos) {
double left = parseFactor(expr, pos);
while (expr[pos] == '*' || expr[pos] == '/') {
char op = expr[pos++];
double right = parseFactor(expr, pos);
left = (op == '*') ? left * right : left / right;
}
return left;
}
constexpr double evalConstExpr(const char* expr) {
size_t pos = 0;
double result = parseExpression(expr, pos);
if (expr[pos] != '\0') throw "Unexpected trailing characters";
return result;
}
static_assert(evalConstExpr("2+3*4") == 14.0, "compile-time evaluation failed");
static_assert(evalConstExpr("(2+3)*4") == 20.0, "compile-time evaluation failed");
这里有几个值得注意的细节:
parseExpression和parseTerm相互调用,形成了经典的递归下降结构。在constexpr函数里递归调用是允许的,但要注意编译期递归深度限制。- 我用
throw "..."来处理错误。在constexpr求值中throw一个异常,如果这条路径在编译期真的被触发,编译器会直接报错。这其实是一种变相的断言机制。 - 数字解析没有支持小数和负号,但在工程上够用了。你也可以用类似方法扩展。
5.3 编译期解释的代价与实战注意事项
首先要明确一点:这种constexpr解释器不是让你在正常运行期大量使用的。它的用法是“编译期已知输入,编译期计算输出”,运行期的开销是零。我实际用它做过一个场景:系统有一份JSON配置文件里的数学计算公式,我在代码里把这些公式作为字符串常量传进来,用constexpr求值器在编译期算出来,再配合static_assert验证配置里的公式没有写错。这样配置的合法性在编译期就被兜底了。
编译期解释器最大的限制是编译时间会膨胀。如果你的表达式特别长、嵌套特别深,编译器的模板实例化和constexpr递归会拖慢编译速度。我在一个大型项目里测过,一个函数里嵌入十来个constexpr长表达式之后,单次编译时间从20秒涨到了35秒左右。所以我的建议是:只对关键常量做编译期校验,不要把所有逻辑都塞进constexpr里去。
另外,编译期解释器的调试体验非常差。你不能打断点、不能看变量值,只能靠static_assert的错误信息来排查。所以代码量一旦超过一个屏幕,我建议还是老老实实退回运行期解释器,用单元测试来保证正确性。
6. 四个变体的取舍标准与我的实战踩坑记录
6.1 四类实现方案在不同场景下的选型参考
做了这么多变体,其实它们各有各的主场。我做了一张表,方便你根据实际场景定位:
| 方案 | 核心结构 | 适合场景 | 主要缺点 | 上手难度 |
|---|---|---|---|---|
| 经典解释器模式 | 抽象基类 + 继承体系 | 语法节点少、操作固定、教学演示 | 类爆炸、扩展操作困难 | 低 |
| 递归下降 + lambda | 解析函数 + std::function闭包 | 简单表达式、配置解析、一次解析多次求值 | 无AST、难以做复杂语义分析 | 中 |
| std::variant + std::visit | 代数数据类型 + 访问器 | 语法节点稳定、操作频繁新增 | 新增节点要改动所有visit分支 | 中高 |
| 字节码表驱动 | 指令序列 + 栈式虚拟机 | 规则引擎、动态配置、追求执行性能 | 编译器和执行器分离,代码量大 | 高 |
| constexpr编译期解释 | constexpr函数递归下降 | 编译期常量校验、静态断言 | 编译时间膨胀、调试困难 | 高 |
6.2 三个让我印象深刻的坑位
第一个坑:递归下降里的左递归导致无限循环。
我第一次写解析器时,一个规则是expression := expression + term。这个写法在文法书上是对的,但在递归下降里会直接死循环——parseExpression()一进来就调用自己,永不退出。后来我改成标准解法:把左递归转成循环,先解析term,再在循环里处理后续的+或-。这也是我上面代码里都采用先parseTerm再while循环的原因。
第二个坑:std::variant递归定义引起的编译错误。
前面提到,BinaryExpr里直接持有一个Expr成员会导致无限递归。我第一次尝试时编译器报了一大堆看不懂的模板错误,后来才意识到类型尺寸无法确定。换成std::unique_ptr<Expr>之后就好了。这个问题在C++里非常经典,你在设计递归variant结构时第一反应就应该是用指针。
第三个坑:栈式虚拟机的栈深度和类型安全。
字节码版本的执行器里,如果编译器生成的指令不平衡(比如少压了一个操作数,或者运算指令弹栈时栈已经空了),运行时就会出问题。这种错误很难直观定位。我后来加了一个调试模式,每次执行完一条指令就打印栈的内容和指令序号,再配合单元测试把编译器生成的指令序列作为golden文件固化下来。只要指令序列变了,测试就会挂,这样编译器和执行器的变动就都有了约束。
6.3 性能优化思路:从解释执行到预编译
最后聊聊性能。解释器模式无论哪种变体,本质上都有“解释”的开销。如果你的表达式会被执行百万次,常见的优化思路是:
- 预编译:把表达式字符串先编译成AST或字节码,而不是每次执行时重新解析
- 常量折叠:如果子表达式里全是常量,可以在编译阶段就把它算成一个常量节点,减少运行期运算
- 栈机器改寄存器机器:字节码在运行时如果频繁压栈弹栈,会有栈操作的开销。改进思路是提前给每个临时值分配虚拟寄存器,把二元运算改成“寄存器模式”,减少push/pop次数
- 模板化表达式:如果表达式类型在编译期就能确定,可以用模板参数传递表达式类型,让编译器生成特化代码
我在字节码引擎里试过“常量折叠”之后,规则判断在千万次调用级别的场景下,性能提升了大约40%。优化的核心思路很简单:把能做一次的事情只做一次,不要把重复解析的开销摊到每一次执行里。
实际上,解释器模式的各种变体,本质上都是在“表达力”“性能”“工程量”三个维度之间找平衡。经典继承体系表达力最强,但工程维护成本高;字节码方案性能最好,但实现复杂;constexpr方案最省运行期开销,但使用面窄。我的实际体会是:没有哪个变体是万能的,最重要的还是先把需求场景看清楚,再选一个最贴合的结构去做。如果心里没底,就用最简单的那版先跑起来,等到确实遇到性能瓶颈或者扩展困难,再往复杂度更高的变体迁移也不迟。
