前一阵子我在重构一个后台策略模块时,遇到了一个很典型的场景:运营同事提了一堆“消费金额大于三千且不是折扣商品,就展示专属优惠”“会员等级不低于三级,并且最近三十天有订单,才发放体验券”之类的规则,而且每周都有增删改。第一版我是理所当然地用 if-else 硬写,第二个星期就发现代码已经没法看了,改一个条件要翻好几个文件,还要反复确认优先级对不对。没办法,我决定在这个 C++ 工程里引入解释器模式,自己写一个轻量的规则表达式引擎。这篇文章就从这个真实需求出发,聊一聊 C++ 中解释器模式的核心结构、完整实现,以及我在落地过程中踩过的一些坑。
这篇文章适合两类人看:一类是已经知道解释器模式概念、但不确定怎么在 C++ 里落地的人;另一类是和我一样被“频繁变化的业务规则”追着跑、想找个稳妥出路的 C++ 开发者。我会把从需求判断、AST 设计、词法语法实现到求值执行的完整链路讲清楚,也会说清楚为什么后来我没有用第三方解析库。
1. 从“硬编码配置”到规则表达式:我为什么会在 C++ 项目里引入解释器模式
1.1 需求爆炸的三个信号
并不是所有项目都需要解释器模式,它解决的是“一类规则持续变化”的问题,而不是“一个问题只变一两次”的问题。我后来复盘时总结了三个信号,出现两个以上就基本可以确定需要引入解释表达式机制了。
第一个信号是规则数量膨胀。第一版可能有 5 条 if-else 规则,第二版变成 15 条,再过两个月可能就 40 条了。每条规则本身还好,但规则之间的组合关系一旦超过 10,人的脑子就装不下了。第二个信号是规则之间存在嵌套和优先级。比如“A 或者 B 且 C”,到底是A || (B && C)还是(A || B) && C,用 if-else 写很容易让代码和真实策略不一致。第三个信号是非技术人员希望直接参与维护。当运营同事拿着 Excel 表来和我“核对规则逻辑”时,我就意识到,应该给规则一个更接近自然语言的表达形式,让变化的部分从代码里挪出去。
我当时这个模块正好三个信号都踩中了,所以我决定走解释器路线。它的核心思路很简单:定义一套领域表达式语言,把表达式解析成一棵语法树,然后让这棵树自己执行。表达式被当成数据存起来,业务变化时改数据,代码不需要跟着变。
1.2 为什么不直接用现成的第三方库
很多人的第一反应是:解析表达式这种事,不是有很多现成库可以用吗?为什么还要自己写?我当时也纠结过,最后没有用第三方库,主要是三个原因。
第一是成本问题。我当时的需求只是四则运算、比较运算、括号和少量变量绑定,引入一个完整表达式解析库,体积和编译时间都不太划算,还要额外学习它的语法约定。第二是扩展自由度问题。我后续想加一些业务特有的函数,比如daysBetween(...)、inRegion(...),用第三方库往往会受限于它预留的接口,调试起来不够直接。第三是我对“自己写解释器”的代码量做了一个估算:词法分析加递归下降解析器加 AST 求值,核心部分大概两三百行 C++,配合单元测试,完全可控。
这里要澄清一个常见误解:解释器模式不等于解析器。解释器模式关注的是“如何用对象表示语法树,并让每个节点实现执行逻辑”,词法分析和语法分析只是前置步骤,甚至完全可以手写递归下降,根本不需要额外依赖。很多人一听“写解释器”就觉得要搞编译原理那一套,其实对于规则表达式这种规模的问题,工作量非常小。
1.3 先想清楚四个角色再动手
解释器模式里通常有四个角色,我在 C++ 代码里也是照着一一映射的。
- AbstractExpression:抽象表达式,对应一个
Expr基类,所有语法树节点的共同接口。 - TerminalExpression:终结符表达式,对应数字、布尔值、变量名这类叶子节点。
- NonterminalExpression:非终结符表达式,对应加、减、乘、除、比较、括号组合这类内部节点。
- Context:上下文,用来在表达式执行时传递外部数据,比如变量表、环境参数、动态值。
我当时的第一步不是写代码,而是把这四个角色落到纸面上。一个表达式price > 3000 && discount == false解析成 AST 后,顶层是一个BinaryExpr(逻辑与),左子树是price > 3000,右子树是discount == false,最底层是变量节点和数字节点。执行时从根节点递归往下,每个节点只负责自己的语义,整个树遍历完,结果自然算出来。这个模型非常直观,C++ 的多态和递归特性天然适合它。
2. 解释器模式的 C++ 落地:先分清 AST 节点与上下文
2.1 四个角色的 C++ 映射
我的第一版实现非常保守,就用最朴素的虚函数多态。下面这段代码是 AST 节点的基本结构,可以看作解释器模式在 C++ 里的骨架。
class Context { public: std::map<std::string, double> vars; }; class Expr { public: virtual ~Expr() = default; virtual double evaluate(Context& ctx) const = 0; }; class NumberExpr : public Expr { public: explicit NumberExpr(double v) : val_(v) {} double evaluate(Context&) const override { return val_; } private: double val_; }; class VarExpr : public Expr { public: explicit VarExpr(std::string name) : name_(std::move(name)) {} double evaluate(Context& ctx) const override { if (!ctx.vars.count(name_)) { throw std::runtime_error("undefined variable: " + name_); } return ctx.vars[name_]; } private: std::string name_; }; class BinaryExpr : public Expr { public: BinaryExpr(char op, std::unique_ptr<Expr> lhs, std::unique_ptr<Expr> rhs) : op_(op), lhs_(std::move(lhs)), rhs_(std::move(rhs)) {} double evaluate(Context& ctx) const override { double l = lhs_->evaluate(ctx); double r = rhs_->evaluate(ctx); switch (op_) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/' : if (r == 0) throw std::runtime_error("divide by zero"); return l / r; case '>': return l > r; case '&': return l && r; case '|': return l || r; default: throw std::runtime_error("unknown operator"); } } private: char op_; std::unique_ptr<Expr> lhs_; std::unique_ptr<Expr> rhs_; };这段代码里最值得琢磨的是选择虚函数而不是switch加整数标签。如果用一个enum NodeType和switch去区分节点类型,每加一种节点就要改好几个地方。而用虚函数多态,新增一个UnaryExpr、FuncExpr,只需要继承Expr并实现evaluate,对已有节点完全无侵入。解释器模式本身就是“让每个节点自己做自己的求值”,这是最符合直觉的写法。
2.2 为什么用 unique_ptr 管理 AST 生命周期
AST 是一棵树,树的每个节点只有一个父节点。这种所有权关系在 C++ 里最适合用std::unique_ptr表达:析构时自动递归释放所有子节点,不需要手写delete,也不会出现两条路径同时释放同一个节点的危险。
我看到不少初学者在这里用裸指针,最后不得不写一个复杂的析构函数,还要小心拷贝构造;也有用shared_ptr的,但 AST 本身不需要共享所有权,shared_ptr的使用计数会带来不必要的开销,而且一旦子树被循环引用,内存泄漏反而更难排查。
有一个细节值得单独说:BinaryExpr的析构函数不需要显式写,unique_ptr成员会在析构时自动释放左右子树。但如果你有前置声明的习惯,记得把析构函数放到实现文件里显式定义,否则编译器可能在需要完整类型的位置实例化析构,导致报错。这个小细节我后来在写std::variant版本时又踩了一次,后文会展开讲。
2.3 “直接解释字符串”和“先解析成 AST 再解释”是两条路线
新手最容易犯的一个错误,是拿到一个表达式字符串就直接从头扫到尾,一边扫一边算。比如遇到1 + 2 * 3,直接从左往右算,得到9,这明显错了。还有人会提前处理乘法优先级,一边扫描一边找乘号,逻辑越来越乱,最后变成一锅粥。
正确的路线是“先解析,再执行”。解析阶段把字符串变成 AST,执行阶段递归访问 AST。这样做有几个立竿见影的好处:第一,优先级和结合性在解析阶段就被语法规则消化掉了,执行阶段根本不需要考虑优先级;第二,可以提前做静态校验,比如变量是否存在、运算符是否合法、是否有除零风险;第三,后续如果需要优化,比如常量折叠,直接操作 AST 就可以了,不用碰字符串。
我当时在设计时坚持把 Lexer、Parser、Interpreter 三个东西分开,即使每个类都只有几十行,也绝不混在一个类里。原因是后续我加任何一方逻辑,都不用担心影响另外两方。
3. 手写一个表达式计算器:词法、语法、求值三层的完整实现
3.1 词法分析:Token 类型与 Lexer 实现
词法分析的任务,是把原始字符串拆成有意义的 Token 序列。比如"price > 3000 && !discount",应该拆成Var(price)、Greater、Number(3000)、And、Not、Var(discount)。Token 里最好带上位置信息,这样后面报错时可以定位到字符串的第几个字符,而不是只给一句“表达式错误”。
我的 Token 定义如下:
enum class TokenType { Number, Var, Plus, Minus, Star, Slash, Greater, Less, Equal, And, Or, Not, LParen, RParen, End }; struct Token { TokenType type; double number = 0.0; std::string_view text; size_t pos = 0; };Lexer 的核心逻辑就是一个循环。跳过空白,然后根据当前字符决定拼数字还是拼变量名还是识别运算符。对于数字,要注意小数点的情况;对于变量名,要支持字母、下划线开头,后面可以跟数字。这部分我很建议单独写单元测试,因为词法错误往往是后面所有报错的根源。
class Lexer { public: explicit Lexer(std::string_view src) : src_(src), cur_(0) {} bool next(Token& out) { skipSpaces(); if (cur_ >= src_.size()) { out = Token{TokenType::End, 0, src_.substr(cur_, 0), cur_}; return false; } size_t start = cur_; char c = src_[cur_]; if (std::isdigit(c) || c == '.') { while (cur_ < src_.size() && (std::isdigit(src_[cur_]) || src_[cur_] == '.')) { ++cur_; } out = Token{TokenType::Number, std::stod(std::string(src_.substr(start, cur_ - start))), src_.substr(start, cur_ - start), start}; return true; } if (std::isalpha(c) || c == '_') { while (cur_ < src_.size() && (std::isalnum(src_[cur_]) || src_[cur_] == '_')) { ++cur_; } out = Token{TokenType::Var, 0, src_.substr(start, cur_ - start), start}; return true; } // 其余运算符映射 switch (c) { case '+': ++cur_; out = Token{TokenType::Plus, 0, "+", start}; return true; case '-': ++cur_; out = Token{TokenType::Minus, 0, "-", start}; return true; case '*': ++cur_; out = Token{TokenType::Star, 0, "*", start}; return true; case '/': ++cur_; out = Token{TokenType::Slash, 0, "/", start}; return true; case '(': ++cur_; out = Token{TokenType::LParen, 0, "(", start}; return true; case ')': ++cur_; out = Token{TokenType::RParen, 0, ")", start}; return true; default: throw std::runtime_error(std::string("unexpected char at ") + std::to_string(start)); } } private: void skipSpaces() { while (cur_ < src_.size() && std::isspace(src_[cur_])) ++cur_; } std::string_view src_; size_t cur_; };这段代码看着简单,但有一个容易翻车的地方:std::string_view指向原始字符串,使用期间必须保证原始字符串存活。我在这个函数里用string_view是为了零拷贝,但代价是调用方必须管理好生命周期。如果你没把握,直接用std::string存text会更稳,性能差异在这个量级可以忽略。
3.2 语法分析:递归下降 Parser 如何保证优先级
表达式最烦人的是优先级。*和/要优先于+和-,括号又要优先于一切。我用的是最经典的递归下降法:一个语法层级对应一个函数。
规则很简单:
expr := term (('+' | '-') term)*term := factor (('*' | '/') factor)*factor := number | var | '(' expr ')' | '!' factor
这样2 + 3 * 4解析出来的 AST 就是Binary('+', Number(2), Binary('*', Number(3), Number(4)))。乘法那一层的解析先于加法,所以乘法天然被压到子树里。
class Parser { public: explicit Parser(std::string_view src) : lexer_(src) { lexer_.next(current_); } std::unique_ptr<Expr> parseExpr() { std::unique_ptr<Expr> node = parseTerm(); while (current_.type == TokenType::Plus || current_.type == TokenType::Minus) { char op = current_.type == TokenType::Plus ? '+' : '-'; advance(); auto rhs = parseTerm(); node = std::make_unique<BinaryExpr>(op, std::move(node), std::move(rhs)); } return node; } private: std::unique_ptr<Expr> parseTerm() { std::unique_ptr<Expr> node = parseFactor(); while (current_.type == TokenType::Star || current_.type == TokenType::Slash) { char op = current_.type == TokenType::Star ? '*' : '/'; advance(); auto rhs = parseFactor(); node = std::make_unique<BinaryExpr>(op, std::move(node), std::move(rhs)); } return node; } std::unique_ptr<Expr> parseFactor() { if (current_.type == TokenType::Number) { auto node = std::make_unique<NumberExpr>(current_.number); advance(); return node; } if (current_.type == TokenType::Var) { auto node = std::make_unique<VarExpr>(std::string(current_.text)); advance(); return node; } if (current_.type == TokenType::LParen) { advance(); auto node = parseExpr(); if (current_.type != TokenType::RParen) { throw std::runtime_error("missing right paren"); } advance(); return node; } throw std::runtime_error("unexpected token"); } void advance() { lexer_.next(current_); } Lexer lexer_; Token current_; };这是整个解释器里最值得多花时间看的环节。递归下降的代码很直白,没有状态机,没有复杂的表驱动,每一层函数名就是语法规则名。如果后续要加比较运算>、<、==,只需在parseExpr下面再加一层比较逻辑,或者直接在parseExpr后面继续处理比较运算符。注意比较运算的优先级低于+和-,所以不能把比较运算符塞进parseTerm里,得放在更外层。
3.3 求值器:节点自己执行,上下文负责传数据
AST 建好之后,求值就非常无脑了:从根节点调用evaluate,每个节点会继续调用子节点的evaluate。如果表达式里有变量,就从Context的vars表里取值。这里值得一提的是,Context可以不只是map<string, double>,你完全可以把它设计成一个更灵活的结构,比如保存数据库查询结果、运行时配置、用户画像数据等。我当时就是为了让Expr不依赖具体业务类型,才把环境数据全部塞进Context,这样表达式和业务解耦。
计算price > 3000 && !discount时,根节点是BinaryExpr('&'),它会先算左边price > 3000,再算右边!discount。如果价格是 5000,左边得到 true,右边得到把discount变成 false 的取反结果,最终输出 true。注意!是单目运算符,我在实现时单独写了一个UnaryExpr,它的evaluate就是先算子树,再取逻辑非。
3.4 整体执行流程和测试用例
组合起来的调用方式是这样的:
double runExpression(std::string_view exprStr, Context& ctx) { Lexer lexer(exprStr); Parser parser(exprStr); auto ast = parser.parseExpr(); return ast->evaluate(ctx); }整个流程只有三步:词法拆 Token,语法建 AST,AST 求值。我强烈建议在写完 Parser 之后立刻写一批覆盖优先级的测试,比如"2+3*4"必须等于 14,"(2+3)*4"必须等于 20,"10-2-3"必须等于 5,保证左结合没有出错。这些用例看着基础,但能挡住后续扩展时绝大多数回归问题。
4. 虚函数之外的选择:用 std::variant 把 AST 节点压平
4.1 从抽象基类到 std::variant 的结构变化
C++17 之后,解释器模式在 C++ 里多了一个很有意思的实现路线:不用虚函数,而是用std::variant存节点数据,再用std::visit做访问。这样写出来的 AST 是一个扁平结构而不是一个继承体系。
思路大概是这样的:定义NumberExpr、VarExpr、UnaryExpr、BinaryExpr作为纯数据结构,不包含任何求值逻辑;再定义一个std::variant<NumberExpr, VarExpr, UnaryExpr, BinaryExpr>作为Node类型;求值逻辑全部放进独立的访问函数,用std::visit分发。
struct NumberExpr { double value; }; struct VarExpr { std::string name; }; struct UnaryExpr { char op; std::unique_ptr<Node> operand; }; struct BinaryExpr { char op; std::unique_ptr<Node> lhs, rhs; }; using Node = std::variant<NumberExpr, VarExpr, UnaryExpr, BinaryExpr>;注意这里UnaryExpr和BinaryExpr里的成员是std::unique_ptr<Node>,这个递归类型在 C++17 里是可以成立的,因为unique_ptr的内容不需要在定义时完整。但有一个细节让很多人踩坑:当编译器需要析构Node时,它必须能看到完整类型定义。如果Node和求值函数写在同一个头文件里,问题不大;如果分文件,就要把析构函数放到实现文件里显式声明,否则会报“incomplete type”错误。
4.2 求值逻辑变成了访问者函数
以前求值是每个节点里的虚函数,现在变成了一个独立的evaluateNode函数。核心变成一个overloaded模式,把所有std::get分支收拢到 lambda 里。
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; double evaluate(const Node& node, Context& ctx) { return std::visit(overloaded{ [](const NumberExpr& n) { return n.value; }, [](const VarExpr& v) -> double { if (!ctx.vars.count(v.name)) { throw std::runtime_error("undefined variable: " + v.name); } return ctx.vars[v.name]; }, [&](const BinaryExpr& b) -> double { double l = evaluate(*b.lhs, ctx); double r = evaluate(*b.rhs, ctx); switch (b.op) { /* ... */ } } }, node); }这种写法最大的优点是“穷尽性”:std::visit会要求访问者覆盖variant里的每一种类型,否则编译不通过。比如以后新增一种FuncExpr,编译器会直接报“缺少对 FuncExpr 的访问”错误,逼着你把所有处理点都改一遍。这比虚函数方案在编译期就能查出漏改,是我比较喜欢它的原因。
不过代价也有。第一,std::variant实际占用空间等于最大成员的空间加上一个类型索引,而UniquePtr本身只占一个指针,但 variant 还要考虑对齐和 tag 开销,整体挺大。第二,每次求值访问都要调用std::visit,虽然通常会被内联优化掉,但调试时堆栈比虚函数乱不少。
4.3 什么时候选 variant,什么时候选虚函数
我在两个方案之间反复切换过,最后得出的判断标准其实很简单:节点类型集合是否固定。
如果表达式语言已经稳定,未来只会在已有类型上扩展语义,不会新增语法结构,用std::variant很好,编译期穷尽访问能带来很强的安全感。如果表达式语言还在快速演进,今天加个逻辑取反,明天加个函数调用,后天加个三元表达式,那用虚函数更省事。新增一种节点类只需继承Expr并实现evaluate,不需要修改std::visit的调用点。另一个判断标准是团队习惯:如果团队里大部分人还不熟overloaded展开技巧,那虚函数方案维护成本更低。技术选型终究要考虑谁能看懂、谁能接手。
5. 工程里真正的难点:错误恢复、变量绑定、生命周期和可维护性
5.1 解析失败的定位:没有位置信息的报错会让人抓狂
第一个版本里,我的 Parser 抛出的异常就一句“unexpected token”,调用方拿它根本没有办法,因为不知道错在哪里。后来我养成了 Token 必带pos的习惯,异常里也带上出错位置,前端就能在编辑框里把红色的波浪线画到对应位置。
一个比较务实的做法是构建一个ParseException,包含原始表达式、出错位置、错误描述,甚至带一个简单的“从出错位置截取后半段字符串”的能力。这样报错信息可以写成:
表达式 "price > 3000 && && discount" 第 19 个字符附近解析失败,意外出现运算符 '&&'。这种用户体验的差距,比解释器本身的性能差距明显得多。如果你要给别人用这个表达式语言,错误信息这一步不要省。
5.2 变量绑定和作用域设计
规则表达式一定会遇到变量。一开始我只支持map<string, double>,后来运营同事要表达“最近三十天订单金额均值”,这显然不是预置变量能解决的,得传入一个计算函数。我在Context里加了一个回调注册表,变量名可以对应一个std::function<double()>,下次解析到同名变量时直接调用。
这里有一个值得注意的语义问题:变量不存在时应该报错还是返回 0?我的选择是报错。因为表达式里写了一个未知变量,往往是配置写错了,静默返回 0 会让错误一路蔓延到业务层,最后变成一个很难排查的“为什么这个人没有优惠”。宁可让规则引擎尽早抛出异常,把问题暴露在配置阶段。
作用域方面,如果只是简单求值,一个全局变量表就够了。如果以后要支持函数调用,就需要每个调用创建一个子Context,把参数绑定进去。到那时,Context就得设计成可嵌套的,查找变量时逐级向上找。这个扩展点建议提前留好,别等到真有人要用函数时再重构。
5.3 更接近真实工程的分层结构
很多人写解释器模式,会把 Lexer、Parser、AST、求值逻辑全堆在一个文件里,甚至一个类里。我强烈不建议这么做,至少拆成三个类,文件也分开。
Token/Lexer:负责把字符串转成 Token 序列。Parser:负责把 Token 序列转成 AST,AST 节点定义放在独立头文件。Interpreter/Evaluator:负责遍历 AST 并求值,不关心 Token 也不关心字符串。
接口层面,最好让对外 API 只暴露一个函数,比如double evalExpression(std::string_view expr, Context& ctx),内部再把三步组合起来。这样做的好处是调用方完全感知不到内部实现,以后把 Lexer 换成手写状态机,或者把 Parser 换成递归下降的另一种实现,接口都不用变。我甚至建议把 AST 节点定义和求值逻辑拆开,这样未来可以做静态分析或 AST 打印,不必修改求值函数。
5.4 什么时候该刹车:解释器模式的滥用迹象
最后我一定要说一条最值钱的判断:不是所有“频繁变化的规则”都该上解释器模式。解释器模式有一个隐藏成本,就是“为每一个语法特征定义一个节点类”。如果你的规则总共只有两三种固定写法,或者用户根本不会直接写表达式,而是通过下拉框配置条件,那直接建一张配置表就够了,建 AST 就是在过度设计。
我再提供一个很直接的判断表,大家可以对着自己的场景打勾:
| 判断维度 | 适合解释器模式 | 不建议用 |
|---|---|---|
| 规则数量 | 几十种以上且持续增加 | 只有几种固定写法 |
| 规则变化频率 | 每周甚至每天调整 | 几个月不动一次 |
| 表达组合程度 | 支持嵌套、优先级、括号 | 简单扁平,没有嵌套 |
| 使用对象 | 需要业务人员或运营直接维护 | 只有开发自己看着改 |
| 性能要求 | 毫秒量级即可满足 | 内核态或极高吞吐场景 |
如果五条里至少有两条落在“不建议用”那一列,我建议再想想是不是用配置表、策略模式或者简单的函数对象就能解决。解释器模式本身是很好的工具,但它不是用来给每个项目都刷存在感的。
最后一点个人体会
这次实践给我最大的收获,不是学会了怎么写递归下降解析器,而是搞清楚了“把变化放在数据里”有多重要。前面提到我把这个规则引擎接进了内部配置系统后,运营同事调整策略时不再需要等开发改代码,发版节奏一下子轻了。如果说还有什么一定要提醒你的,那就是先把测试写好。解释器模式的 AST 结构一旦定下来,改起来成本很低,但 Lexer 和 Parser 的边界情况很容易漏,括号不匹配、空白字符、除零、未知变量,每个都要有对应的用例兜底。我在实际调试中碰到过的最隐蔽问题,是 Token 的string_view引用了临时字符串导致变量名全部变成乱码,排查了很久才发现是生命周期问题。如果一开始就用std::string就不会有这种事,后来我为了性能把string_view的坑填平了,但这部分经验值得任何人提前知道。