ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++解释器模式实战:从AST结构到字节码虚拟机的五种变体选型

C++解释器模式实战:从AST结构到字节码虚拟机的五种变体选型

做过 C++ 解释器的朋友大概都有这种感觉:GoF 书里那张 Expression / TerminalExpression / NonterminalExpression 的类图,在真实工程里用起来总有点“书生气”。我这些年写过不同项目的 DSL 解释器,有的是做配置规则校验,有的是做页面可拖拽公式,有的是做报表引擎里的指标计算,最后基本得出一个结论:解释器模式在 C++ 里不是一个固定写法,而是一组相互竞争的“变体”。选型对了,后续维护能省掉很多麻烦;选型错了,就是一场类和虚函数的灾难。这篇文章想把 C++ 里的解释器模式常见变体摊开讲清楚:经典继承体系、std::variant + std::visit、运算符注册表 + 函数对象、表达式模板,以及从树解释器演化出来的字节码虚拟机。每种变体我都会给可运行示例、适用场景、优缺点和维护要点,适合正在纠结“到底该用哪种解释器”的 C++ 开发同学参考。

1. 为什么解释器模式在 C++ 里有这么多形态

1.1 它到底想解决什么问题

解释器模式要解决的核心问题很朴素:把一段“文本形式的小语言”变成可执行的计算过程。比如用户在配置面板里输入price * quantity - discount,程序不能直接编译这段字符串,但可以把它解析成一棵语法树,然后让每个节点自己“解释”自己,最终算出结果。这就是解释器模式的核心含义:为某个语法定义一套对象化表示,再用这套对象去解释句子。

在实际工程里,这些小语言无处不在。报表系统里的指标公式、规则引擎里的条件表达式、权限系统里的策略判断,甚至工业软件里的单位换算脚本,都是典型的解释器模式应用场景。和通用编程语言解释器不同的是,业务 DSL 的语法通常很小,可能只有加减乘除、比较运算、变量引用和少量函数调用,但使用频率和扩展需求却不低,所以实现方式会非常讲究。

1.2 经典实现:用虚函数建一棵语法树

教科书里的经典做法是定义一个抽象基类Expression,所有具体表达式继承它,通过虚函数interpret递归求值。

class Expression { public: virtual ~Expression() = default; virtual int interpret(const Context& ctx) const = 0; }; class NumberExpr : public Expression { int value_; public: explicit NumberExpr(int value) : value_(value) {} int interpret(const Context&) const override { return value_; } }; class AddExpr : public Expression { std::unique_ptr<Expression> left_, right_; public: AddExpr(std::unique_ptr<Expression> left, std::unique_ptr<Expression> right) : left_(std::move(left)), right_(std::move(right)) {} int interpret(const Context& ctx) const override { return left_->interpret(ctx) + right_->interpret(ctx); } };

这种写法非常直观,定义一种新算法就是加一个interpret方法,每个节点都是个自包含的小部件。但问题也很明显:如果表达式类型一多,类数量会迅速膨胀,而且一旦要新增加一种操作,比如从“求值”扩展成“打印 AST”,就得在整棵类体系里挨个加虚函数,破坏开闭原则。再加上虚函数调用本身有性能和跳转不确定性,在表达式数量很大的场景下并不占优势。正因为这些痛点,C++ 社区才演化出了一堆变体。

2. 变体一:std::variant 和 std::visit 替代继承体系

2.1 递归 variant 的构造方法

现代 C++ 给出的第一种变体,是用std::variant保存所有表达式类型,再用std::visit完成求值。这种做法的核心是“先定义存储,再定义行为”,把类型分派从虚函数转移到编译期匹配。

一个典型的递归 variant 长这样:

#include <iostream> #include <map> #include <memory> #include <string> #include <variant> struct Expr; struct Number { double value; }; struct Variable { std::string name; }; struct BinaryOp { char op; // + - * / std::unique_ptr<Expr> left; std::unique_ptr<Expr> right; }; struct Expr : std::variant<Number, Variable, BinaryOp> { using variant::variant; };

