我最近在一套规则引擎里把解释器模式用了个痛快,趁着记忆还热乎,把这次 C++ 落地过程中的设计思路、核心代码和踩过的坑一起整理出来。解释器模式在 GoF 那 23 个模式里属于存在感不算高、但一旦用对就极其顺手的一种,尤其适合处理表达式解析、小型 DSL、规则校验、数学公式计算这类场景。这个模式解决的核心问题是:如何把一段“语言”变成一棵可执行的树。C++ 里做这件事,除了常规的类设计,还额外牵扯到生命周期管理、移动语义、异常安全这些语言特性层面的取舍,比 Java 版本多了一层体力活,但也多了一层掌控感。这篇文章我会从最朴素的四则运算表达式开始,手写一个完整的解析器加解释器,再讲怎么把它往工程化方向演进,最后把我在实际调试中遇到的典型问题整理成一份避坑清单。
1. 解释器模式到底解决什么问题
1.1 一句话说透核心思想
解释器模式的核心可以概括成一句话:为某个语言定义文法表示,然后为每种文法规则提供一个对应的解释器对象。这句话有点绕,换个说法就是,你有一门语言,这门语言由若干条语法规则组成,每条规则对应一个 C++ 类,最后你用一个“解释器”把这些类组合起来,让它们能对输入的一句话进行求值。
拿生活类比一下。你拿到一句英文 "The cat eats fish",要想理解它,得先做词法分析——把句子拆成单词;再做语法分析——判断主谓宾结构;最后才能知道是谁吃了什么。解释器模式做的就是这件事,只不过这里的“语言”往往是你自己定义的小型表达式语言,比如x * 3 + (y - 1) * 2,或者一条过滤规则(age > 18 && city == "beijing")。
在 C++ 里,这种“语言”最直观的落地方式就是用对象表达语法树。你写一个抽象基类Expression,所有节点都继承它,每个节点实现一个interpret方法,节点和节点组合成树,调用根节点的interpret,整棵树就动起来了。
1.2 模式结构的四个角色
虽然解释器模式听起来玄乎,但它的结构其实非常固定,只有四个核心角色:
| 角色 | 职责 | C++ 表现形式 |
|---|---|---|
| 抽象表达式(AbstractExpression) | 定义统一的解释接口 | 抽象基类Expression |
| 终结符表达式(TerminalExpression) | 语法树中不可再拆分的叶子节点 | 数字、变量名、字符串字面量 |
| 非终结符表达式(NonterminalExpression) | 由子表达式组合而成的复合节点 | 加减乘除、逻辑与或、比较判断 |
| 上下文(Context) | 保存解释过程中需要的外部状态 | 变量表、环境配置、运行时数据 |
这个模式最巧妙的地方在于:终结符和非终结符都实现同一个接口。这样一来,叶子节点和组合节点就被统一了。外部调用者根本不需要关心自己手里拿的是数字节点还是加法节点,反正调interpret就能拿到结果。这种“统一接口 + 递归组合”的设计,其实也是组合模式在解释器里的必然应用——语法树本身就是一棵天然的组合树。
1.3 为什么 C++ 里特别值得用这套思路
Java 世界里,解释器模式很多时候被框架代码包办掉了,开发者自己手写的时候反而不多。但 C++ 这边情况不太一样,很多嵌入式脚本、游戏 DSL、配置中心的表达式引擎,都倾向于内嵌一个轻量的自研解释器,而不是引入一整套重量级脚本引擎。原因很现实:C++ 项目往往对依赖非常敏感,不想为了一个表达式求值就拖进来一个 Lua 或者 Python 解释器。
C++ 实现解释器模式,天然有三个优势。
第一,RAII 管内存,不需要手写垃圾回收。语法树上的节点是典型的树形所有权结构,父节点拥有子节点,用std::unique_ptr从根节点开始往下串,整棵树的释放就是自动的。这比 Java 里靠 GC 回收省心,也比 C 语言里手写free安全得多。
第二,移动语义让构建语法树变得廉价。解析过程中会不断产生临时节点,std::move可以零拷贝地把节点“搬”进父节点。这在性能敏感场景下很有价值。
第三,const 正确性让解释器的并发安全更可控。只要 Context 传入的是只读视图,interpret就可以设计成const成员函数,多个线程共享同一棵 AST 做只读求值,天然安全。这一点在后面的代码里会体现得很明显。
1.4 什么时候该用,什么时候千万别用
解释器模式的适用边界必须拎清楚,不然容易把一个简单项目拖进类爆炸的泥潭。
适合用的特征有三个:文法规模小(一般不超过几十条产生式)、规则相对稳定(不会每个迭代都在改语法)、解释重复发生(同样的规则要批量套在不同数据上)。比如配置中心里定义了一堆优惠计算表达式,每个订单都要算一遍,表达式本身不变,变的是输入参数。这种场景就是解释器模式的舒适区。
不适合用的信号也很明显:文法复杂、语法规则频繁变化、对解析性能要求极高。一旦触及这些红线,就该老老实实用现成的解析器生成工具,或者干脆引入一门成熟脚本语言。解释器模式的本质是用 C++ 类去模拟语法规则,每条规则一个类,几十条规则就是几十个类,如果规则真的庞杂,光维护这些类就会让你怀疑人生。
2. 动手前设计:文法、词法与抽象语法树
2.1 从四则运算的 BNF 说起
很多人写解释器模式直接上来就写代码,这其实是本末倒置。解释器模式里最难的不是写代码,而是把文法定义清楚。一句没定义清楚的话,写出来的类怎么拼都别扭。
我这次从一个最简单的四则运算表达式做起,支持数字、变量、括号、加减乘除。先写它的文法,用 BNF(巴科斯范式)表示:
expression -> term (('+' | '-') term)* term -> factor (('*' | '/') factor)* factor -> '-' factor | primary ('^' factor)? primary -> NUMBER | IDENTIFIER | '(' expression ')'文法里的*表示重复零次或多次,|表示或,->表示“由……组成”。这段文法看着枯燥,但它已经把两个关键信息锁死了:
一是优先级。expression在最上层,所以加减法优先级最低;term在中间层,乘除法优先级居中;factor在最底层,幂运算和一元负号优先级最高。这种层级关系就是后面写递归下降解析器时的骨架。
二是结合性。expression用的是循环迭代,表示加减法是左结合的,所以3 - 2 + 1应该解析成(3 - 2) + 1,结果是 2。factor里primary ('^' factor)?用的是递归,表示幂运算右结合,所以2 ^ 3 ^ 2是2 ^ (3 ^ 2),结果是 512。这俩如果搞反了,计算结果会天差地别。
提示:动手写代码之前,先在纸上把文法写出来,哪怕只是三行。很多解释器写到最后发现优先级不对,翻回来改解析代码,本质上都是当时没先写文法。
2.2 终结符与非终结符如何映射成 C++ 类
文法定义清楚后,映射关系几乎是一比一的。每个产生式对应一个类,终结符对应叶子节点,非终结符对应组合节点。
| 文法元素 | 运算符/类型 | C++ 节点类 |
|---|---|---|
| NUMBER | 数字字面量 | Literal |
| IDENTIFIER | 变量引用 | VariableRef |
| expression + term | 加减法组合 | BinaryExpr(Op::Add / Sub) |
| term + factor | 乘除法组合 | BinaryExpr(Op::Mul / Div) |
| '-' factor | 一元负号 | UnaryExpr |
| primary ^ factor | 幂运算 | BinaryExpr(Op::Pow) |
这个映射表可以直接照着敲代码。注意到没有?加减乘除其实都是“两个子表达式 + 一个二元运算符”的结构,所以我只用一个BinaryExpr类,通过枚举区分运算类型,而不是为加法写一个AddExpr、为乘法再写一个MulExpr。这是我在实际项目里比较推荐的做法——当多个产生式结构完全相同、只是运算符不同时,合并成一个参数化节点类,而不是机械地按规则一对一建类。这样能显著减少类爆炸。
真正区分终结符和非终结符的标志是:它有没有子节点。Literal只存一个double值,没有子节点;BinaryExpr持有两个unique_ptr<Expression>,必然有子节点。在 C++ 里,这个差异直接体现为成员变量的不同。
2.3 词法分析与语法分析的边界怎么切
解释器模式本身不强制要求你做词法分析,但实际写代码时,把“拆词”和“组句”分成两个阶段会让实现清晰得多。词法分析的任务是把"x*3+(y-1)"这种原始字符串拆成 token 流,每个 token 是一个独立单元,比如Identifier(x)、Star(*)、Number(3)、LParen。语法分析的任务则是把这些 token 按照文法规则拼成 AST。
我这次用两个类来切分职责:Tokenizer负责词法分析,Parser负责语法分析。Tokenizer 的代码很机械,就是遍历字符串,按字符类型归类:
std::vector<Token> tokenize(const std::string& text) { std::vector<Token> tokens; size_t i = 0; while (i < text.size()) { char ch = text[i]; if (std::isspace(static_cast<unsigned char>(ch))) { ++i; } else if (std::isdigit(static_cast<unsigned char>(ch)) || ch == '.') { size_t start = i; while (i < text.size() && (std::isdigit(static_cast<unsigned char>(text[i])) || text[i] == '.')) { ++i; } std::string numText = text.substr(start, i - start); tokens.push_back({TokenType::Number, numText, std::stod(numText)}); } else if (std::isalpha(static_cast<unsigned char>(ch)) || ch == '_') { size_t start = i; while (i < text.size() && (std::isalnum(static_cast<unsigned char>(text[i])) || text[i] == '_')) { ++i; } tokens.push_back({TokenType::Identifier, text.substr(start, i - start), 0.0}); } else { switch (ch) { case '+': tokens.push_back({TokenType::Plus, "+", 0.0}); break; case '-': tokens.push_back({TokenType::Minus, "-", 0.0}); break; case '*': tokens.push_back({TokenType::Star, "*", 0.0}); break; case '/': tokens.push_back({TokenType::Slash, "/", 0.0}); break; case '^': tokens.push_back({TokenType::Caret, "^", 0.0}); break; case '(': tokens.push_back({TokenType::LParen, "(", 0.0}); break; case ')': tokens.push_back({TokenType::RParen, ")", 0.0}); break; default: throw std::runtime_error(std::string("unexpected character: ") + ch); } ++i; } } tokens.push_back({TokenType::End, "", 0.0}); return tokens; }Token 结构体我定义成:
struct Token { TokenType type; std::string text; double value; };词法分析和语法分析的边界一旦切开,后续要加新运算符就是小工程:词法里加一个 token 类型,语法里加一条解析规则,节点类里加一个枚举值,三步走完,互不干扰。这个清晰度比起在解析时临时判断字符类型,好维护不止一个量级。
2.4 直接构建 AST 而不是边解析边求值
新手写这种解析器很容易掉进一个坑:在解析的同时直接求值。比如读到3就直接返回3.0,读到+就直接加。这样做对一次性求值没问题,但会丢掉语法树本身的价值。
解释器模式的价值恰恰在于分离解析和解释两个阶段。解析阶段把字符串变成一棵 AST,这棵树是结构化数据,可以被反复解释;解释阶段只是在这棵树上做遍历计算。一次解析、多次解释,是很多表达式引擎的经典架构。比如配置中心里存了一条规则,系统启动时解析一次,之后每个请求都复用同一棵 AST,性能收益立竿见影。
还有一个更隐蔽的好处:AST 保留之后,你可以在解释之前做很多额外操作。比如静态检查有没有引用未定义变量、给表达式做常量折叠优化、甚至把 AST 翻译成另一种语言的代码。这些操作在边解析边求值的写法里都不可能实现。
3. C++ 实现:一个可以跑的表达式解释器
3.1 抽象接口:先把基类定死
万事开头难,但开头的接口定义其实很简单。所有节点都要能解释自己,所以基类只需要一个纯虚函数:
class Expression { public: virtual ~Expression() = default; virtual double interpret(const Context& ctx) const = 0; };这里有两个细节值得较真。
第一,析构函数必须声明为虚函数。因为所有节点都要通过基类指针被unique_ptr<Expression>管理,如果析构不是虚函数,删除派生类对象时就是未定义行为,轻则内存泄漏,重则直接崩溃。
第二,interpret被声明为 const 成员函数。这样语义更准确:解释一棵表达式树不应该修改树本身。配合 const 的 Context 引用,就保证了同一棵 AST 可以被多个线程并发解释而不产生数据竞争。这也是 C++ 版本解释器比很多动态语言版本更容易写出线程安全代码的原因。
3.2 终结符节点:Literal 和 VariableRef
终结符是语法树的叶子。先看Literal:
class Literal : public Expression { public: explicit Literal(double value) : m_value(value) {} double interpret(const Context&) const override { return m_value; } private: double m_value; };这个类没什么好说的,就是一个带值的叶子,interpret直接返回值。真正有点意思的是VariableRef:
class VariableRef : public Expression { public: explicit VariableRef(std::string name) : m_name(std::move(name)) {} double interpret(const Context& ctx) const override { return ctx.getVariable(m_name); } private: std::string m_name; };VariableRef自己在interpret时并不知道变量值是多少,它需要向 Context 要。这就是 Context 这个角色的存在意义——把表达式中的符号与运行时数据绑定起来。表达式树本身是纯结构的,不依赖具体数据,数据全部从 Context 注入。把表达式和数据解耦,正是解释器模式能在规则引擎场景大杀四方的根本原因。
3.3 非终结符节点:BinaryExpr 和 UnaryExpr
接下来是核心的组合节点。BinaryExpr表示所有二元运算,通过枚举区分具体运算类型:
class BinaryExpr : public Expression { public: enum class Op { Add, Sub, Mul, Div, Pow }; BinaryExpr(Op op, std::unique_ptr<Expression> left, std::unique_ptr<Expression> right) : m_op(op), m_left(std::move(left)), m_right(std::move(right)) {} double interpret(const Context& ctx) const override { double lhs = m_left->interpret(ctx); double rhs = m_right->interpret(ctx); switch (m_op) { case Op::Add: return lhs + rhs; case Op::Sub: return lhs - rhs; case Op::Mul: return lhs * rhs; case Op::Div: if (rhs == 0.0) { throw std::runtime_error("divide by zero"); } return lhs / rhs; case Op::Pow: return std::pow(lhs, rhs); } throw std::logic_error("unhandled operator"); } private: Op m_op; std::unique_ptr<Expression> m_left; std::unique_ptr<Expression> m_right; };UnaryExpr我用来处理一元负号,结构上比二元节点少一个子节点,但套路是一样的:
class UnaryExpr : public Expression { public: enum class Op { Negate }; UnaryExpr(Op op, std::unique_ptr<Expression> operand) : m_op(op), m_operand(std::move(operand)) {} double interpret(const Context& ctx) const override { if (m_op == Op::Negate) { return -m_operand->interpret(ctx); } throw std::logic_error("unhandled unary operator"); } private: Op m_op; std::unique_ptr<Expression> m_operand; };这两个类展示了非终结符的解释模式:先递归调用子节点的interpret,拿到子结果,再用自己的运算符组合出最终结果。整个解释过程就是一个深度优先遍历,从根节点一层层往下走,叶子节点返回基础值,组合节点汇总结果。
注意:除法运算在这里做了除零检查。这种运行时错误不能指望数学库替你兜底,C++ 里浮点除零不会抛异常,只会给你一个
inf或者nan,这个错误会一路传播到业务层,极其难排查。在解释器里显式检查,把错误留在可定位的地方,是值得坚持的习惯。
3.4 Context:给变量一个“环境”
Context 是解释器和外部世界的接口。我这次的需求很简单:能设置变量、能取变量、取不到就报错,所以实现也简单:
class Context { public: void setVariable(const std::string& name, double value) { m_variables[name] = value; } double getVariable(const std::string& name) const { auto it = m_variables.find(name); if (it == m_variables.end()) { throw std::runtime_error("undefined variable: " + name); } return it->second; } private: std::unordered_map<std::string, double> m_variables; };这里有个设计决策值得说明:getVariable找不到变量时是抛异常还是返回默认值。我选择抛异常。因为一个引用未定义变量的表达式,绝大多数情况下是表达式本身写错了,用异常的刺耳声音把它暴露出来,比静默返回 0 然后计算结果出现诡异偏差要好得多。我在一个项目里吃过静默处理的亏,某条配置规则依赖一个不存在的变量,系统跑了两周才有人发现数值不对。从那以后我再也不敢用默认值掩盖这类问题。
3.5 递归下降 Parser:把字符串变成 AST
现在到了整个实现里最核心的部分:Parser。我采用递归下降法,它的思路很直白:文法里的每条产生式就是解析器里的一个函数,函数之间互相调用,形成递归。
class Parser { public: explicit Parser(std::string text) : m_tokens(tokenize(text)), m_pos(0) {} std::unique_ptr<Expression> parse() { auto ast = parseExpression(); if (peek().type != TokenType::End) { throw std::runtime_error("unexpected trailing token"); } return ast; } private: const Token& peek() const { return m_tokens[m_pos]; } bool match(TokenType type) { if (peek().type != type) return false; m_last = m_tokens[m_pos++]; return true; } std::unique_ptr<Expression> parseExpression() { auto left = parseTerm(); while (true) { if (match(TokenType::Plus)) { auto right = parseTerm(); left = std::make_unique<BinaryExpr>( BinaryExpr::Op::Add, std::move(left), std::move(right)); } else if (match(TokenType::Minus)) { auto right = parseTerm(); left = std::make_unique<BinaryExpr>( BinaryExpr::Op::Sub, std::move(left), std::move(right)); } else { break; } } return left; } std::unique_ptr<Expression> parseTerm() { auto left = parseFactor(); while (true) { if (match(TokenType::Star)) { auto right = parseFactor(); left = std::make_unique<BinaryExpr>( BinaryExpr::Op::Mul, std::move(left), std::move(right)); } else if (match(TokenType::Slash)) { auto right = parseFactor(); left = std::make_unique<BinaryExpr>( BinaryExpr::Op::Div, std::move(left), std::move(right)); } else { break; } } return left; } std::unique_ptr<Expression> parseFactor() { if (match(TokenType::Minus)) { auto operand = parseFactor(); return std::make_unique<UnaryExpr>(UnaryExpr::Op::Negate, std::move(operand)); } auto node = parsePrimary(); if (match(TokenType::Caret)) { auto right = parseFactor(); return std::make_unique<BinaryExpr>( BinaryExpr::Op::Pow, std::move(node), std::move(right)); } return node; } std::unique_ptr<Expression> parsePrimary() { if (match(TokenType::Number)) { return std::make_unique<Literal>(m_last.value); } if (match(TokenType::Identifier)) { return std::make_unique<VariableRef>(m_last.text); } if (match(TokenType::LParen)) { auto expr = parseExpression(); if (!match(TokenType::RParen)) { throw std::runtime_error("missing closing parenthesis"); } return expr; } throw std::runtime_error("unexpected token"); } std::vector<Token> m_tokens; size_t m_pos = 0; Token m_last; };这个 Parser 看起来不长,但里面塞了好几个值得反复嚼的点。
第一个点是循环和递归的取舍。注意parseExpression和parseTerm用的是while循环,而不是递归。这就是左结合的实现方式:3 - 2 + 1进来,第一个循环先解析出3 - 2作为左子树,第二个循环再看到+ 1,拼到现有树的右边。如果这里写成递归parseExpression -> parseTerm '+' parseExpression,就会把3 - 2 + 1解析成3 - (2 + 1),结果为 0,这就是经典的结合性错误。
第二个点是对称结构里的临时变量。我在make_unique之前先把parseTerm()的结果存到局部变量right。这不是多此一举。C++ 里函数参数的求值顺序是未指定的,如果直接写成make_unique<BinaryExpr>(op, std::move(left), parseTerm()),编译器完全可能先求值std::move(left)再执行parseTerm(),这时如果parseTerm()抛异常,左子树已经被移动走了,直接丢失。先算right再移动left,顺序明确、异常安全。
第三个点是parsePrimary的对称处理。把括号里的表达式解析完,expect(RParen)检查右括号是否匹配。这里没有用复杂的报错体系,一个runtime_error足够定位问题。
3.6 主流程:一次解析,多次执行
最后把它拼起来跑一遍:
int main() { Context ctx; ctx.setVariable("x", 6.0); ctx.setVariable("y", 2.0); Parser parser("x * 3 + (y - 1) * 2"); auto expr = parser.parse(); std::cout << expr->interpret(ctx) << std::endl; // 输出 20 }手算验证一下:6 * 3 + (2 - 1) * 2 = 18 + 2 = 20,结果正确。
关键的是,这棵 AST 完全可以重复使用:
ctx.setVariable("x", 10.0); ctx.setVariable("y", 4.0); std::cout << expr->interpret(ctx) << std::endl; // 输出 36表达式字符串只被解析了一次,之后每次解释只是换了一批变量值。这就是典型的“一次解析、多次执行”模式,也是解释器模式在工程上最有价值的使用姿势。
3.7 完整实现中的 C++ 特有细节
代码跑通之后,回头看几个隐藏很深的 C++ 实现细节。
所有权设计。整棵 AST 从根到叶子全部用unique_ptr串起来。父节点持有子节点的所有权,子节点绝不能被两个父节点共享。理解了这套所有权模型,Parser 里的std::move就顺理成章了:每个parse*函数返回的都是一个独占的unique_ptr,上一层节点把它移动进自己的成员变量。整棵树没有裸指针,没有shared_ptr,没有循环引用,RAII 自动管理所有生命周期。
异常安全。解析中任何一个环节出错抛异常,已经在栈上构造的一部分 AST 会随着栈展开自动销毁,不会泄漏。比如parsePrimary解析括号时发现右括号缺失,此时parseExpression已经构造了一棵子树,异常一路抛上去,子树作为局部变量被unique_ptr自动释放。这在裸指针时代是噩梦般的手动清理工作,在 RAII 时代完全不值得一提。
可扩展性。想加一个取模运算%,只需要四步:Tokenizer 里加一个Percenttoken,Parser 里在parseTerm的循环里加一个match(TokenType::Percent)分支,BinaryExpr的枚举里加一个Op::Mod,interpret的 switch 里加上对应计算。改动的代码量不超过二十行,且完全不碰其他部分。这种局部性本身就是解释器模式可维护性最好的证明。
4. 从玩具到工程:解释器模式在真实系统里的演化
4.1 案例一:配置中心的小型 DSL
四则运算只能算热身。我在实际项目里用到解释器模式,最常见的是在配置中心里存一条“表达式配置”。比如一套价格计算规则:
basePrice * (1 + categoryRate) - discount这条规则存在配置中心里,业务方可以随时修改,系统启动时解析一次,之后所有订单都能用到最新规则。如果用硬编码的方式写定价逻辑,每次调整价格策略都要发一次版本,业务方和开发都被折腾得够呛。换成表达式配置后,业务方自己改配置就能完成策略调整。
这个场景完美踩中了解释器模式的三个适用条件:文法规模小、规则相对稳定、解释重复发生。定价规则里的变量就那几个——基础价、品类系数、折扣——文法基本不变,但规则字符串本身是配置驱动的,系统每天要解释上万次。这里解释器模式真正把一个“变更频繁的规则”从“稳定的解析代码”中解耦了出来。
实现上,你只需要把Context从unordered_map<string, double>升级成更丰富的环境对象,除了数值变量之外再提供一些内置函数,比如max(a, b)、min(a, b)、floor(x)。这些内置函数可以做成一种新的函数调用节点:
class FunctionCallExpr : public Expression { public: FunctionCallExpr(std::string funcName, std::vector<std::unique_ptr<Expression>> args) : m_funcName(std::move(funcName)), m_args(std::move(args)) {} double interpret(const Context& ctx) const override { std::vector<double> values; for (const auto& arg : m_args) { values.push_back(arg->interpret(ctx)); } if (m_funcName == "min") return *std::min_element(values.begin(), values.end()); if (m_funcName == "max") return *std::max_element(values.begin(), values.end()); if (m_funcName == "floor") return std::floor(values[0]); throw std::runtime_error("unknown function: " + m_funcName); } private: std::string m_funcName; std::vector<std::unique_ptr<Expression>> m_args; };你看,新增一种节点类型,对其他所有节点类零侵入。语法树的叶子还是叶子,组合节点还是组合节点,只是组合方式多了一种。
4.2 案例二:规则引擎中的布尔表达式
如果说数值表达式让你觉得解释器模式只是“高级计算器”,那布尔表达式会让你看到它的第二种形态。实际业务里特别常见的是这种需求:读取一条类似(age > 18 && city == "beijing") || vip == true的过滤规则,然后把这条规则应用在每一条用户数据上。
要实现这个,需要三类新节点:
- 比较节点:比如
>、<、==,左右两边是表达式,结果是布尔值。 - 逻辑节点:
&&、||、!,组合多个比较结果。 - 类型扩展:数值、字符串、布尔值要能共存于同一个 Context。
树形结构完全不需要动,变的只是节点的 interpret 返回类型。如果你想让interpret同时支持数值和布尔值,可以考虑让函数返回一个Value变体类型,例如用std::variant<double, bool, std::string>。这样改造之后:
class LogicalExpr : public Expression { public: enum class Op { And, Or }; LogicalExpr(Op op, std::unique_ptr<Expression> left, std::unique_ptr<Expression> right) : m_op(op), m_left(std::move(left)), m_right(std::move(right)) {} Value interpret(const Context& ctx) const override { bool lhs = m_left->interpret(ctx).asBool(); bool rhs = m_right->interpret(ctx).asBool(); if (m_op == Op::And) return Value(lhs && rhs); return Value(lhs || rhs); } private: Op m_op; std::unique_ptr<Expression> m_left; std::unique_ptr<Expression> m_right; };这个扩展只新增类,不改动已有类。解释器模式的这种“开闭原则”天然友好,是它能从玩具走向工程的关键品质。
4.3 与访问者模式组合:一次遍历干多件事
解释器模式在生产系统里往往不是单打独斗,它最常见的搭档是访问者模式。为什么要组合?因为解释器模式把节点结构定死了,但节点上的操作会随着需求增多而膨胀。
一开始你只有interpret一个操作。后来要支持表达式打印、变量收集、类型检查、AST 可视化,每个操作都要在各个节点类里加一个虚函数。节点类的职责会越来越臃肿,违反单一职责原则。
用访问者模式可以把操作从节点中剥离出来。先在每个节点上加一个accept方法:
class Visitor { public: virtual ~Visitor() = default; virtual void visit(Literal& node) = 0; virtual void visit(VariableRef& node) = 0; virtual void visit(BinaryExpr& node) = 0; virtual void visit(UnaryExpr& node) = 0; };然后每个节点实现accept(Visitor&),在方法里回调 visitor 的对应 visit 接口:
void BinaryExpr::accept(Visitor& v) { v.visit(*this); }接下来,打印 AST 的操作就可以独立成一个ExprPrinter访问者,不需要动任何节点类:
class ExprPrinter : public Visitor { public: void visit(Literal& node) override { m_out << node.value(); } void visit(VariableRef& node) override { m_out << node.name(); } void visit(BinaryExpr& node) override { m_out << "("; node.left()->accept(*this); m_out << " " << node.opSymbol() << " "; node.right()->accept(*this); m_out << ")"; } // ... };这里有个 C++ 特有的问题值得注意:accept 和 visit 会破坏 const 正确性。为了遍历时不修改 AST,Visitor 接口里的参数应该用 const 引用。但const引用的accept又没法在非 const 访问者里调用非 const 方法,取舍之后我一般把 Visitor 的方法参数设计成const引用,而节点 accept 本身是 const。这样在 C++ 里能保持最好的并发安全语义。
解释器模式提供稳定的树结构,访问者模式提供可扩展的操作集,两个模式一起用,正好互补了彼此的短板。
4.4 与工厂模式结合:解析节点的统一出口
当表达式类型多起来之后,解析器里到处都是make_unique<XxxExpr>(...),代码会很散。我习惯引入一个简单的节点工厂:
class ExprFactory { public: std::unique_ptr<Expression> makeLiteral(double value) { return std::make_unique<Literal>(value); } std::unique_ptr<Expression> makeBinary(BinaryExpr::Op op, std::unique_ptr<Expression> lhs, std::unique_ptr<Expression> rhs) { return std::make_unique<BinaryExpr>(op, std::move(lhs), std::move(rhs)); } // ... };Parser 只跟工厂打交道,不再直接 new 节点。这样做的收益有两层。第一,工厂可以集中处理一些公共逻辑,比如记录节点创建次数做统计、在节点上附加源文件行号便于调试。第二,如果有一天你要把Literal换成共享对象、把某些常量节点做缓存,只需要改工厂,不用碰 Parser。虽然看起来只是“多包了一层”,但工程化系统里这一层往往是救命的。
4.5 性能问题与优化思路
解释器模式的性能短板是绕不开的话题:每个interpret调用都是一次虚函数调用,整棵树算下来可能几十上百次虚调用。在低并发、低频率的规则引擎里这根本不是问题,但如果你在热路径上每秒解释几百万次,就必须考虑优化。
我实际用过的优化思路主要有三条,按性价比排序。
第一条,分离解析和执行,复用 AST。这是最便宜也最有效的优化。一次解析、万次执行,解析开销被摊薄到几乎为零。很多团队连这一步都没做,每次请求都重新解析字符串,性能差了两个数量级。
第二条,常量折叠。AST 里如果有一棵子树全是Literal节点,比如2 * 3 + x,你可以在解释之前把2 * 3折叠成6。做法是给节点加一个求值器之外的分析优化 pass,遍历时如果发现BinaryExpr的两个子节点都是Literal,就原地替换成一个新的Literal。这个优化对“半静态”的配置表达式特别有效,因为配置里相当一部分子表达式在运行时确实不变。
第三条,编译到字节码或原生函数。这是终极手段。AST 本身是一棵树,但你可以在第一次解释时把树编译成一个线性指令序列,比如PUSH_VAR 0, PUSH_CONST 3, MUL, ...,后续执行只遍历这个紧凑的指令数组,完全消除虚函数调用。这一步性能提升非常可观,但复杂度也上一个台阶。如果项目真到了这一步,你其实已经在写一个编译器前端了,需要重新评估是否引入成熟的脚本虚拟机更划算。
4.6 何时应该停止“手工解释器”
这是我在文章里必须强调的现实问题。解释器模式适用的文法规模是有上限的,我个人的经验阈值是:如果文法里的产生式数量超过五十条,或者语法规则一年内要大改超过两次,就该悬崖勒马。
超过这个阈值,手写的递归下降解析器和节点类会变得极其庞大、难以维护。此时正确的选择是用现成的解析器生成工具,或者直接在项目里内嵌一门成熟的脚本语言。这跟“不要重复造轮子”是一个道理。解释器模式的优雅在于“小而美”,强行把它用在“大而复杂”的场景,就是在和自己的维护精力过不去。
判断方法也很简单:写代码之前,先把文法列在纸上。如果列满一页纸还没列完,立刻放弃手写解释器。你的时间值得花在更重要的业务上。
5. 常见错误、排查方法与避坑清单
5.1 优先级与结合性的“经典翻车现场”
我在第一次写递归下降解析器时,就在结合性上栽过跟头。当时我图省事,把parseTerm写成了右递归:
// 错误示范:右递归会导致结合性错误 std::unique_ptr<Expression> parseTerm() { auto left = parseFactor(); if (match(TokenType::Star)) { auto right = parseTerm(); // 问题出在这 return std::make_unique<BinaryExpr>(Op::Mul, std::move(left), std::move(right)); } return left; }当时测2 * 3 * 4没问题,因为乘法满足结合律。但一测3 - 2 + 1就漏馅了。解析器把中缀的3 - 2 + 1理解成了3 - (2 + 1),结果是 0,而正确结果是 2。问题根源是:用右递归写循环运算符,会把左结合变右结合。
这个错误在测试用例不齐全时特别隐蔽,因为加减乘除单独出现时,结合性被交换律掩盖了。只有当混合运算并且顺序影响结果时才露出马脚。我最后的解决方案就是前面代码里的写法:parseExpression和parseTerm全部用while循环迭代解析,每次把新拼出来的节点作为下一次循环的左子树。
自查口诀是我后来总结的:“左结合用循环,右结合用递归”。加减乘除是左结合,循环;幂运算和赋值是右结合,递归。这条规则可以帮你避开绝大多数结合性问题。
5.2 递归深度与栈溢出
另一个我很早就遇到的坑是递归深度。解释器模式构建的 AST 树深和输入表达式长度成正比。如果你让用户输入1+1+1+...+1,一共几千项,parseExpression的 while 循环本身不深,但每次解析出来的左子树会越来越高。解释阶段从根节点递归到底部,栈深度跟着树高走。树高几千层,栈就爆了。
实际遇到的现象是:程序在小规模输入时完全正常,一压测就栈溢出崩溃,而且崩溃位置在interpret里,很难联想到是解析阶段留下的“开”字形树。
我的应对方案有三层。第一层,对表达式长度做上限校验,配置中心里限制表达式字符串不超过 1024 字符,从源头掐断极端输入。第二层,解析和解释时都注意避免不必要的递归加深,能压平成迭代的左结合运算就压平。第三层,如果确实需要处理超深表达式,可以把interpret改成显式栈迭代遍历,但代价是代码复杂度显著上升。绝大多数业务场景下,限制长度已经足够,不必过度设计。
5.3 Context 变量不存在的“假阴性”
解释器模式的另一个经典坑出在 Context 上。当你从配置中心加载一条表达式,它引用了变量price,但运行数据里没有这个字段,会发生什么?
如果getVariable返回 0.0,表达式会“安静地”算出一个错误结果,而且这个结果看起来很正常,不容易被注意到。如果getVariable抛异常,又可能导致整批数据中断。我经历过一次前者,事后排查浪费了大半天。
我的最终方案是分层处理:解析阶段做静态变量收集,提前发现表达式引用了哪些变量;运行时getVariable仍然抛异常,但调用方会捕获异常并记录上下文信息,包括表达式原文、变量名、当前业务主键。这样每次出问题,日志里都有一条完整的“表达式在哪个上下文里因缺哪个变量失败”的记录。宁可让异常响一点,也不要让错误静默。
5.4 生命周期、移动语义与异常安全的“隐形坑”
C++ 版本解释器跟其他语言最大的区别就在内存管理上,这也是最容易出问题的地方。
最常见的坑是有人为了省事,在BinaryExpr里存裸指针而不是unique_ptr。结果解析完 AST 后,局部变量里的子节点被析构,树里留下悬垂指针,一 interpret 就 use-after-free。这种崩溃非常难排查,因为它不是每次都崩,取决于内存有没有被覆盖。解决办法就一条:从根到叶子,全链所有权明确,禁止裸指针。
第二个坑是移动后使用。unique_ptr被std::move之后,原来的指针变量变成空指针。如果在 move 之后再访问它,就是空指针解引用。我在写 Parser 时也栽过:先std::move(left)送去 make_unique,然后又在后面的代码里访问left,结果崩溃。这个问题的根源是使用顺序,解法就是前面提过的分步写法,先取右边,再移动左边。
第三个坑跟参数求值顺序有关,也是 C++ 特有的。我在 3.5 节已经讲过了:make_unique<BinaryExpr>(op, std::move(left), parseTerm())的实参求值顺序未定义,可能导致 left 被移动后 parseTerm 抛异常。别赌编译器行为,拆成两行写最稳妥。
5.5 调试递归下降解析器的三板斧
递归下降解析器调试起来比普通代码别扭,因为它是一层套一层的递归,一报错就不知道卡在哪层。我调试这类代码用三个方法,每次都管用。
第一板斧:打印 Token 流。解析失败时,第一件事不是看 AST,而是看 token 流是不是符合预期。一个五六行的 token 打印函数,能在十秒钟内帮你确认词法分析有没有问题。
第二板斧:给节点加调试输出。给每个表达式类实现一个toString()方法,把子树用括号括起来打印,比如((x * 3) + ((y - 1) * 2))。一眼就能看出解析树的结构对不对、结合性对不对。这个方法在排查优先级问题时几乎是决定性的。
第三板斧:写最小复现测试。不要用一个几百字符的长表达式去调试,拆成最小单元:先测单个数字、单个变量;再测括号解析;再测一元负号;逐层往上加。我的测试用例表大概是这样的:
| 输入表达式 | 预期结果 | 验证点 |
|---|---|---|
42 | 42 | 数字字面量 |
x | 6 | 变量引用 |
(1 + 2) * 3 | 9 | 括号优先级 |
-2 + 3 | 1 | 一元负号 |
2 ^ 3 ^ 2 | 512 | 幂右结合 |
3 - 2 + 1 | 2 | 加减左结合 |
10 / 4 | 2.5 | 除法 |
1 / 0 | 异常 | 除零检查 |
这套表打全了,解析器基本就稳了。
5.6 快速自查清单
最后把我这些年写解释器模式的经验浓缩成一张自查清单,动手前过一遍,能省掉后面大部分调试时间。
| 检查项 | 说明 |
|---|---|
| 文法是否已写在纸上 | 没有明确文法就写代码,是大部分优先级错误的根源 |
| 终结符与非终结符是否区分明确 | 叶子节点和组合节点不要混在同一个类里 |
| 左结合是否用循环实现 | 加减乘除必须 while 循环,不能右递归 |
| 右结合是否用递归实现 | 幂运算等必须右递归 |
| unique_ptr 所有权是否覆盖全树 | 禁止裸指针子节点 |
| interpret 是否声明为 const | 保证并发只读安全 |
| 除法是否做了除零检查 | 浮点除零不会自动抛异常 |
| Context 未定义变量是否显式报错 | 不要静默返回 0 |
| parse 结果是否做了 End token 校验 | 防止输入尾部有非法残留字符 |
| 是否需要解析/执行分离 | 高频场景优先分离 |
最后再分享一个小技巧:如果项目里允许,给 AST 节点统一加一个line()和column()字段,记录它在原始表达式中的位置。解析出错时,把这个位置打印在异常信息里,排错体验直接提升一个档次。这个字段平时占不了多少内存,但关键时刻远比一堆堆栈信息实用。
我个人在实际操作中的体会是:解释器模式其实一点都不神秘,它的核心难点早在动手写类之前就已经被文法定义解决了。真正让一个解释器变得好用或者难用的,往往不是模式本身,而是你对文法想得清不清楚、对 C++ 所有权模型理得顺不顺。文法想清楚了,代码只是体力活;所有权理顺了,内存问题根本不会出现。希望这篇从设计到实现再到排错的完整记录,能帮你少走几步我曾经绕过的弯路。