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 ==