这里有个非常容易踩的坑:std::variant如果直接保存Expr,编译器会抱怨incomplete type,因为variant需要知道所有成员类型的大小,而Expr本身还在定义中。解决方案就是用std::unique_ptr<Expr>间接持有子表达式,因为unique_ptr可以指向不完整类型,这样就绕开了递归长度无限的问题。

2.2 基于 visit 求值的完整例子

有了存储结构,求值器可以写成独立函数:

double eval(const Expr& expr, const std::map<std::string, double>& env) { return std::visit([&](const auto& e) -> double { using T = std::decay_t<decltype(e)>; if constexpr (std::is_same_v<T, Number>) { return e.value; } else if constexpr (std::is_same_v<T, Variable>) { auto it = env.find(e.name); if (it == env.end()) { throw std::runtime_error("unknown variable: " + e.name); } return it->second; } else { double l = eval(*e.left, env); double r = eval(*e.right, env); switch (e.op) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': if (r == 0.0) throw std::runtime_error("divide by zero"); return l / r; default: throw std::runtime_error("unsupported op"); } } }, expr); }

这种实现比继承体系紧凑得多。所有语法节点都在一个variant里,没有虚函数表,内存布局连续,std::visit在多数编译器上会被优化成一张跳转表甚至条件移动,性能比虚函数调用更可预测。而且求值逻辑集中在一个函数里,对全局理解很友好。

2.3 这种变体的边界和不足

std::variant方案并不是银弹。首先,所有操作集中在一个std::visit里,如果语法节点很多,这个函数会变得很长,后续扩展时得小心地加if constexpr分支。其次,新增表达式类型时,所有使用variant的地方都要同步改,这点和继承体系新增子类在某些场景下体验差不多。还有,if constexpr加std::is_same_v的写法本质上是编译期模式匹配,如果分支太多,模板展开和编译时间也会明显上升。

我的实际感受是:当语法类型在 5 到 10 个之间、求值逻辑不算太复杂时,variant 方案体感最好,代码短、高效、易读;一旦语法类型超过 15 个或者有多种完全不同的操作路径,就要慎重考虑是不是该拆成多个 visitor 或改用字节码方案。

3. 变体二:运算符注册表 + 函数对象

3.1 把运算符变成可插拔策略

第二种变体和经典解释器差距较大,它不建语法树对象,而是把运算规则做成一个注册表,代码运行时从注册表里查“操作符对应的处理函数”。这种方式特别适合“运算符可动态扩展”的业务场景。

设想一个逆波兰表达式求值器:输入一串 token,遇到数字就压栈,遇到运算符就从栈里弹出操作数,调用注册表里的函数,再把结果压回栈。每个运算符只是一个std::function,不需要单独的 AST 节点类。

3.2 RPN 求值器代码示例

先定义注册表和求值器:

#include <functional> #include <stdexcept> #include <string> #include <unordered_map> #include <vector> #include <cmath> using BinaryFn = std::function<double(double, double)>; class Calculator { public: void registerOp(const std::string& name, BinaryFn fn) { ops_[name] = std::move(fn); } double evalRPN(const std::vector<std::string>& tokens) const { std::vector<double> stack; for (const auto& token : tokens) { auto it = ops_.find(token); if (it != ops_.end()) { if (stack.size() < 2) throw std::runtime_error("stack underflow"); double r = stack.back(); stack.pop_back(); double l = stack.back(); stack.pop_back(); stack.push_back(it->second(l, r)); } else { stack.push_back(std::stod(token)); } } if (stack.size() != 1) throw std::runtime_error("invalid expression"); return stack.back(); } private: std::unordered_map<std::string, BinaryFn> ops_; };

使用的时候可以动态注册函数:

Calculator calc; calc.registerOp("+", [](double l, double r) { return l + r; }); calc.registerOp("-", [](double l, double r) { return l - r; }); calc.registerOp("*", [](double l, double r) { return l * r; }); calc.registerOp("/", [](double l, double r) { if (r == 0) throw std::runtime_error("divide by zero"); return l / r; }); calc.registerOp("pow", [](double l, double r) { return std::pow(l, r); });

这段代码比 variant 方案更灵活,新增一个运算符不用改动任何实体类,入口函数没有 switch,也不需要 AST 结构。配合设计良好的解析器,甚至可以在运行时动态加载用户自定义操作符,这对产品化的规则引擎非常重要。

3.3 动态扩展与隐藏坑

使用注册表方案时有几个坑值得记录。第一,std::function本身有开销,每次调用都有间接函数调用,频繁调用时性能会明显低于虚函数或 variant 方案。第二,注册表用字符串查找,字符串哈希本身有成本;如果性能敏感,可以改用枚举或整数 ID 作为 key。第三,函数签名固定为double(double, double)时灵活性受限,想支持不同元数的运算符就得额外定义多种函数类型,或使用可变参数结构。

我自己的经验是,注册表方案最好和解析期类型检查配合。很多 C++ 实现里运算符注册成功但运行时操作数类型不匹配,最后抛出一堆难懂的错误。如果能在注册时同时登记元数和参数类型,错误信息会友好很多。这些扩展可以做成一张注册元信息表,而不是只存一个函数对象。

4. 变体三:表达式模板——编译期解释器

4.1 从 AST 到编译期表达式树

前两个变体都是运行期求值,C++ 还有一类非常特殊的“解释器”,它发生在编译期,通过模板元编程构建表达式树,这就是表达式模板(Expression Templates)。

它的核心思想不是解析字符串,而是利用 C++ 运算符重载,在编译期把a + b * c这样的表达式符号组合成一个模板类型。整个过程中没有虚函数、没有堆分配、没有运行时 AST,表达式在编译期就被折叠成了高效代码。

4.2 一个可运行的表达式模板例子

假设有一组double数值节点,我们想对它们做组合计算:

struct Var { double value; }; template <typename L, typename R> struct Add { const L& lhs; const R& rhs; double eval() const { return lhs.eval() + rhs.eval(); } }; template <typename L, typename R> Add<L, R> operator+(const L& lhs, const R& rhs) { return Add<L, R>{lhs, rhs}; }

这里Var有eval()方法,Add<L, R>也有eval(),经过一次加法操作后得到一个模板类对象,不产生任何临时计算结果。只有到最后真正需要数值时,才调用顶层的eval()。在向量计算、矩阵运算这类高性能数值库里,表达式模板能避免大量临时对象和内存分配,性能非常可观。

对比下面这种朴素写法:

std::vector<double> c(a.size()); for (size_t i = 0; i < a.size(); ++i) { c[i] = a[i] + b[i] * d[i]; }

使用表达式模板后,编译器可以把整个循环直接优化成类似手工展开的形式,每轮只访问底层数组一次,内存访问模式友好,执行效率是普通临时向量计算的数倍。

4.3 这个变体最好别用在哪

表达式模板的缺点也很致命:第一,调试噩梦,模板类型一长串,编译错误像天书;第二,类型膨胀严重,简单的表达式就能生成很复杂的类型名,增加编译时间和代码体积;第三,完全无法表示运行时语法,用户输入一段文本,解析动作只能发生在编译期之前,做不到“运行时解析”。

所以我的建议是:表达式模板适合给那些“表达式形式固定、追求极致性能”的内部库使用,而不要拿来当通用业务 DSL 的解释器。如果需求里有“让最终用户输入表达式”这几个字,请果断放弃这个变体。

5. 变体四:从树遍历走向字节码虚拟机

5.1 为什么要把 AST 变成指令序列

树遍历解释器每访问一个节点都要进行一次递归函数调用或std::visit分派,这种开销在表达式规模变大后会变得很扎眼。很多 C++ 开发者发现,与其辛辛苦苦优化 AST 求值,不如直接把语法树编译成一条指令序列,用一个紧凑的栈机循环来执行。指令越扁平,CPU 分支预测越准,Cache 命中率越高,整体性能就越好。

这其实就是“解释器模式变体”里相当激进的一支:用编译器的一部分思路,把解释器做成“字节码解释器”。它保留了“解释执行”的本质,但运行时遍历的不再是树,而是数组里的指令。

5.2 最小栈机实现

定义一个简单的指令集:

enum class Op : uint8_t { Push, Add, Mul, Print }; struct Instruction { Op op; double operand; };

对应的虚拟机:

class ByteCodeVM { public: explicit ByteCodeVM(std::vector<Instruction> code) : code_(std::move(code)) {} double run() { for (size_t ip = 0; ip < code_.size(); ++ip) { const auto& inst = code_[ip]; switch (inst.op) { case Op::Push: stack_.push_back(inst.operand); break; case Op::Add: { double r = stack_.back(); stack_.pop_back(); double l = stack_.back(); stack_.pop_back(); stack_.push_back(l + r); break; } case Op::Mul: { double r = stack_.back(); stack_.pop_back(); double l = stack_.back(); stack_.pop_back(); stack_.push_back(l * r); break; } case Op::Print: std::cout << stack_.back() << '\n'; break; } } return stack_.back(); } private: std::vector<Instruction> code_; std::vector<double> stack_; };

当你把表达式(1 + 2) * 3编译成Push 1, Push 2, Add, Push 3, Mul, Print后,整个执行循环没有任何递归调用,只有 switch 分派和数组访问,性能通常比树解释器高一个数量级以上。业界很多脚本引擎、正则引擎、数据库表达式引擎都走了这条路。

5.3 什么时候才需要 JIT 这条路

字节码虚拟机再往前一步就是 JIT。C++ 场景里真正用 JIT 的项目通常有明确的性能刚需,比如游戏脚本引擎、数据库聚合计算、数学计算库。JIT 的实现成本高很多,往往要引入一个后端来生成机器码,或者绑定外部 JIT 库,维护难度也不是普通团队乐意承担的。

我个人的判断标准很简单:如果表达式解释频率很低,树解释器完全够用;如果表达式被反复执行且单次执行路径很短,先做字节码;只有当你发现字节码解释器的分派开销占到整体运行时间的显著比例,且优化空间确实存在时,才值得考虑 JIT。大多数业务工程走到字节码这一层就足够了。

6. 实操复盘:用解释器变体做一个规则引擎

6.1 业务背景与瓶颈

之前我参与过一个内部配置系统,需要让业务人员通过页面配置“费用计算表达式”。最早的实现是硬编码,每个计算分支都写死在 C++ 代码里,业务人员每次改规则都要提版本、走发布流程,效率很低。团队于是决定做一个规则表达式引擎,让用户直接输入类似subscriptionFee * months - discount的文本,系统解析后解释执行。

刚开始我们理所当然地采用了经典继承方案,很快发现语法虽然只有十几类节点,但每个节点都要维护eval、toJson、explain等一堆接口,类文件碎得让人烦躁,新增一个节点类型就得改五六个地方。后来我们改成std::variant方案,把节点全部塞进一个 variant 里,再为每个操作单独写std::visit访问器,代码瞬间清爽很多。

6.2 选型过程和框架结构

最终结构大概是这样:

  • 词法解析:字符串 tokenizer,产出 token 流。
  • 语法解析:递归下降解析器,优先级靠函数调用层级实现。
  • AST 表示:std::variant<NumberNode, VariableNode, BinaryOpNode, FuncCallNode>。
  • 求值器:一个独立的Evaluator类,持有变量环境表,用std::visit完成递归求值。
  • 运算符表:用于函数节点的注册表,支持外部扩展自定义函数。

这个结构里,我把 variant 和注册表两个变体合在一起用。语法结构固定,用 variant 表示;函数行为多变,用注册表处理。解析器只负责生成 AST,不求值;求值器只认 AST,不关心语法解析。两者解耦之后,新语法进不来,新函数随时能加。

6.3 实测效果与维护建议

改完以后有几组真实收益:代码量比继承方案减少差不多一半;新增函数功能不触碰 AST 代码;线上解析和求值的总耗时也明显下降。因为 variant 方案在递归求值时没有虚函数跳转,指令层级更平坦,对编译器优化更友好。

维护阶段我最大的心得是:AST 数据结构是解释器的命门。不要在节点里塞进一堆只对单个操作有用的字段,不要在 visitor 函数里写太长的业务逻辑,尽量让每个表达式节点保持“值语义清晰、容易构造、容易打印”。另外,一定要在 AST 里保留源码位置信息,这样运行时报错才能定位到具体文本位置。

7. 常见问题与排查技巧实录

7.1 递归 variant 编不过怎么办

这是使用 std::variant 方案时最常遇到的问题。如果写struct Expr : std::variant<Number, Variable, std::unique_ptr<Expr>>还是报incomplete type,大概率是递归包装方式不对。正确做法是让 variant 作为独立 struct,子表达式用std::unique_ptr<Expr>持有。使用std::unique_ptr时,节点析构也必须放在 cpp 文件中,让编译器看到完整类型;如果你把 AST 头文件直接塞进一个很深的模板调用里,同样会碰到奇奇怪怪的实例化错误。

调试这类问题的高级技巧是:用static_assert(std::is_copy_constructible_v<Expr>)和static_assert(std::is_destructible_v<Expr>)来快速定位类型属性,再通过-ftemplate-backtrace-limit=0展开完整模板调用链,能省掉很多翻编译器报错的时间。

7.2 std::function 注册表的内存和生命周期陷阱

注册表方案的一个隐蔽问题不是函数本身,而是捕获列表里的生命周期。很多人会写:

Calculator calc; int base = 10; calc.registerOp("addBase", [base](double l, double r) { return base + l + r; });

如果base是指针或引用,且原变量在注册之后被销毁,lambda 调用时就会产生悬空引用,崩溃非常难查。我的习惯是:注册表里的 lambda 只捕获值类型,或者捕获受shared_ptr管理的状态,绝不让 lambda 保存裸指针或裸引用。另一个相关问题是静态注册表的初始化顺序,在跨编译单元的静态初始化里注册操作符,很容易出现访问未初始化注册表的未定义行为,最好改成函数内静态局部变量,或者用显式init()函数。

7.3 深递归导致栈溢出

AST 深度过大时,树遍历和 variant 递归很容易把线程栈击穿。真实业务里触发条件一般是用户写了一个超长的嵌套括号表达式,或者解析器生成了右偏斜的二叉树。我在规则引擎里提前限制了 AST 深度和 token 数量,超过阈值直接拒绝,而不是让调用栈在运行时崩溃。

另一个实战经验是尽量让二叉树保持平衡。相同表达式1+2+3+4+...+N,如果解析器构建成左结合链,递归深度是 N;如果通过运算符优先级和括号结构强行压成平衡结构,深度会接近 log(N)。仅这一项优化,就能让栈溢出风险小很多。

7.4 给解释器做测试的经验

解释器代码容易出问题的地方不在“九九乘法”这类简单求值,而在变量查找、错误处理、优先级组合这些交叉场景。我建议至少保留四层测试:

  • 词法层:token 边界、非法字符。
  • 语法层:错误优先级、不完整表达式。
  • 求值层:正常结果、除零、未知变量。
  • 集成层:从用户输入字符串到最终结果的端到端用例。

测试数据里一定要覆盖“表达式为空”“只有数字没有运算符”“变量名不存在”“括号不匹配”四类异常。很多解释器在正确路径下跑得挺好,一到异常路径就崩溃或抛出不友好错误,根源都是错误路径的测试缺失。

我个人在实际项目里的体会是,解释器模式最考验人的不是怎么把类图背下来,而是你对“语法树用什么载体存储”“求值发生在什么时候”“扩展点设计在哪里”三个问题的回答。写这篇文章的时候,我特意把五种变体放在同一条轴线上对比:继承方案胜在直观,variant 胜在紧凑,注册表胜在灵活,表达式模板胜在零成本,字节码胜在性能。真实项目往往不会只用一种,我在规则引擎里就是 variant 加注册表混着用,解析阶段用 variant,扩展运算符用注册表。最后再给个建议:动手之前先花两小时把 AST 的数据结构设计好,把上下文和环境变量的接口定下来,后面写求值器只是一件水到渠成的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进