ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

解释器模式在C++规则引擎中的落地:从语法树到AST解析与性能优化

解释器模式在C++规则引擎中的落地:从语法树到AST解析与性能优化 每个做业务系统的C开发早晚都会遇到这么一类需求一段业务规则今天这样、明天那样今天支持这个场景、下周又冒出新的配置项。一开始你把这些规则写成if-else硬编码后来发现需求变更的速度远比你想象得快于是你开始琢磨——有没有办法把规则本身变成数据让程序在运行时去解析、执行一套定义好的语法这时你就会撞上解释器模式。解释器模式是GoF二十三式里看起来最学术的一个很多教程把它讲得云山雾罩动不动就整语法树、终结符、非终结符。但说穿了它的核心思想就一句话把一个领域中频繁变化的业务规则提炼成一种小型语言再用一个解释器去读取和执行这种语言。在C这种偏底层、强调性能和资源可控的语言里解释器模式的应用既有优势也有不少坑本文我会用自己实际写过的表达式引擎、规则引擎项目的经验把它掰开揉碎讲清楚。这篇内容适合想理解设计模式在真实C项目中怎么落地的读者也适合正在为业务规则老变而头疼、想引入规则引擎但不知道从何下手的同学。我会从模式的结构讲起给出完整的C17实现示例然后重点聊一聊那些教科书里很少提的递归下降解析器的边界、AST内存管理、性能优化、以及与Visitor模式组合的实战技巧。1. 为什么需要解释器模式从一次需求变更说起先讲个我自己经历过的场景。当时做一个交易系统的风控模块最初版本里对订单金额的限制是写死的if (order.amount 10000) { reject(单笔金额超限); }后来业务方说不同用户等级限制不同于是改成if (order.amount user.vip_level * 5000 5000) { reject(超过用户等级对应限额); }接着又来新需求某些特殊商品不受限、夜间交易限额减半、黑名单用户一律拒绝……每条规则都往代码里塞。一个月后check()函数膨胀到两百多行每次变更都要发版、重新编译运维同事怨声载道。这时你开始想如果这些规则能像配置文件一样动态下发程序内部有一个迷你语言来解释它们那该多好。这就是解释器模式诞生的实际动机当某个领域的规则复杂到变化速度超过开发速度时把规则抽象成语言比把规则写成代码更划算。注意我刻意用了领域这个词。解释器模式并不是让你给所有业务搞一套DSL领域特定语言那会陷入过度设计的泥潭。正确的使用场景有几个共同特征规则数量中等偏多且频繁变化但规则的种类有限——不是无限自由的逻辑而是有限几种表达式的组合业务人员或运维人员需要在不重启服务的情况下调整行为规则本身是结构化的、可组合的比如金额1000且用户等级3这种布尔表达式性能要求不是极端苛刻——解释执行的效率肯定比原生编译代码低但通常在可接受范围。如果你遇到的问题同时满足这些条件解释器模式就有了用武之地。反之如果规则只有两三条、几乎不变或者规则高度复杂需要图灵完备的语言那干脆用硬编码或者直接嵌入一个成熟的脚本引擎比如Lua都比自己写解释器靠谱。2. 解释器模式的经典结构四个角色和一颗语法树解释器模式的结构一句话可以概括为定义一套语法规则把每条语法规则映射成一个类然后用这些类去构建并解释一棵语法树。GoF书里的类图大多数人记不牢我直接用C代码来帮你建立心智模型。2.1 四个核心角色的C映射经典结构里有四个角色抽象表达式AbstractExpression定义解释操作的接口interpret(Context)。在C中通常是一个抽象基类。终结符表达式TerminalExpression语法树中的叶子节点比如表达式里的数字、变量名。它直接返回自身的值。非终结符表达式NonterminalExpression组合节点比如加减乘除、逻辑与或。它递归地解释子节点。上下文Context存放解释时需要的全局信息比如变量表、操作数栈。C中常见的就是一个unordered_mapstring, int之类的符号表。用代码说话。假设我们要支持一个极简的算术表达式语言只包含整数常量、变量、加法和乘法那么核心接口是这样#include memory #include string #include unordered_map struct Context { std::unordered_mapstd::string, int variables; }; class Expression { public: virtual ~Expression() default; virtual int interpret(Context ctx) const 0; };终结符表达式——数字和变量class NumberExpr : public Expression { public: explicit NumberExpr(int value) : value_(value) {} int interpret(Context) const override { return value_; } private: int value_; }; class VariableExpr : public Expression { public: explicit VariableExpr(std::string name) : name_(std::move(name)) {} int interpret(Context ctx) const override { auto it ctx.variables.find(name_); if (it ctx.variables.end()) { throw std::runtime_error(unknown variable: name_); } return it-second; } private: std::string name_; };非终结符表达式——加法、乘法class AddExpr : public Expression { public: AddExpr(std::unique_ptrExpression lhs, std::unique_ptrExpression rhs) : lhs_(std::move(lhs)), rhs_(std::move(rhs)) {} int interpret(Context ctx) const override { return lhs_-interpret(ctx) rhs_-interpret(ctx); } private: std::unique_ptrExpression lhs_; std::unique_ptrExpression rhs_; }; class MulExpr : public Expression { public: MulExpr(std::unique_ptrExpression lhs, std::unique_ptrExpression rhs) : lhs_(std::move(lhs)), rhs_(std::move(rhs)) {} int interpret(Context ctx) const override { return lhs_-interpret(ctx) * rhs_-interpret(ctx); } private: std::unique_ptrExpression lhs_; std::unique_ptrExpression rhs_; };到这里结构已经清楚了表达式对象组合成树状结构interpret递归地从叶子到根求出结果。这就是解释器模式最朴素的形态。2.2 语法树到底是怎么来的注意一个容易被忽略的点上面的类只是解释部分语法树本身怎么构建GoF书里把客户端创建树的过程也画进去了但在真实项目里构建树通常由一个**语法分析器Parser**负责。这个Parser负责把字符串形式的规则如a2*b解析成Expression对象树。我曾经见过有初学者把解释器模式和Parser混为一谈其实它们是两个层次Parser从文本到语法树属于编译原理的前端。解释器模式从语法树到结果属于树结构的递归求值。Parser可以用手写递归下降也可以用工具生成ANTLR、Flex/Bison但解释器模式的类结构核心始终是抽象表达式和非终结符的组合。手写方式配合解释器模式在小型DSL场景里配合度极高这也是后面第4节要展开聊的。3. C实现示例一个支持变量和四则运算的迷你表达式引擎光看结构不够我用一个可以跑的迷你项目把整个过程串起来。这个引擎支持整数常量、变量、加减乘除和括号目标是能解析并执行类似a * (b 2) / 4这样的表达式。3.1 分词Tokenizer解析文本的第一步是分词。这里我用最简单的逻辑跳过空白识别数字、变量名、运算符和括号。#include vector #include cctype enum class TokenType { Number, Variable, Plus, Minus, Star, Slash, LParen, RParen, End }; struct Token { TokenType type; std::string text; int value 0; // 当type为Number时有效 }; std::vectorToken tokenize(const std::string src) { std::vectorToken tokens; size_t pos 0; while (pos src.size()) { char c src[pos]; if (std::isspace(static_castunsigned char(c))) { pos; continue; } if (std::isdigit(static_castunsigned char(c))) { int val 0; while (pos src.size() std::isdigit(static_castunsigned char(src[pos]))) { val val * 10 (src[pos] - 0); pos; } tokens.push_back({TokenType::Number, {}, val}); } else if (std::isalpha(static_castunsigned char(c))) { std::string name; while (pos src.size() std::isalnum(static_castunsigned char(src[pos]))) { name.push_back(src[pos]); pos; } tokens.push_back({TokenType::Variable, name}); } else { switch (c) { case : tokens.push_back({TokenType::Plus}); break; case -: tokens.push_back({TokenType::Minus}); break; case *: tokens.push_back({TokenType::Star}); break; case /: tokens.push_back({TokenType::Slash}); break; case (: tokens.push_back({TokenType::LParen}); break; case ): tokens.push_back({TokenType::RParen}); break; default: throw std::runtime_error(std::string(unexpected char: ) c); } pos; } } tokens.push_back({TokenType::End}); return tokens; }这里有个细节std::isdigit和std::isalpha在传char时可能产生未定义行为所以我先转成unsigned char。小坑但值得提一下免得有人照抄之后在非ASCII环境下踩雷。3.2 递归下降Parser有了Token流就可以用递归下降法构建AST。我们给表达式定义优先级加减低于乘除乘除高于括号括号强制提升优先级。递归下降的思路是为每个语法产生式写一个函数expression处理加减term处理乘除factor处理数字、变量和括号。class Parser { public: explicit Parser(std::vectorToken tokens) : tokens_(std::move(tokens)), pos_(0) {} std::unique_ptrExpression parse() { auto expr parseExpression(); if (current().type ! TokenType::End) { throw std::runtime_error(unexpected trailing tokens); } return expr; } private: const Token current() const { return tokens_[pos_]; } void advance() { if (pos_ tokens_.size()) pos_; } void expect(TokenType type) { if (current().type ! type) throw std::runtime_error(unexpected token); advance(); } std::unique_ptrExpression parseExpression() { auto lhs parseTerm(); while (current().type TokenType::Plus || current().type TokenType::Minus) { auto op current().type; advance(); auto rhs parseTerm(); if (op TokenType::Plus) { lhs std::make_uniqueAddExpr(std::move(lhs), std::move(rhs)); } else { lhs std::make_uniqueSubExpr(std::move(lhs), std::move(rhs)); } } return lhs; } std::unique_ptrExpression parseTerm() { auto lhs parseFactor(); while (current().type TokenType::Star || current().type TokenType::Slash) { auto op current().type; advance(); auto rhs parseFactor(); if (op TokenType::Star) { lhs std::make_uniqueMulExpr(std::move(lhs), std::move(rhs)); } else { lhs std::make_uniqueDivExpr(std::move(lhs), std::move(rhs)); } } return lhs; } std::unique_ptrExpression parseFactor() { if (current().type TokenType::Number) { auto val current().value; advance(); return std::make_uniqueNumberExpr(val); } if (current().type TokenType::Variable) { auto name current().text; advance(); return std::make_uniqueVariableExpr(name); } if (current().type TokenType::LParen) { advance(); auto expr parseExpression(); expect(TokenType::RParen); return expr; } throw std::runtime_error(unexpected token in factor); } std::vectorToken tokens_; size_t pos_; };这里需要补上SubExpr和DivExpr它们的实现和AddExpr、MulExpr如出一辙就不重复贴了。跑一个例子int main() { Context ctx; ctx.variables[a] 10; ctx.variables[b] 20; std::string src a * (b 2) / 4; auto tokens tokenize(src); Parser parser(tokens); auto expr parser.parse(); int result expr-interpret(ctx); printf(result %d\n, result); // 输出10*(22)/455 return 0; }到这里一个最小的解释器模式C工程已经能跑通了。你也看到了解释器模式的类提供了解释执行的能力而Parser完成了从文本到对象树的转换两者合在一起才是完整的规则引擎。3.3 为什么设计成unique_ptr的树上面所有组合节点都持有子节点的std::unique_ptr这不仅是RAII资源管理的要求更重要的是它保证了树的唯一所有权关系。每个子节点只有一个父节点析构时递归释放整个树不会出现循环引用、多处释放的问题。在C里实现树结构时unique_ptr是首选而不是裸指针或shared_ptr——裸指针你得手动delete一个异常就能泄漏shared_ptr则带来了原子引用计数的开销而且逻辑上也不符合唯一拥有的语义。4. 反直觉的真相解释器模式与手写递归下降的边界写完上面那个迷你引擎很多人会困惑我到底是在用解释器模式还是只是学会了手写解析器这两个概念之间的边界比教科书说的要模糊得多。4.1 解释器模式并不等于Parser严格来说GoF书里的解释器模式包含了构建语法树的客户端代码但不强制你用什么方式构建。你可以用手写Parser也可以用Parser生成器甚至可以直接在代码里手动new出树节点。模式的本质是树里的每个节点都知道自己如何解释——也就是把语法规则和执行逻辑绑定到对象上。所以当你用递归下降解析时Parser部分不属于解释器模式它只是为解释器模式提供输入的辅助工具。但我在实战中很少把这两者分开——没有Parser的解释器模式就像没有点火开关的汽车引擎没法单独使用。因此我们通常习惯把Tokenizer Parser Expression树这个完整链条统称为解释器模式在C中的落地。4.2 什么时候可以跳过完整Parser很多实际业务场景里的规则根本不需要Parser。举个例子你要实现一个规则引擎规则从数据库读取已经在后台存成了结构化的JSON或XML。这种情况下你完全不需要解析文本直接根据JSON字段构建Expression对象树即可。比如if (json[type] greater_than) { auto lhs build(json[lhs]); auto rhs build(json[rhs]); return std::make_uniqueGreaterThanExpr(std::move(lhs), std::move(rhs)); }这仍然是解释器模式只是省掉了Tokenizer和Parser。所以判断你用的到底是不是解释器模式不要看有没有解析文本而要看是否用类表示每种语法规则是否通过递归调用来求值。4.3 反向理解为什么不直接用函数指针有人可能会问如果不考虑可读性我直接把每个运算符对应到std::functionint(int, int)再用一个通用的节点类型包装不行吗比如struct Node { std::functionint(Context) eval; };当然可以这叫函数对象或者命令模式的变体。它同样能实现递归求值代码量更小但会在可扩展性上付出代价当你需要给语法树增加打印表达式类型检查生成代码等新操作时基于std::function的设计必须在构造时把所有操作都塞进lambda难以优雅地扩展。而解释器模式配合Visitor模式的合体见第6节可以让新操作与语法结构解耦做到类稳定、操作开放。从工程角度如果你的语法规则极少、变化更少用std::function完全够用。解释器模式的价值在于规则种类多且组合复杂时每个规则一个类让代码更清晰、更容易测试。这也是设计模式应用的重要前提——不要为了模式而模式。5. 性能优化与内存管理的现实难题在C里谈论解释器模式绕不开两个现实问题解释执行的性能以及AST内存的分配与释放。5.1 解释执行为什么慢怎么优化interpret()是递归函数每到一个节点就做一次虚函数调用和递归分解。对比编译成机器码的原生逻辑解释执行的损耗主要在三处虚函数调用每个节点都要通过vtable跳转无法内联。也就是所谓的多态损失。频繁的小对象分配整颗AST里有大量NumberExpr、AddExpr对象每个都是独立的堆分配缓存不友好。递归调用深度表达式嵌套很深时栈压力大甚至会栈溢出。针对这些我在实际项目中常用几种优化手段第一对象池或内存池分配节点。因为AST生命周期明确通常在一个请求内可以在Parser构建时一次性从std::pmr::monotonic_buffer_resource分配所有节点然后整树统一释放。这样既省了malloc调用次数又提高了内存局部性。C17里memory_resource就是为这种场景准备的#include memory_resource char buffer[1 20]; // 1MB 内存池 std::pmr::monotonic_buffer_resource pool(buffer, sizeof(buffer));然后用std::pmr::polymorphic_allocator去定制unique_ptr的删除逻辑或者简单粗暴一点直接用池分配器创建节点。不过说实话对于大多数业务规则引擎不需要做到这一步先测测性能再说别急着优化。**第二缓存解释结果。**如果同一棵AST会被反复求值多次例如从数据库加载的一次规则被几千个请求共享可以为每个节点增加可选的缓存字段。但这要求表达式必须是纯的不能依赖会变化的外部变量。很多时候规则里引用的业务变量每次请求都不一样缓存就会失去意义。**第三套上字节码编译层。**如果每个表达式要执行几万次以上纯粹的树解释可能扛不住。这时可以把Expression树先编译成一组顺序指令例如一个vector里的简单操作码enum class Op { LoadConst, LoadVar, Add, Mul, Div, Sub }; struct Instruction { Op op; int arg; // 常数值或变量ID };一次遍历AST生成指令序列之后解释执行就变成遍历指令数组完全没有虚函数调用。这个思路相当于从树遍历编译器到了栈式虚拟机性能可以提升一个数量级。不过这就已经偏离解释器模式的经典范畴了属于它的进阶变体我建议你把基础版本跑通后再考虑。5.2 内存管理的三个铁律C里用解释器模式内存管理比Java、C#要操心得多。我的经验可以浓缩成三条用unique_ptr表达树的所有权任何地方不要出现裸new。析构自动递归释放异常发生时也不会有泄漏。如果节点需要被多条路径引用例如共享的常量节点考虑shared_ptr并用weak_ptr避免环。但大多数AST的引用关系是严格单父多子的根本不需要共享。Parser抛出异常时已经创建的子树必须能正常释放。因为子树中每个节点是用unique_ptr保存的局部变量在异常栈展开时自动析构不会泄漏。这一点是用裸指针地雷区的最大优势。曾经我在一个项目中看到用裸指针写AST解析中途遇到非法字符直接throw结果一整棵半成品树全部泄漏内存监控曲线直线上升。后来改成unique_ptr问题消失。如果你要写解释器模式从第一行代码开始就用智能指针定义接口。6. 与Visitor模式的合体如何避免类爆炸经典解释器模式有一个非常难受的扩展问题如果想对语法树增加一个打印为逆波兰表达式或类型检查的操作按原始设计就得在每个表达式类里都加一个方法。今天加一个print()明天加一个check()C的类会越来越多方法接口越来越臃肿这被称作类爆炸。Visitor模式就是专门解决在稳定的对象结构上增加新操作这个问题的。把它和解释器模式合体AST的节点类保持稳定新的操作全部下沉到Visitor子类中。6.1 改造后的结构先在Expression基类里加一个accept方法class ExpressionVisitor; class Expression { public: virtual ~Expression() default; virtual int interpret(Context ctx) const 0; virtual void accept(ExpressionVisitor visitor) const 0; };NumberExpr::accept里调visitor.visitNumber(*this)AddExpr::accept里调visitor.visitAdd(*this)。再定义统一的访问者接口class ExpressionVisitor { public: virtual void visitNumber(const NumberExpr expr) 0; virtual void visitAdd(const AddExpr expr) 0; virtual void visitMul(const MulExpr expr) 0; // ... 每个具体类对应一个visit函数 };这其实是一种被称为双重分派的技巧第一次分派通过accept的虚函数找到具体节点类型第二次分派通过visitXxx的重载找到对应的访问方法。6.2 新增打印为源码字符串操作想给AST加一个把表达式还原为字符串的打印功能不再需要改表达式类只需要写一个新的Visitorclass PrintVisitor : public ExpressionVisitor { public: std::string result; void visitNumber(const NumberExpr expr) override { result std::to_string(expr.getValue()); } void visitAdd(const AddExpr expr) override { result (; expr.getLhs()-accept(*this); result ; expr.getRhs()-accept(*this); result ); } // visitMul 类似 };注意这里AddExpr需要暴露getLhs()、getRhs()访问接口或者让Visitor可以直接访问子节点。这一步在C里需要把Visitor设为友元或者提供公开的getter。6.3 什么时候应该合体Visitor模式在C里实现起来会让代码量大增——每个节点类都要加一个accept每个Visitor都要实现所有visit函数哪怕某些操作对某些节点毫无意义也得写个空函数。我的实践经验是如果AST的节点种类稳定比如就十几类且你预计会不断有新的跨节点操作打印、求值、类型检查、中间代码生成那就要提前引入Visitor。反之如果只有求值一个操作那裸的interpret放到每个类里反而更简洁。很多现代C项目还会用std::variant替代继承树配合std::visit来实现静态多态访问者那是一种完全不同的风格效率更高但对读者的模式识别要求也更高。这里不展开只提醒一句解释器模式Visitor的经典OOP方案在维护性和扩展性上依然有它的独特价值特别适合团队里C功底深浅不一的协作场景。7. 实际项目中的经验总结与避坑清单最后我想分享几个真实项目里踩过的坑和沉淀下来的经验希望能帮你少走弯路。7.1 警惕表达式安全性与异常处理解释器模式输入的表达式字符串来自哪里如果是用户输入、运营配置那它就是不可信的输入。你至少要处理除零错误interpret执行到DivExpr时要判断除数是否为零不能裸除。变量未定义错误VariableExpr在上下文中找不到变量时要有明确的异常类型。解析器拒绝恶意长表达式parseFactor遇到连续的深度嵌套括号时递归深度可能暴涨。我见过一个配置错误的括号把调用栈打爆进程直接崩掉。解决方式是限制AST的深度或节点总数比如超过5000个节点就抛异常或者在Parser里采用迭代替代递归。class DivExpr : public Expression { public: // ... int interpret(Context ctx) const override { int divisor rhs_-interpret(ctx); if (divisor 0) { throw std::runtime_error(division by zero); } return lhs_-interpret(ctx) / divisor; } };别小看这些细节规则引擎上线后被错误规则搞挂的事件我在不同公司至少见过三次。7.2 别把解释器模式用在需要高性能的核心路径如果每个请求都要执行规则而规则引擎在请求延迟里占比超过20%你就要重新审视选型了。此时要么把表达式预编译成更接近机器码的形式字节码或LLVM JIT要么把频繁执行的规则直接转成C代码动态编译。解释器模式适用于低频到中频的规则解释而不是每秒百万次的高频决策。举个具体数字我做过一个促销引擎每个订单会评估几百条规则每条规则平均几十个节点纯解释执行大约5微秒到20微秒这在实际业务里完全可接受。但如果把它放在一个每笔交易都要实时判断、延迟要求毫秒级的场景就必须考虑编译路线。7.3 测试策略每个表达式类一个诚实的单元测试解释器模式的每个节点类逻辑都很简单但这不代表不用测试。恰恰因为树是递归组合的组合层级越深越容易产生微妙的错误。我给团队定的规矩是每个XxxExpr::interpret至少3个用例常规值、边界值、异常条件如除零、未知变量。对Parser做表驱动测试给一组表达式字符串 - 期望AST描述或期望值的用例表。针对递归深度做压力测试解析一个500层嵌套的表达式确认不会栈溢出或者优雅地报错。实话实说手写Parser解释器模式的代码量并不小如果测试跟不上后续扩展时心里会非常没底。7.4 要留后路从解释器模式迁移到编译方案我参与过一个比较极致的项目最早用解释器模式实现了一套风控规则DSL后来业务量暴涨解释执行的性能成了瓶颈。我们做了一个平滑迁移的过渡方案保留原始的Expression接口作为抽象语法树的别名增加一个buildInstructions(const Expression) - std::vectorInstruction的函数完成从树到指令序列的编译运行时先尝试用指令解释器找不到对应指令片段的表达式再回退到树解释。这个双轨方案让我们在生产环境灰度切换没有一次性推翻重写。这件事给我的启发是好的架构应当允许你从解释器模式出发一步步演进到编译器模式。而解释器模式的价值就在于它先把语法和求值这两个关注点理清了后续的编译优化才有清晰的下手点。写在最后的一个实用建议如果你要给自己的项目引入解释器模式我建议你先从一个小而完整的案例练手就是上面那样一个四则运算引擎不要一上来就奔着完整DSL去。等你能熟练地把分词、解析、解释三个阶段拆开再考虑加入变量赋值、逻辑运算符、函数调用这些扩展。这是我在多个项目里验证过最平滑的学习路径。关于模式本身我一直觉得它像一把手术刀——切得好能精准解决问题切不好就成了过度设计的代名词。关键在于你能否识别出规则频繁变化这个核心特征。我见过太多人写了个几十行的规则解析器就敢说自己用了解释器模式也见过不少人在配置驱动需求摆在面前时却还在用if-else硬扛。希望这篇文章能帮你在正确的时机拿起这把刀。
返回列表