☰
C++解释器模式工程化变体:从AST到constexpr与表驱动的实战指南
2026/10/6 13:29:46 网站建设 项目流程

说实话,大部分设计模式的书里讲解释器模式,都是拿一个“四则运算计算器”当例子,画一张满是抽象类的类图,然后大家在IDE里敲一遍,跑通就删了。但只要你真正在C++里做过表达式解析、规则引擎、DSL设计,或者哪怕只是想给某个配置系统加一套简单的脚本语法,你就会发现教科书里的那套写法根本不够用。类爆炸、访问者横跳、性能稀烂,任何一个问题都够你喝一壶的。

这篇我就从实际工程的角度,聊聊我在C++里折腾解释器模式时沉淀下来的一套“变体”打法。不是什么标准答案,而是几种在不同场景下被验证过的、能真正落地的结构和取舍。文章会包含完整可跑的代码、选型对比,以及一堆代码里看不出来的坑。

1. 别急着套教科书,解释器模式在C++里的真实姿态

1.1 教科书里的解释器模式为什么不够用

先回忆一下GoF书里的经典结构。给每条文法规则定义一个类,比如NumberExpr、AddExpr、SubtractExpr,继承自抽象的Expression,然后为每个类实现一个interpret(Context&)方法。这样文法规则和代码类一一对应,逻辑上非常清晰。

但放到真实工程里,这个方案几乎撑不住。原因有四个:

第一,类数量爆炸。一个只有加减乘除和括号的表达式就得四五个类,你要是做个稍微像样的规则引擎,支持IF、AND、OR、NOT、字符串函数、日期函数,类轻松过百。代码里全是AddExpr、AndExpr、GreaterThanExpr这样名字毫无信息量的类,维护起来非常痛苦。

第二,职责割裂。标准做法通常把“遍历结构”和“具体求值”分成两套东西:一套是节点类,另一套是访问者。这在Java里用双分派玩得很漂亮,但在C++里想实现完整的Visitor,要么用一大堆visit重载,要么用std::variant和std::visit硬扭,模板报错能把人看哭。

第三,性能差。经典实现几乎每个节点都是一次虚函数调用,每个数据都是一次堆分配。你要是解析一个几千行的表达式,或者一条请求过来就要parse并execute,这种结构的开销会很感人。

第四,扩展方向单一。教科书假设你只有“加一种新表达式”这一个扩展维度。但实际场景里,你既要加语法,又要加变量来源,又要加函数库,还要加不同的执行模式(实时解释、预编译、转成别的语言)。一条轴线根本不够用。

1.2 “变体”到底在变什么

所以我在实际项目里用的解释器模式,和教科书里的同名不同形。核心思路是有三条,后面所有变体都围着它们转。

第一条,把“解释”和“结构”合体。不要单独维护一套解释器逻辑去遍历AST,而是让每个节点自己知道自己怎么算。这样增删语法只需要动节点类,不用动遍历框架。

第二条,用组合、函数对象、模板等手段替代一部分继承体系。能少建类就少建类,能用std::function解决的问题就不要新建一个子类。

第三条,根据执行形态调整存储和调度方式。解释器模式本质上是对“文法”的“解释执行”,但“解释执行”不一定非要用类层次实现。你可以用表驱动,可以用编译期模板,甚至可以用一组回调函数。

下面几种变体,每一种都是基于这三个思路的某一种排列组合。我会把它们的适用场景、代码骨架、和实际工程中容易踩的坑分别讲清楚。

2. 组合式求值:最实用的解释器模式变体

2.1 把“解释”拆进节点里:Composite + Interpreter

先看我最推荐的第一种变体,也是我目前在新项目里优先采用的方案:用组合模式承载AST结构,用虚函数承担解释执行,但抛弃独立的Context对象,改为用求值参数直传。

先看节点接口的大致形态:

