资讯动态

C++设计模式之解释器模式

发布时间:2026/10/21 23:21:46 来源:尧图企业网站定制
前言解释器模式Interpreter Pattern解决的是一个很具体的问题给定一种小型的、规则明确的语言如何用面向对象的方式表示它的语法并对其求值。经典例子是算术表达式、布尔表达式、查询条件、简单的命令脚本。它的做法是给文法中的每条规则定义一个类语法树就是对象树求值就是对树做一次递归遍历。这个模式经常被误解成「写一个编程语言解释器」。实际上它适合的是文法很小、变化不频繁的场景。GoF 的书里明确写过当文法复杂到一定程度解释器模式会因为类的数量爆炸而变得难以维护此时应该改用语法分析器生成器。把解释器模式当成「手写一个迷你语言的骨架」更准确而不是「实现一门完整语言的方法」。另一个常见的混淆是解释器模式与组合模式Composite。两者的结构确实很像都有一致的抽象基类都有叶子节点和容器节点。区别在意图组合模式关心「部分—整体」的树形结构与统一操作解释器模式关心「用一个类表示一条文法规则」。实际实现中解释器模式的抽象语法树往往就是一棵组合结构。本文先讲模式的角色划分再讲文法和递归下降解析然后给出一个完整的算术表达式解释器最后讨论它的边界以及和 Visitor 模式的分工。代码按 C17 编写没有在本机编译或运行过。一、模式的角色与结构解释器模式有四个角色。角色职责本例中的类型AbstractExpression声明interpret接口ExpressionTerminalExpression文法中的终结符不再包含子表达式NumberExpr、VariableExprNonterminalExpression文法中的非终结符持有子表达式AddExpr、SubExpr、MulExpr、DivExprContext保存解释过程中需要的全局信息Context调用方负责构建语法树然后对根节点调用interpret把Context传进去。每个非终结符在实现里递归调用子节点的interpret把结果组合起来。终结符直接返回自身的值或者从Context里查。这个结构的关键在于「求值逻辑分散在各个节点类里」。每个节点只关心自己那一条规则怎么算不需要知道整棵树长什么样。新增一条文法规则就是新增一个类新增一种「对树做点什么」的操作则要修改所有类。这个取舍在最后一节会展开。二、文法、语法树与递归下降在写类之前先把文法写清楚。算术表达式的经典文法如下expr、term、factor是非终结符数字和标识符是终结符。expr : term (( | -) term)*term : factor ((* | /) factor)*factor : number | identifier | ( expr )这个文法把优先级编码进了层次expr处理加减term处理乘除factor处理括号和原子。因为每一层只引用下一层不存在expr : expr term这样的左递归递归下降解析器可以直接按文法写成三个函数每个函数消费一部分输入并返回一棵子树。语法树与类结构的对应关系是直接的2 * (x y) - 4解析后根是SubExpr左子节点是MulExpr它的右子节点是AddExpr叶子分别是NumberExpr(2)、VariableExpr(x)、VariableExpr(y)、NumberExpr(4)。对根调用interpret递归自然完成求值。递归下降的写法有一个前提文法不能有左递归否则解析函数会无限调用自己。真实语言的文法通常需要先消除左递归或改用运算符优先级爬升precedence climbing本例的文法已经按优先级分层不需要额外处理。三、完整实现算术表达式解释器下面的代码包含上下文、节点类、解析器和入口函数是一个可以独立编译的完整程序。为控制篇幅只支持整数、四则运算、括号和变量。// 适用于 C17#include cctype#include cstddef#include iostream#include map#include memory#include stdexcept#include string#include utilityclass Context {public:void set(const std::string name, int value){vars_[name] value;}int get(const std::string name) const{auto it vars_.find(name);if (it vars_.end()) {throw std::runtime_error(undefined variable: name);}return it-second;}private:std::mapstd::string, int vars_;};class Expression {public:virtual ~Expression() default;virtual int interpret(const Context ctx) const 0;};class NumberExpr : public Expression {public:explicit NumberExpr(int value) : value_(value) {}int interpret(const Context) const override { return value_; }private:int value_;};class VariableExpr : public Expression {public:explicit VariableExpr(std::string name) : name_(std::move(name)) {}int interpret(const Context ctx) const override { return ctx.get(name_); }private:std::string name_;};class BinaryExpr : public Expression {public:BinaryExpr(std::unique_ptrExpression lhs,std::unique_ptrExpression rhs): lhs_(std::move(lhs)), rhs_(std::move(rhs)) {}protected:std::unique_ptrExpression lhs_;std::unique_ptrExpression rhs_;};class AddExpr : public BinaryExpr {public:using BinaryExpr::BinaryExpr;int interpret(const Context ctx) const override{return lhs_-interpret(ctx) rhs_-interpret(ctx);}};class SubExpr : public BinaryExpr {public:using BinaryExpr::BinaryExpr;int interpret(const Context ctx) const override{return lhs_-interpret(ctx) - rhs_-interpret(ctx);}};class MulExpr : public BinaryExpr {public:using BinaryExpr::BinaryExpr;int interpret(const Context ctx) const override{return lhs_-interpret(ctx) * rhs_-interpret(ctx);}};class DivExpr : public BinaryExpr {public:using BinaryExpr::BinaryExpr;int interpret(const Context ctx) const override{int divisor rhs_-interpret(ctx);if (divisor 0) {throw std::domain_error(division by zero);}return lhs_-interpret(ctx) / divisor;}};class Parser {public:explicit Parser(std::string text) : text_(std::move(text)) {}std::unique_ptrExpression parse(){auto expr parse_expr();skip_space();if (pos_ ! text_.size()) {throw std::runtime_error(unexpected trailing input);}return expr;}private:std::unique_ptrExpression parse_expr(){auto lhs parse_term();for (;;) {skip_space();if (pos_ text_.size() ||(text_[pos_] ! text_[pos_] ! -)) {break;}char op text_[pos_];auto rhs parse_term();if (op ) {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 parse_term(){auto lhs parse_factor();for (;;) {skip_space();if (pos_ text_.size() ||(text_[pos_] ! * text_[pos_] ! /)) {break;}char op text_[pos_];auto rhs parse_factor();if (op *) {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 parse_factor(){skip_space();if (pos_ text_.size()) {throw std::runtime_error(unexpected end of input);}char c text_[pos_];if (c () {pos_;auto inner parse_expr();skip_space();if (pos_ text_.size() || text_[pos_] ! )) {throw std::runtime_error(expected ));}pos_;return inner;}if (std::isdigit(static_castunsigned char(c))) {int value 0;while (pos_ text_.size() std::isdigit(static_castunsigned char(text_[pos_]))) {value value * 10 (text_[pos_] - 0);pos_;}return std::make_uniqueNumberExpr(value);}if (std::isalpha(static_castunsigned char(c)) || c _) {std::size_t start pos_;while (pos_ text_.size() (std::isalnum(static_castunsigned char(text_[pos_])) ||text_[pos_] _)) {pos_;}return std::make_uniqueVariableExpr(text_.substr(start, pos_ - start));}throw std::runtime_error(std::string(unexpected character: ) c);}void skip_space(){while (pos_ text_.size() std::isspace(static_castunsigned char(text_[pos_]))) {pos_;}}std::string text_;std::size_t pos_ 0;};int main(){Context ctx;ctx.set(x, 6);ctx.set(y, 7);try {Parser parser(2 * (x y) - 4);auto expr parser.parse();std::cout expr-interpret(ctx) \n;} catch (const std::exception e) {std::cerr error: e.what() \n;return 1;}return 0;}这段实现有几处值得注意的工程细节。第一抽象基类的析构函数写成了virtual ~Expression() default;。语法树由std::unique_ptrExpression持有删除时通过基类指针进行析构函数不是虚函数就是 UB——这是解释器模式里最容易犯又最难察觉的错误因为它在很多实现上「看起来正常」。第二节点一律用std::unique_ptr持有子节点BinaryExpr的构造函数接管所有权。解析器构造新节点时用std::make_unique中途抛出异常也不会泄漏。不要用裸new构建语法树。第三DivExpr先求出除数再检查零除数为零时抛std::domain_error。整数除法本身在除数为零时是 UB必须在调用之前拦住不能依赖硬件异常。第四解析器在parse()里检查了pos_ text_.size()。少了这一步1 2 abc会被当成解析成功因为parse_expr只要匹配到1 2就返回了后面的字符被静默忽略。这是手写解析器最常见的漏洞。第五std::isdigit、std::isalpha、std::isspace的参数都做了static_castunsigned char。这些函数要求参数是「能表示为unsigned char的值或EOF」直接传负的char是 UB。C 里char是否有符号由实现决定不能想当然。四、适用边界与相关模式解释器模式的优点是结构清晰、扩展文法方便新增一种运算就是新增一个继承Expression的类其余代码不动。缺点是每个节点一个类文法一大类的数量就线性增长而且求值逻辑散落在各处想加一种新操作比如「打印语法树」「做常量折叠」「生成目标代码」就必须修改每一个节点类。这就是所谓的表达式问题expression problem很难同时做到「新增类型容易」和「新增操作容易」。Visitor 模式解决的是另一条轴把「操作」从节点类里抽出来新增操作只需新增一个 visitor 类但新增节点类型要修改所有 visitor。两者经常配合使用语法树用解释器模式的结构遍历和变换用 Visitor。什么时候不该用解释器模式文法规模大比如完整的 SQL、JSON Schema、正则表达式。手写类层次不现实应该用解析器生成器或者现成的解析库。文法经常变化。每次改文法都要改类层次和解析器维护成本高。对性能极其敏感。虚函数调用加指针追逐的树遍历通常不如一次编译成字节码再执行但这属于定性判断具体差异要测量。只需要一次性求值、不需要复用语法树。此时直接边解析边计算比如调度场算法更简单。如果语法树不需要支持多种操作C17 的std::variant加std::visit也是一个紧凑的替代方案用variantNumber, Binary, ...表示节点用visit做求值避免了一堆小类和虚函数代价是节点类型集合固定、编译期展开。选哪种取决于你更看重扩展性还是简洁性。常见坑点❌Expression的析构函数不写virtual然后用unique_ptrExpression管理整棵树。✅ 写virtual ~Expression() default;通过基类指针删除非虚析构对象是 UB。❌ 解析完成后不检查是否还有剩余输入。✅ 在入口处检查位置指针是否到达末尾否则非法尾部会被静默忽略。❌ 整数除法直接算指望除数为零时能抛异常。✅ 先判断除数是否为零并抛出std::domain_error整数除零是 UB。❌ 用裸new构造节点解析中途抛异常导致整棵树泄漏。✅ 用std::unique_ptr和std::make_unique让所有权和异常安全由 RAII 保证。❌ 变量未定义时返回 0让错误悄悄传播。✅ 抛出异常或返回std::optional把「未定义」和「值为 0」区分开。❌ 把char直接传给std::isdigit、std::isspace。✅ 先转换std::isdigit(static_castunsigned char(c))避免负值导致的 UB。❌ 文法写成左递归expr : expr term递归下降直接栈溢出。✅ 改写成循环形式或分层文法保证每个解析函数在递归前先消费输入。❌ 把解释器模式和 Visitor 模式混为一谈节点里既放求值又放十种遍历操作。✅ 按「类型扩展」还是「操作扩展」的主方向选一个两者结合时把操作集中到 visitor 里。总结方面说明核心思想一条文法规则一个类语法树即对象树求值即递归遍历关键角色抽象表达式、终结符、非终结符、上下文构建方式手写递归下降文法按优先级分层避免左递归扩展方向新增文法规则容易新增操作需改动所有节点内存管理unique_ptr持有子树基类析构必须为虚适用边界小型、稳定的领域语言大型文法改用解析器生成器解释器模式真正提供的是一套「把语法变成类型」的思路一旦文法被类型化求值、检查、优化、打印都可以围绕这棵树展开。它的成本也来自同一处——类型数量随文法增长。先用它把最小可用的语义跑通再根据「改文法多还是改操作多」决定是否引入 Visitor通常比一开始就设计一套庞大的类层次更务实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