class Expr { public: virtual ~Expr() = default; virtual double eval(const EvalContext& ctx) const = 0; virtual std::string to_string() const = 0; }; class NumberExpr : public Expr { public: explicit NumberExpr(double v) : value_(v) {} double eval(const EvalContext&) const override { return value_; } std::string to_string() const override { return std::to_string(value_); } private: double value_; }; class BinaryExpr : public Expr { public: BinaryExpr(std::unique_ptr<Expr> l, std::unique_ptr<Expr> r, char op) : lhs_(std::move(l)), rhs_(std::move(r)), op_(op) {} double eval(const EvalContext& ctx) const override { double l = lhs_->eval(ctx); double r = rhs_->eval(ctx); switch (op_) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return l / r; default: throw std::runtime_error("unknown operator"); } } std::string to_string() const override { return "(" + lhs_->to_string() + op_ + rhs_->to_string() + ")"; } private: std::unique_ptr<Expr> lhs_, rhs_; char op_; };

这段代码和教科书写法最大的区别在eval的签名。我没有用一个可变的、贯穿整个解释过程的Context对象,而是直接把求值上下文作为参数传入。这样设计的好处有几点:

  • 天然支持嵌套和重入。你不需要担心解释器的某个内部状态被上一次执行污染。
  • 让eval很容易做成const,意味着同一个AST可以被多个线程安全地并行求值,只要EvalContext不变。对服务器程序来说,这一点很值钱。
  • 在需要“部分求值”的场景里非常灵活,比如传一个带默认值的变量环境,只覆盖少量字段。

整个结构实际上就是“Composite模式 + Interpreter模式”的混合体。AST的组装方式完全照搬组合模式,而“解释执行”则放在每个节点的eval实现里。这就是我所说的“变体”:不是换了一个新东西,而是把两个经典模式的优点缝合起来。

2.2 带错误处理与变量表的最小实现

光有数字和二元运算还不够,实际用肯定要有变量、比较运算、逻辑运算。我给这套结构补一个完整的最小实现,包含变量查找和错误抛出机制:

class VarRefExpr : public Expr { public: explicit VarRefExpr(std::string name) : name_(std::move(name)) {} double eval(const EvalContext& ctx) const override { if (auto it = ctx.vars.find(name_); it != ctx.vars.end()) { return it->second; } throw std::runtime_error("undefined variable: " + name_); } std::string to_string() const override { return name_; } private: std::string name_; }; class LogicalExpr : public Expr { public: LogicalExpr(std::unique_ptr<Expr> l, std::unique_ptr<Expr> r, TokenKind op) : lhs_(std::move(l)), rhs_(std::move(r)), op_(op) {} double eval(const EvalContext& ctx) const override { double l = lhs_->eval(ctx); if (op_ == TokenKind::And && l == 0.0) return 0.0; // 短路 if (op_ == TokenKind::Or && l != 0.0) return 1.0; // 短路 double r = rhs_->eval(ctx); switch (op_) { case TokenKind::And: return (l != 0.0 && r != 0.0) ? 1.0 : 0.0; case TokenKind::Or: return (l != 0.0 || r != 0.0) ? 1.0 : 0.0; default: throw std::runtime_error("unknown logical op"); } } std::string to_string() const override { return "(" + lhs_->to_string() + " " + op_name(op_) + " " + rhs_->to_string() + ")"; } private: std::unique_ptr<Expr> lhs_, rhs_; TokenKind op_; };

很多人在这个阶段容易犯一个错误:在LogicalExpr里不实现短路逻辑,而是先把左右两边都算出来再判断。这在语法上没问题,但语义上和常规语言的短路逻辑就不一致了。比如x != 0 && (10 / x) > 2,如果x是0,先算出10 / x会直接抛异常,整个表达式挂掉,而正确的解释器应该返回false。

所以我的建议是:当解释器语言包含逻辑运算时,短路逻辑不是优化,而是语义的一部分,必须一开始就实现。

变量表这里用了简单的std::unordered_map<std::string, double>。真实项目里也可能是std::any或者std::variant,这会牵扯到类型系统的设计,我在后面第7章专门讲。

这一变体的适用场景非常广:规则引擎、告警判断、计算字段、小型脚本解释器,甚至一些在线业务里动态拼查询条件,都可以用这套骨架去扩展。你只需要持续新增节点类即可,框架本身基本不用动。

3. 函数式变体:用std::function和lambda替代类爆炸

3.1 从多态类到函数对象的映射

第二种变体适合那种“语法类型多但每个类型的逻辑都很简单”的场景。比如你有一个事件处理引擎,支持on("click", ...)、on("timer", ...)、on("data", ...),每种事件的语义就是触发表中的某个处理器。对这种场景还建一堆OnExpr、EventExpr子类,就显得有些笨重了。

这时可以换一个思路:不把“解释器”建模成一组类,而是建模成一组可调用的函数对象。用std::function做分发中心:

struct Rule { std::string name; std::function<bool(const EvalContext&)> predicate; std::function<void(EvalContext&, const Json&)> action; }; class ScriptEngine { public: void register_rule(const std::string& name, std::function<bool(const EvalContext&)> pred, std::function<void(EvalContext&, const Json&)> act) { rules_.push_back(Rule{name, std::move(pred), std::move(act)}); } void execute(const Json& event) { for (auto& rule : rules_) { EvalContext ctx(event); if (rule.predicate(ctx)) { rule.action(ctx, event); } } } private: std::vector<Rule> rules_; };

这种思路的本质是:解释器的“上下文”变成了一个可以被闭包捕获的对象,而“文法规则”变成了逻辑闭包。它没有AST,没有多态节点,但解释执行的过程依然是“读规则-解释规则-执行动作”的完整流程。从模式角度讲,它仍然属于解释器模式的一种变体,只是把“解释”这一步下沉到了注册阶段。

用这种方案的好处是,新增规则经常连一行类定义都不用。比如用户要“当温度大于50度时报警”,直接写:

engine.register_rule("high_temperature_alert", [](const EvalContext& ctx) { return ctx.get("temp").as<double>() > 50.0; }, [](EvalContext& ctx, const Json& e) { send_alert("temp_high", ctx.get("zone")); });

这种写法非常符合直觉,比维护一个GreaterThanExpr类加一套外部解释器清爽得多。

3.2 捕获式解析器与作用域链

不过用std::function做解释器,有一个特别容易被忽略的问题:闭包之间的作用域和生命周期。

先说生命周期。闭包会捕获外部变量,如果捕获的是引用,而引用对象在注册后被销毁,那等到执行规则的时候就会悬空。我自己的经验是:规则引擎里的闭包最好统一按值捕获,或者用std::shared_ptr捕获共享数据。按引用捕获只适合生命周期明确、并且规则引擎和宿主对象绑定在同一个作用域内的场景。

再说作用域链。规则不是孤立的,有些平台有“全局规则”和“场景规则”,场景规则能影响全局规则,局部配置要能覆盖全局配置。如果解释器用的是类层次结构,作用域链可以做成EvalContext里的一个parent指针;但如果用的是闭包,作用域链就变成了几个std::function之间的联动,处理起来略显别扭。

我的折中方案是:把作用域链仍然放在EvalContext里,闭包只负责“从上下文取数据”,不做作用域管理。规则引擎本身维护一个std::vector<std::shared_ptr<EvalContext>>作为作用域栈,闭包通过ctx.get("temp")触发链式查找。这样闭包和类层次两种方案共享同一个上下文结构,迁移成本最低。

这一变体的定位是“以简洁换结构,以灵活换严格”。当你发现自己的解释器规则多到需要配置化、可视化、甚至塞给非技术人员写的时候,闭包方案就不够用了。那时候就得回头把解析器、AST、求值这套东西补齐。反过来,如果你的规则就二三十条,闭包方案是最省事的,别犹豫,直接上。

4. 编译期解释器:constexpr能走多远

4.1 用constexpr算表达式的基本玩法

第三种变体比较偏向奇技淫巧,但某些场景非常有用:用编译期求值代替运行期解释。C++17之后constexpr能做的事情越来越多,C++20又加了consteval,这让“编译期解释器”成为一个完全可行的方案。

考虑一个场景:某个算法模块有一堆参数,参数之间有若干依赖关系,比如rate = base * 1.2 + fixed。这些表达式是固定的、不会变的,却在每次运行都要解释一遍,纯属浪费。这时可以把它挪到编译期去算。

写一个constexpr计算器其实不复杂,关键在于用模板递归模拟循环:

template <size_t N> struct ConstExprParser { const char* str; size_t pos = 0; constexpr ConstExprParser(const char* s) : str(s) {} constexpr double parse_expression() { double lhs = parse_term(); while (peek() == '+' || peek() == '-') { char op = next(); double rhs = parse_term(); lhs = (op == '+') ? lhs + rhs : lhs - rhs; } return lhs; } constexpr double parse_term() { double lhs = parse_factor(); while (peek() == '*' || peek() == '/') { char op = next(); double rhs = parse_factor(); lhs = (op == '*') ? lhs * rhs : lhs / rhs; } return lhs; } constexpr double parse_factor() { if (peek() == '(') { next(); double v = parse_expression(); next(); // ')' return v; } return parse_number(); } constexpr double parse_number() { double v = 0; while (pos < sizeof(str) && str[pos] >= '0' && str[pos] <= '9') { v = v * 10 + (str[pos] - '0'); ++pos; } return v; } constexpr char peek() const { return str[pos]; } constexpr char next() { return str[pos++]; } }; template <size_t N> constexpr double compile_time_eval(const char (&expr)[N]) { ConstExprParser<N> p(expr); return p.parse_expression(); } int main() { constexpr double v = compile_time_eval("(1+2)*3+4"); static_assert(v == 13.0); }

这段代码是我实际用过的方案的精简版,支持加减乘除和括号,在编译期就能算出表达式结果。可以看到它没有std::string,没有堆分配,所有状态都是constexpr友好的标量。

这种“编译期解释器变体”解决的核心痛点是:把“解释”这个行为从运行期挪开,用编译器完成解释,运行时拿到的是已经算好的常量。代价是表达式本身必须是编译期常量,语法支持范围也比较狭窄。但它对嵌入式、算法参数域这类场景很香。

4.2 模板递归与参数包传递的边界

把constexpr解释器往复杂做,有几个边界你一定会碰到的。

第一个是嵌套深度。编译器对constexpr递归深度有限制,默认值在不同编译器上不一样,-fconstexpr-depth、/constexpr:depth可以调。如果表达式嵌套特别深,比如几百层括号,很容易触发编译错误。这里没有特别优雅的解法,要么限制规则,要么放弃纯constexpr方案。

第二个是解析对象。上面的例子用的是C字符串,没处理数字的小数部分、指数、负数。你要是想支持浮点字面量,得用constexpr版本的浮点解析函数,C++标准库的std::from_chars可不是constexpr的,得自己写。

第三个是类型系统。到constexpr里,std::string基本不可用(C++20虽然部分放宽,但依然很有限),std::vector也不能用。所以只要你的表达式语法涉及字符串拼接、字符处理、动态数组,纯constexpr解释器基本就玩不转了。

我的建议是:编译期解释器适合做“字典级参数引擎”,不适合做“通用语言”。它最好的定位是替代那些非常稳定的、被频繁调用的配置计算公式。我做过一个信号处理模块,里面几十个滤波器系数依赖环境参数,之前是每次启动解析一遍,后来改成constexpr求值,启动时零开销,静态断言还能在编译期把明显越界、除零之类的配置错误暴露出来,省了很多运行时debug时间。

5. 表驱动变体:当解释器遇上操作码与DSL命令

5.1 用查找表替换模式匹配

第四种变体非常工程化:表驱动解释器。它更适合这类场合——你有一套固定的“命令集”或“操作码”,每个操作对应的执行逻辑很简单,但他们组合起来形态各异。

比如一个配置文件解析器,支持set key value、inc key、dec key、if...then这样几条命令。如果用AST,每个命令都要建类,成本不低;用std::function虽然好一点,但命令多了,每个注册闭包的写法又重复。表驱动方案则把所有注册信息集中到一张表里,一个命令一行数据:

enum class OpCode { Set, Inc, Dec, If, EndIf, Stop }; struct Instruction { OpCode op; std::string arg1; double arg2 = 0.0; }; class TableDrivenInterpreter { public: using Handler = std::function<void(EvalContext&, const Instruction&)>; void register_handler(OpCode op, Handler h, const char* name) { handlers_[static_cast<size_t>(op)] = std::move(h); } void execute(const std::vector<Instruction>& program) const { EvalContext ctx; size_t pc = 0; while (pc < program.size()) { const auto& ins = program[pc]; auto& handler = handlers_[static_cast<size_t>(ins.op)]; handler(ctx, ins); ++pc; } } private: std::array<Handler, 8> handlers_; };

表驱动方案的核心价值是“可配置性”。解释器的行为由表和注册函数完全决定,你可以把注册过程做成一个register_default_handlers()函数,里面集中体现DSL的全部语义。要加命令,加一个枚举值,写一个register_handler的调用即可,和AST方案的“新增节点类+适配框架”相比,变更成本低很多。

另一个隐藏好处是易于内嵌脚本化。很多系统支持把操作序列导出成文本再重新加载,表驱动方案的指令结构本身就接近“行-字段”格式,序列化和反序列化非常直接,甚至连Instruction结构都省了,直接用std::vector<std::variant<...>>存储也行。

5.2 命令分发与状态机的结合

表驱动解释器再往上走一步,就和状态机结合了。某些DSL本质上是一段带状态的流程。比如自动化测试脚本的“等待元素出现-点击-校验文本”这种命令序列,每个命令执行完要明确切换到下一个状态,还可能分支跳转。

在这类场景,我给Instruction加一个next_pc字段,支持跳转和循环:

struct Instruction { OpCode op; std::string arg1; double arg2 = 0.0; int jump_target = -1; // -1 表示顺序执行 };

在执行循环时,找到命令最方便的是一张std::unordered_map<OpCode, Handler>,但要注意不稳定性和代码体积。如果你追求性能,命令数量又少(比如少于50),用std::array<Handler, N>直接索引是最快的方式。

表驱动解释器的最大风险在于“过度设计”。有段时间我做规则引擎,一开始只有十来条规则,结果为了“通用”,把所有规则都入表,又加了一个配置文件格式,又支持表达式字符串。最后那套东西比直接用AST写复杂十倍,性能还更差。后来我把“简单命令用表驱动,复杂表达式用AST”混着用,整个子系统才活过来。

这就是解释器模式变体的核心精神:不存在一个最完美的结构,只有恰好契合当前问题的结构。表驱动适合的是“命令多、语法简单、形态固定”的DSL,不是所有场景的万能钥匙。

6. 完整实操:从0写一个可用的表达式求值器

前面的几种变体各讲了一部分,这里我把它们整合到一个完整的实操里,带大家从零把一个支持变量、比较运算、逻辑运算、括号的表达式求值器写出来。代码会用C++17,结构采用第2章的组合式求值框架,并集成第3章的变量表思路。

6.1 词法与语法设计

先定义Token。这里我采用最简的enum + 值联合方式:

enum class TokenType { Number, Ident, Plus, Minus, Star, Slash, LParen, RParen, Eq, Ne, Gt, Lt, Ge, Le, And, Or, Not, End }; struct Token { TokenType type; std::string text; double value = 0.0; };

词法分析器按住一个字符一个字符读,数字和变量名分别走两套状态。变量名支持字母、数字、下划线,但不能以数字开头。这个阶段不用追求正则表达式引擎,手写一个状态机足够了,代码量也不大。

语法分析我用递归下降,这是手工解析器里最有直觉感的一种方式,表达式的优先级通过函数调用的层级自然体现:

class Parser { public: explicit Parser(const std::vector<Token>& tokens) : tokens_(tokens), pos_(0) {} std::unique_ptr<Expr> parse() { auto e = parse_or(); expect(TokenType::End); return e; } private: std::unique_ptr<Expr> parse_or() { auto lhs = parse_and(); while (match(TokenType::Or)) { auto rhs = parse_and(); lhs = std::make_unique<LogicalExpr>(std::move(lhs), std::move(rhs), TokenType::Or); } return lhs; } std::unique_ptr<Expr> parse_and() { auto lhs = parse_comparison(); while (match(TokenType::And)) { auto rhs = parse_comparison(); lhs = std::make_unique<LogicalExpr>(std::move(lhs), std::move(rhs), TokenType::And); } return lhs; } std::unique_ptr<Expr> parse_comparison() { auto lhs = parse_additive(); while (peek().type == TokenType::Eq || peek().type == TokenType::Ne || peek().type == TokenType::Gt || peek().type == TokenType::Lt || peek().type == TokenType::Ge || peek().type == TokenType::Le) { TokenType op = next().type; auto rhs = parse_additive(); lhs = std::make_unique<ComparisonExpr>(std::move(lhs), std::move(rhs), op); } return lhs; } std::unique_ptr<Expr> parse_additive() { /* 类似 */ } std::unique_ptr<Expr> parse_multiplicative() { /* 类似 */ } std::unique_ptr<Expr> parse_unary() { /* 类似 */ } std::unique_ptr<Expr> parse_primary() { /* 变量/数字/括号/Not */ } };

这里有一个很容易引发bug的点:比较运算的错误处理。ComparisonExpr::eval应当对布尔结果转换成1.0/0.0,我在实际项目里因为忘了这一层转换,导致true + 1这种表达式被静默地当成true处理,排查了很久。所以所有比较、逻辑节点,返回值统一是double,1.0代表真,0.0代表假,保持整个求值器不引入额外的bool类型。

6.2 求值核心与性能优化

求值核心用的是第2章的eval虚函数。把所有节点类写完后,跑一个简单用例:

int main() { Lexer lexer("(price < 100.0 || discount == 0.2) && in_stock == 1.0"); auto tokens = lexer.tokenize(); Parser parser(tokens); auto ast = parser.parse(); EvalContext ctx; ctx.vars["price"] = 80.0; ctx.vars["discount"] = 0.15; ctx.vars["in_stock"] = 1.0; bool result = ast->eval(ctx) != 0.0; // result == true }

到这里功能是能跑了,但直接这么用在生产环境,我心里有几个优化点必须提醒。

第一个优化点是类型分派。EvalContext::vars如果只有一种double类型,那就用unordered_map<string, double>,访问开销还算可控。如果要支持字符串、数组、对象,建议用variant<double, string, bool, vector<double>>代替std::any,因为std::any在每次取值时都要做类型检查,有额外开销,而variant的类型检查是编译期的。

第二个优化点是消除虚函数调用。当评估链很深,比如一次求值有几万个节点时,虚函数开销会显著。但我不建议把架构改复杂去换来这一点性能,更先是先把EvalContext的取值做成内联快的路径,再考虑switch分发。实际上我在优化一个3000行的表达式时,把eval改成inline后性能提升就足够了,并没有动结构。

第三个优化点是AST节点的内存布局。一次解析几百个节点时,std::make_unique每个节点有两次堆分配:节点对象本身和它的子节点。对几十上百个节点来说无所谓,但对上万的节点还是有影响的。那时可以用std::pmr池化分配器,把节点先分配在一个monotonic_buffer_resource里,然后整体释放。这种优化对解释器模式来说收益很明显,但会让代码变得复杂,建议等基准测试证明确实是瓶颈再说。

6.3 测试用例与边界情况

写解释器最容易翻车的就是边界情况。我整理了一份自测清单,每次改动完都跑一遍,比写单元测试文档更直接有效:

  • 运算符优先级:1 + 2 * 3 == 7.0,(1 + 2) * 3 == 9.0
  • 短路逻辑:0.0 && (1/0) == ?,应该安全返回false
  • 未定义变量:undefined_var + 1必须有明确报错
  • 比较链:1 < 2 < 3在多数C风格语言里不成立(因为1<2结果是1,1<3成立),但有些DSL里需要抛警告,避免语义混淆
  • 除零:必须抛异常,不能产生inf
  • 解析错误定位:1 + * 2报错时要给出token下标,方便定位

边界情况的处理不该放在最后,而是在设计阶段就定好。比如布尔值,我一开始就统一成double,后续字符串类型进来时也保持这个约定,不会出现“这个比较返回bool,那个比较返回double”的双轨制。

7. 常见问题与调试实录

7.1 内存与生命周期问题

解释器模式的C++实现里,最经典的坑就是AST节点的所属关系。我在2.1用了unique_ptr,好处是父节点销毁时子节点自动释放,坏处是你不能随便把一个节点“借”给两个地方,比如两个规则共享同一个子表达式。

如果你需要共享子表达式,最简单的做法是用shared_ptr代替unique_ptr。但要注意,EvalContext里存的是数据,AST里存的是结构,别把数据指针和AST节点混在一起。我见过有人图省事把double的指针挂在EvalContext上,然后闭包里捕获这个指针,结果规则重载时指针悬空,程序随机崩,这种问题几乎无法定位。

如果你连续遇到这类崩溃,我建议在解释器里加一个enable_shared_from_this的管理基类,或者在调试期给Expr基类加一个虚的dbg_dump()方法,把AST树打出来看引用关系。光靠屏幕输出错误信息往往不够,可视化AST是一个很有效的排查手段。

7.2 类型与错误传播问题

第二个高频问题集中在类型上。当变量表从double扩展到支持字符串和数组后,eval的返回类型就变得尴尬:返回double显然不够,返回std::any又会埋下隐患。

我的经验是:在解释器语言的内部,统一用std::variant<double, std::string, bool>作为数值表示。所有运算都先从这个variant取出具体类型,再做操作。代码会稍微啰嗦,但换来的是显式的类型分支。错误处理方面,运算时类型不匹配就立刻抛异常,不要在解释器内部做隐式转换。

错误传播这里有一个细节:eval调用链很深,如果底层抛一个string类型的异常,外层捕捉不到类型信息,很难定位是哪个子表达式出了问题。建议所有异常都用带错误码的自定义异常类,并且把表达式打印进去。比如:

class ExprError : public std::runtime_error { public: ExprError(std::string msg, size_t token_index) : runtime_error(std::move(msg)), token_index_(token_index) {} size_t token_index() const { return token_index_; } private: size_t token_index_; };

这样外层捕获后能直接报出token下标,配合源码片段做提示。这一步体验提升非常大,很多“解释器能用但难用”的系统,差距往往就在这里。

7.3 递归深度与栈溢出

第三个坑纯粹是工程层面的。递归下降解析器很吃栈,AST求值也是递归的。你把它用在用户可控的表达式上时,必须防范递归过深导致进程崩溃。我遇到过有人传一个一万层括号嵌套的表达式,直接把整个服务打到栈溢出。

处理办法有两个。

第一个是解析阶段限制嵌套深度,比如在parse_primary里维护一个depth计数,超过512层直接抛异常。这个办法简单有效,代价是合法表达式太深时会被拒。

第二个是求值阶段把递归改成显式栈遍历,但AST本来就是树形结构,改显式栈工程量不小。所以我更推荐第一个办法,在实际项目中,表达式的嵌套深度极少超过100层,限制512层是安全且够用的。

还有一个小问题经常被忽略:解析器本身对无效输入的崩溃保护。你可能会对“1+*2”这种输入做测试,但真正的崩溃点往往出现在“token列表里根本没有结束符”、“数字后紧跟字母”这类半合法输入。所以词法分析器在生成token列表之后,建议做一遍合法性校验,确认token首尾匹配、括号计数为0等基础约束,再交给解析器,能把很多运行时崩溃提前拦下来。

8. 变体选型与最终建议

8.1 各变体的适用场景对比

把前文几种变体放一张表里对照,会看得更清楚:

变体类型核心机制典型场景优势劣势我的推荐度
组合式求值多态节点 + eval虚函数规则引擎、计算字段、通用表达式结构清晰、扩展方便、可加短路逻辑类多、递归深、内存开销大最推荐
函数式std::function + lambda事件脚本、少量规则代码少、灵活、直观难以配置化、作用域管理难规则少时首推
编译期constexpr + 模板参数计算、固定公式零运行时开销、编译期可校验语法范围窄、编译慢特定场景用
表驱动操作码 + Handler数组命令脚本、状态机DSL易序列化、集中管理复杂语法表达力不足命令型脚本推荐

选型时不要按照“哪个模式听起来高级”来选,而是问自己三个问题。

第一个问题:语法树会不会变?如果你的表达式结构很固定,比如永远是“比较-逻辑-动作”,用表驱动就最合适。如果语法会不断生长,组合式求值更稳。

第二个问题:执行频率高不高?如果一条表达式在每次请求里都要执行,追求性能你就应该考虑把表达式预编译成某种紧凑结构,或者直接用编译期解释器。如果频率低,AST也无妨。

第三个问题:谁来写规则?如果写规则的是程序员,函数式变体最舒服。如果写规则的是运营人员,那么你必须提供配置化界面和DSL,表驱动和组合式求值的组合会更合适。

8.2 一些个人经验总结

最后分享几点我在多次折腾解释器模式变体后的体会。

第一,解释器模式在C++里的价值不在“给文法建类”,而在“把领域语言和宿主语言解耦”。一本书教你建多少类不重要,重要的是让领域逻辑能以一门小语言的形式存在,让那块逻辑可以被测试、被复用、被配置化。为此你可以用类、闭包、模板、表,什么顺手用什么。

第二,尽量不要自己造通用表达式语言。你实际上只需要满足当前业务的最小子集。支持加减乘除、逻辑、变量、三五个函数往往就够了。随意加功能会导致解释器变成维护负担。我宁可预留扩展点,也不一次性支持几十个函数,因为每多一个函数就多一对测试和维护成本。

第三,调试工具比解析器本身更重要。没有一个好用的to_string()和错误定位,任何解释器都会变成黑盒。我每次写解释器,第一件事是把Expr的to_string()实现好,第二件事是做一版“导出AST为缩进文本”的工具。这两件事加起来花不了半天,但在后续排错中能节约数天时间。

我自己把第2章的组合式求值用在了一个线上规则系统里,支撑了上千条运行时规则,单次求值基本都在微秒级别;把第3章的函数式方案用在了一个小工具上,维护成本接近零;把第4章的constexpr方案用在了一个嵌入式模块里,编译期就把公式校验完了。这三种方案各自都用得舒心。希望这篇经验对你有用,也欢迎你们在构建自己的解释器时,放下“标准模式”的包袱,做出真正适配自己工程的变体。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询