ARTICLE DETAIL

资讯详情

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

C++解释器模式实战:从字符串表达式到抽象语法树求值

C++解释器模式实战:从字符串表达式到抽象语法树求值 如果你在C项目里遇到“表达式求值”“DSL脚本解析”“规则引擎”这种需求哪怕只是需要让用户输入一句price * 0.8 shipping然后算出结果C中的解释器模式就是那几个最值得先考虑的方案之一。解释器模式的价值不在于它能把代码写得花哨而在于它把“语法规则”和“执行逻辑”彻底拆开让一套表达式从“字符串”变成“可计算的结构”这一套思路在编译器前端、公式引擎、权限规则校验里都通用。这个模式在很多教材里被放在最后一个章节看起来又抽象又不常用但实际上它是理解递归、多态和语法分析三者的交汇点。适合谁看已经能写C类、会用虚函数和智能指针但还没系统接触过“如何把文本变成程序能理解的东西”的开发者。你如果只想用现成库调一个表达式解析那直接用exprtk这类库就行但如果你想搞懂背后发生了什么或者要做一个定制语法的功能模块这篇文章能帮你把解释器模式从概念到代码完整落地。1. 解释器模式是什么它到底在解决什么问题先给解释器模式一个准确且不绕弯的定义它针对某种“语言”或者一个受限制的表达式文法把每一个语法规则映射成一个类这些类组合成一棵抽象语法树然后通过递归调用解释方法来计算结果。这么说还是有点抽象我换成人话你有一串字符串比如(1 2) * 3你想让程序算出 9。解释器模式就是先定义“数字”“加号”“乘号”“括号”这些规则分别对应什么节点再按规则把这些节点搭成一棵树最后从树根到底层逐个求值。1.1 核心定义与真实场景教材里对解释器模式Interpreter通常这样描述给定一个语言定义它的文法的一种表示并定义一个解释器这个解释器使用该表示来解释语言中的句子。这句话的重点落在“文法”和“表示”上。现实项目中你不会真的拿它去解释一整门编程语言那工程量太大了解释器模式最适合的场景是表达式计算器支持加减乘除、变量、函数调用的公式引擎比如财务计算、奖金规则计算。DSL领域特定语言解析游戏里的技能描述、配置系统里的规则文本例如if hit 10 then damage damage * 2。查询/过滤规则数据库查询语句的解析简化版或者内存数据过滤条件比如age 18 city beijing。协议报文解析规则相对固定的二进制或文本协议把每条指令映射成可执行的行为。这些场景的共同特征是语法规则的种类有限、结构相对固定、不需要像通用编程语言那样复杂。一旦规则数量膨胀到几百条或者语法开始包含循环、函数定义、作用域解释器模式就撑不住了那时候你需要的是真正的语法分析器生成器比如 ANTLR、Flex/Bison或者直接引入 Lua/Python 这类嵌入式脚本引擎。1.2 四个核心角色解释器模式在《设计模式》那本书里被拆成了四个角色我用自己的话翻译一下抽象表达式AbstractExpression一个抽象基类声明一个解释操作通常是interpret()或evaluate()。它定义了整棵树统一的“求值入口”。终结符表达式TerminalExpression树上的叶子节点代表文法里不可再分的基本元素。表达式里的“数字”“变量名”都属于终结符。终结符的interpret()直接返回自身代表的值比如数字返回数值变量去上下文里查值。非终结符表达式NonterminalExpression树上的内部节点代表一条组合规则。比如AddExpression代表“左表达式 右表达式”它的interpret()会先递归调用左子树和右子树的interpret()再把两个结果相加。上下文Context存放解释过程中需要的共享信息最典型的就是变量表。比如表达式里有x 1那x的值就放在 Context 里。上下文也可以存放函数表、当前作用域等。这四者配合起来的直观理解可以类比成一个家族结构爷爷是“加法表达式”爸爸是“变量x”妈妈是“数字2”x 2求值时爷爷说“我儿子去查一下自己的值儿媳妇直接报常量2然后他俩加起来”。每次求值都是这样从上往下递归再从下往上返回结果。1.3 C 实现与其他语言的差异解释器模式在 C 里的实现和 Java、Python 里有个显著差异就是**“类型判断的代价”。Java 里你可以用instanceof逐个判断节点类型然后强转写起来很方便但有点啰嗦Python 这种动态语言甚至不需要预定义类结构。而 C 推荐的做法是用虚函数代替类型判断**把所有行为放进interpret()这个统一的虚函数里让多态帮你把“该执行哪个操作”这件事分发掉。这意味着 C 版本的类层次设计会非常整洁一个基类管接口两个子类分别代表叶子节点和内部节点内部节点持有子节点的shared_ptr。另一个差异点是内存管理。C 没有自动垃圾回收AST 节点之间的父子关系必须明确——父节点持有子节点的所有权通常用std::shared_ptr最省心生命周期跟着整棵树走析构也递归自动完成。还有一个现实问题C 的递归深度和性能。interpret()是深度优先递归表达式层数非常深时比如一个几千层的左结合表达式调用栈可能爆掉。当然普通业务场景到不了这个量级但设计时心里要有这根弦。2. 先别急着写代码把表达式文法设计清楚很多初学者一上来就写代码结果写出一个只能处理12的“计算器”加个括号就崩。问题不在代码能力而是没有先定义文法。文法就是语法规则的形式化描述它决定了你的程序能接受什么输入、不能接受什么输入。这一节我带着你把一个带优先级、括号、变量的表达式文法完整定义出来。2.1 文法就是抽象语法树的蓝图文法本身是可以用简单的 BNF巴科斯范式或者说“产生式规则”描述的。比如我们要支持的四则运算表达式文法可以写为expression :: term (( | -) term)* term :: factor ((* | /) factor)* factor :: NUMBER | IDENTIFIER | ( expression )这三条规则表达的信息非常密集。第一条说一个表达式是一个 term后面可以跟零个或多个“加减号 term”的组合。第二条同理乘除法优先级更高所以 term 要在 expression 下面一层。第三条说最基本的因子要么是数字、要么是变量名、要么是一个括号包起来的完整表达式。这其实就是你后面代码的“骨架图”。你会发现expression对应解析器里的一个函数factor里递归引用了expression这一下就实现了括号内任意嵌套。先写清文法再写代码你的思路会豁然开朗。2.2 运算符优先级如何映射到类结构很多人困惑的是优先级怎么通过对象模型体现出来。这里的关键在 AST 的形状。表达式1 2 * 3的 AST 应该是这样的根节点是“加法”左孩子是常量1右孩子是“乘法”乘法左孩子是常量2右孩子是常量3。求值时先算2*3再算16优先级通过树的深度自动体现。乘除法节点在更深的层次求值就更先执行。之所以要专门设term和factor两层解析函数就是为了让 AST 长成这样。如果你只用一个parseExpression不分层直接循环读数字和运算符那1 2 * 3就会解析成(1 2) * 3优先级语义就错了。所以文法里有几层产生式代码的解析函数就分几层这是一一对应的。2.3 词法分析、语法分析、解释求值的分工边界动手前还要明确一个很容易混在一起的分工。一个完整的解释过程通常分三个阶段词法分析Lexer把原始字符串拆成“令牌流”比如(1 2) * 3拆成LParen, Number(1), Plus, Number(2), RParen, Star, Number(3)。词法分析不管语法对不对它只负责把字符归类。语法分析Parser根据文法把令牌流组合成 AST。1 2 * 3会被组合成“加法节点右子树乘法节点”。这一步会检查括号匹配、运算符是否合法等。解释求值Evaluator/Interpreter遍历 AST执行真正的计算。这一步才调用每个节点的interpret()。这三步逻辑上一定要分开。如果你把它们搅在一起比如边读字符边解析边算短期看能写出一个运行很快的小计算器但一旦要支持变量、函数、错误定位代码会迅速失控。我的建议是即使你的项目很小也给 Lexer、Parser、Evaluator 各写一个类它们之间的边界用清晰的接口切分。这跟解释器模式本身是相辅相成的解释器模式解决的是“ AST 怎么定义、怎么求值”的问题而 Lexer 和 Parser 解决的是“字符串怎么变成 AST”的问题。这里也顺带回应一下热词里反复出现的“C 字符串数组初始化”“C 字符串转数组”这类问题它们和解释器模式的关系在于词法分析阶段你实际上就是在做字符串到令牌数组的转换C 的std::vectorToken就是你想要的那个“数组”别用裸指针数组自找麻烦。3. 手写一个表达式计算器C 完整实现现在进入正题。我用最经典的四则运算 变量 括号来完整实现一版。整个实现我刻意控制在几百行以内但该有的抽象和健壮性都在。工程上你完全可以拿这个做模板扩展。完整代码我放在下面的小节里逐步讲解最终汇总成一个可直接编译运行的程序。建议你动手敲一遍光看是记不住的。3.1 表达式节点类的设计首先要定义一个抽象基类Expr它只做一件事声明虚函数interpret()参数是变量环境返回double。#include iostream #include string #include map #include memory #include sstream #include stdexcept #include vector #include cctype class Expr { public: virtual ~Expr() default; virtual double interpret(const std::mapstd::string, double vars) const 0; };然后是两个终结符子类。第一个是数字字面量Literal“1”“3.14”这种它的interpret()直接返回构造时保存的值。第二个是变量Variable它去vars里查自己的名字查不到就抛异常。class Literal : public Expr { double value_; public: explicit Literal(double value) : value_(value) {} double interpret(const std::mapstd::string, double) const override { return value_; } }; class Variable : public Expr { std::string name_; public: explicit Variable(std::string name) : name_(std::move(name)) {} double interpret(const std::mapstd::string, double vars) const override { auto it vars.find(name_); if (it vars.end()) { throw std::runtime_error(undefined variable: name_); } return it-second; } };接着是非终结符子类BinaryExpr表示一个二元运算节点。它持有操作符枚举和左右两个子树interpret()先递归求左值、右值再做对应运算。enum class Op { Add, Sub, Mul, Div }; class BinaryExpr : public Expr { Op op_; std::shared_ptrExpr left_, right_; public: BinaryExpr(Op op, std::shared_ptrExpr left, std::shared_ptrExpr right) : op_(op), left_(std::move(left)), right_(std::move(right)) {} double interpret(const std::mapstd::string, double vars) const override { double l left_-interpret(vars); double r right_-interpret(vars); switch (op_) { case Op::Add: return l r; case Op::Sub: return l - r; case Op::Mul: return l * r; case Op::Div: if (r 0.0) { throw std::runtime_error(division by zero); } return l / r; } return 0.0; } };到这一步解释器模式的“解释部分”已经成型。后面要做的 Lexer 和 Parser实际上是在构造这棵由Expr节点组成的树。3.2 上下文与变量环境的设计我上面代码里用std::mapstd::string, double作为变量环境这个 map 就是 Context 的一种极简实现。如果表达式里始终没有变量那传一个空 map 就行如果有变量调用时把值塞进去。为什么不用一个专门的 Context 类包一层在我们这个例子里map 已经足够没必要为了模式而模式。但在更大的系统里Context 还需要支持变量赋值、函数名查找、嵌套作用域、求值过程中的状态记录。此时你应该定义独立的 Context 类而不是继续用裸 map。这个权衡我建议你在实际项目里按复杂度来定如果只有一个作用域、只读变量裸 map 完全合理一旦出现赋值语句和嵌套作用域再上 Context 类。3.3 递归下降解析器从令牌流到抽象语法树先写词法分析器。它把字符串拆成Token流。每个 Token 有类型、文本、数值数字时才有效三个字段。enum class TokenType { Number, Ident, Plus, Minus, Star, Slash, LParen, RParen, End }; struct Token { TokenType type; std::string text; double value 0.0; }; class Lexer { public: explicit Lexer(std::string input) : input_(std::move(input)) {} std::vectorToken tokenize() { std::vectorToken tokens; while (pos_ input_.size()) { char c input_[pos_]; if (std::isspace(static_castunsigned char(c))) { pos_; continue; } if (std::isdigit(static_castunsigned char(c)) || c .) { tokens.push_back(lexNumber()); continue; } if (std::isalpha(static_castunsigned char(c)) || c _) { tokens.push_back(lexIdent()); continue; } switch (c) { case : tokens.push_back({TokenType::Plus, }); pos_; break; case -: tokens.push_back({TokenType::Minus, -}); pos_; break; case *: tokens.push_back({TokenType::Star, *}); pos_; break; case /: tokens.push_back({TokenType::Slash, /}); pos_; break; case (: tokens.push_back({TokenType::LParen, (}); pos_; break; case ): tokens.push_back({TokenType::RParen, )}); pos_; break; default: throw std::runtime_error(std::string(unexpected character: ) c); } } tokens.push_back({TokenType::End, }); return tokens; } private: std::string input_; size_t pos_ 0; Token lexNumber() { size_t start pos_; while (pos_ input_.size() (std::isdigit(static_castunsigned char(input_[pos_])) || input_[pos_] .)) { pos_; } std::string text input_.substr(start, pos_ - start); return {TokenType::Number, text, std::stod(text)}; } Token lexIdent() { size_t start pos_; while (pos_ input_.size() (std::isalnum(static_castunsigned char(input_[pos_])) || input_[pos_] _)) { pos_; } std::string text input_.substr(start, pos_ - start); return {TokenType::Ident, text}; } };接着是递归下降解析器。递归下降是手写语法分析最直观的方法每个非终结符对应一个解析函数函数之间互相调用。文法有三层解析函数就按这三层来写。class Parser { public: explicit Parser(std::vectorToken tokens) : tokens_(std::move(tokens)) {} std::shared_ptrExpr parse() { auto expr parseExpression(); if (current().type ! TokenType::End) { throw std::runtime_error(unexpected trailing tokens); } return expr; } private: std::vectorToken tokens_; size_t pos_ 0; const Token current() const { return tokens_[pos_]; } void advance() { if (pos_ 1 tokens_.size()) pos_; } std::shared_ptrExpr parseExpression() { auto left parseTerm(); while (current().type TokenType::Plus || current().type TokenType::Minus) { Op op (current().type TokenType::Plus) ? Op::Add : Op::Sub; advance(); auto right parseTerm(); left std::make_sharedBinaryExpr(op, std::move(left), std::move(right)); } return left; } std::shared_ptrExpr parseTerm() { auto left parseFactor(); while (current().type TokenType::Star || current().type TokenType::Slash) { Op op (current().type TokenType::Star) ? Op::Mul : Op::Div; advance(); auto right parseFactor(); left std::make_sharedBinaryExpr(op, std::move(left), std::move(right)); } return left; } std::shared_ptrExpr parseFactor() { if (current().type TokenType::Number) { double v current().value; advance(); return std::make_sharedLiteral(v); } if (current().type TokenType::Ident) { auto name current().text; advance(); return std::make_sharedVariable(std::move(name)); } if (current().type TokenType::LParen) { advance(); auto inner parseExpression(); if (current().type ! TokenType::RParen) { throw std::runtime_error(missing closing parenthesis); } advance(); return inner; } throw std::runtime_error(unexpected token when parsing factor); } };你可能注意到parse()末尾有个unexpected trailing tokens检查这一步很重要。它能抓出12 3这类输入防止解析器只解析前面一半就悄悄成功。解析器的鲁棒性很多时候就体现在这种“收尾验证”上。3.4 解释求值的核心逻辑把Lexer和Parser组装起来之后求值入口就非常简单了double evaluate(const std::string expr, const std::mapstd::string, double vars {}) { Lexer lexer(expr); auto tokens lexer.tokenize(); Parser parser(std::move(tokens)); auto ast parser.parse(); return ast-interpret(vars); }这四行代码是解释器模式在 C 里的标准流程文本 → Token 流 → AST → 求值结果。注意interpret是const方法所以整棵 AST 不修改只读取如果你未来要支持赋值语句那就得去掉 const 并让节点持有变量的可写引用。3.5 完整测试与使用示例最后用一个main函数跑通几个用例包括嵌套括号、变量求值和错误场景。int main() { std::cout evaluate(1 2 * 3) std::endl; // 7 std::cout evaluate((1 2) * 3) std::endl; // 9 std::cout evaluate(10 / 4) std::endl; // 2.5 std::cout evaluate(x * 2 1, {{x, 3.5}}) std::endl; // 8 try { evaluate(1 / 0); } catch (const std::exception e) { std::cout error: e.what() std::endl; } try { evaluate((1 2); } catch (const std::exception e) { std::cout error: e.what() std::endl; } return 0; }输出应该是7 9 2.5 8 error: division by zero error: missing closing parenthesis到这里一个具备词法分析、语法解析、抽象语法树求值的最小解释系统就完整了。它的核心思想完全可以迁移到更复杂的 DSL 上。4. 实现中要警惕的坑这一节分享我实际踩过的坑。有些坑是设计上的有些是 C 特有的很多文档里根本不会写。4.1 优先级处理错误导致左结合问题最经典的坑就是把所有二元运算放在一个解析函数里循环处理。你可能会写出一个“简化版”读一个数、读一个运算符、再读一个数、再读运算符……这样处理1 - 2 - 3时结果会变成1 - (2 - 3)等于 2而正确结果应该是(1 - 2) - 3等于 -4。这就是结合性问题加减乘除都是左结合的但从右往左组合就错了。我之前提的递归下降方案天然规避了这个问题因为while循环里总是把新子树拼到左侧。但如果你稍微改一下把left BinaryExpr(op, right, left)写成left BinaryExpr(op, left, right)的顺序和其他地方导致右结合就会很隐蔽。排查方法很简单拿1 - 2 - 3和8 / 4 / 2这种左结合用例做单测这两条用例能暴露 90% 的结合性问题。还有一种情况是优先级层级不够。比如以后要加比较运算、你不能直接塞进parseTerm因为比较运算的优先级低于加减法必须在parseExpression之上再加一层parseComparison。每次加优先级就是往解析函数链的合适位置插一个新层这是递归下降解析器的核心扩展方式想清楚这一点就不好乱。4.2 一元运算符与括号的区分只处理二元运算时会觉得很简单一旦要支持负数比如-3 5麻烦就来了。这个-符号和二元减号是同一个 Token但语义完全不同它后面跟的是一个因子不是整个表达式。正确做法是在parseFactor里检测当前 token 是Minus时提取下一个因子然后包装一个NegateExpr节点表示取负。这里有个容易忽略的细节一元负号和二元减号的优先级不同。-2 * 3应该解析成(-2) * 3所以取负必须发生在parseFactor这一层而不是parseTerm层。我见过一些实现把-一元处理放在parseTerm里结果-2^2如果加了幂运算这种表达式算出来就完全不对。括号的处理也一样parseFactor里遇左括号就解析一个完整表达式等到右括号再返回这是嵌套结构唯一的正确写法。看到括号就试图用正则或者字符计数去匹配那是死路。4.3 内存管理与虚析构函数C 的 AST 节点是用继承关系组织的基类Expr必须声明virtual ~Expr()否则通过基类指针删除子类对象时子类析构函数不会被调用内存直接泄漏。这点在单测里不容易发现因为进程退出后系统会回收所有内存但在长时间运行的服务里每解析一次就泄漏一点几天后内存就爆了。如果用了std::shared_ptr管理节点虚析构依然必要因为shared_ptr删除对象时也是通过基类指针调用的。还有一个相关问题是共享所有权和环引用。AST 本身是严格的树结构父节点持有子节点的shared_ptr子节点不反指父节点就不会有环。如果你为了做某些遍历给子节点加了weak_ptr回指父节点那也没问题但千万别让子节点持有父节点的shared_ptr那样树就变成环内存永远释放不了。4.4 错误处理与异常安全解释器模式天然适合用异常来处理语法错误和求值错误。Lexer 遇到非法字符就抛异常Parser 遇到括号不匹配或多余 token 就抛异常interpret 遇到除零或未定义变量也抛异常。调用方统一 catch可以拿到清晰的错误信息。但有一个细节容易被忽略异常抛出的位置和错误定位。如果你只在evaluate层 catch用户只知道“错了”但不知道错在第几个字符。更好的做法是 Token 里记录行号和列号Lexer 产生错误时把位置信息拼进what()Parser 组合 AST 时也可以给每个节点关联源码位置。这样用户能看到类似 “unexpected character ? at position 7” 的提示而不是干巴巴的 “parse error”。这个细节在真实产品里非常影响体验。4.5 性能优化从常量折叠到 JIT 的思考解释器模式最被人诟病的就是性能。interpret()是一次树遍历每个节点还涉及虚函数调用相比直接执行机器码慢很多。但解决思路分几档第一档常量折叠。如果表达式里没有变量你可以在解析结束后立刻求值一次后续直接返回结果。比如1 2 * 3可以在构建 AST 后就算出 7以后每次调用都不需要再遍历树。第二档AST 缓存。如果同一个表达式文本会被反复使用比如规则引擎里同一句规则对成千上万条数据求值那 Lexer 和 Parser 只跑一次只把 AST 存下来每次只做interpret。这一步通常就能带来 10 倍以上的性能提升。第三档字节码或 JIT。把 AST 编译成自定义的字节码序列用一段紧凑的解释循环执行避免虚函数调用的开销再进一步可以用 LLVM 或直接生成机器码。到了这个阶段你已经在做真正的编译器了解释器模式只是其中的前端一环。从工程角度说先做常量折叠和 AST 缓存绝大多数场景已经够用。5. 扩展与真实场景应用看完基础实现我想再聊聊扩展方向和应用边界。解释器模式经常被误用也经常在该用的地方被冷落。以下是几个有价值的扩展点和真实的落地场景参考。5.1 扩展运算符一元负号、比较与逻辑运算为了让计算器更有用一个很自然的扩展是支持比较运算和逻辑运算。比如规则表达式可能是这样的age 18 score 60。这要求你做三件事第一文法加层。比较运算优先级低于加减法逻辑与又低于比较所以解析函数链变成logicOr → logicAnd → comparison → expression → term → factor。每加一层就是在链上插一个新函数。第二节点类型增加。比较节点CompareExpr返回的不再是double而是一个布尔值为了统一可以把interpret()的返回类型改成Value用std::variant承载double和bool。第三逻辑短路求值。左边为 false 时右边根本不应该求值因为右边可能有未定义变量。这意味着interpret()需要支持“有条件地不执行子树”你可以在逻辑节点里先求左值再决定是否求右值而不是简单地把两边都算完再合并。这里我要特别提一下std::variant。C17 之后它非常适合用来做返回值类型。如果你继续用double硬扛那age 18这种表达式的值只能用 0/1 表示语义上很别扭。用std::variantdouble, bool或者自定义的Value结构体代码的可读性和扩展性都会好很多。5.2 解释器模式在真实项目中的位置我见过不少人在规则引擎里用解释器模式效果很好。比如一个风控系统风控策略要以文本形式配置运营人员写amount 10000 risk_score 0.5系统需要解析这条规则并对每笔订单求值。这里的做法是策略文本用解释器模式解析成 ASTAST 缓存下来订单数据作为变量环境传入interpret()每单一次求值。因为有缓存解析成本只付一次最终性能完全可以接受。另一个典型场景是SQL 风格条件的解析。嵌入式设备或内存数据库里经常需要支持select * from table where age 18这种过滤条件。你不需要接一个完整的 SQL 引擎只需要把age 18这一小段条件用解释器模式解析成 AST然后让每行数据带进来求值。这个模式在处理“结构化文本 → 可执行逻辑”时比正则匹配、字符串拼接等硬编码方式优雅得多。对比热词里提到的“TDengine C 绑定写入数据库”这类真实开发任务你会发现数据库交互本身是另一套体系但数据过滤规则、标签表达式这类需求解释器模式经常作为中间层出现。它不是万金油但在“小语言解析”领域它的代码组织方式最贴合需求。5.3 什么时候用解释器模式什么时候不该用最后说点得罪人但实在的话。有几种情况你不应该用解释器模式表达式语法特别简单比如只有加法和乘法没有变量和括号。那你手写一个几十行的循环就够了引入类层次完全是过度设计。需求只有一个固定表达式比如代码里唯一的公式是price * 0.9。那直接写计算别折腾 AST。语法复杂到接近一门通用语言比如要有函数定义、循环、作用域、闭包。解释器模式硬写下去会变成一座无法维护的屎山这时候直接引入 Lua、Python 嵌入或者用 ANTLR 生成真正的解析器是更理性的选择。性能是绝对瓶颈又缺乏优化预算。解释器模式的开销再优化也有上限如果单次求值需要在微秒级完成建议直接生成代码或字节码。反过来说当你的场景满足这三个条件解释器模式就是首选语法规则有限且稳定以后大概率要扩展需要把“规则文本”和“执行逻辑”解耦。比如一个规则引擎久久变一次文法、但规则内容天天变那解释器模式的价值就非常明显。在 C 里实现解释器模式整个过程从头到尾其实是在做三次“翻译”字符串到令牌、令牌到 AST、AST 到结果。这三层各司其职、逐层解耦会让你的代码结构非常清晰。我个人在实际操作中还有一个调试小技巧分享给你给Expr加一个to_string()纯虚函数让每个节点能输出自己的子树结构。当 AST 组合出错时比如括号位置不对、优先级串了把表达式解析后的树打印出来一眼就能看出问题在哪。你完全可以用缩进来表示层级( (1) (* (2) (3)))这个小函数写起来只要二十分钟但排查问题节省的时间是几何级数的。等你真的在项目里用到解释器模式会发现最花时间的往往不是写解释器本身而是排查“为什么解析出来的树和我设想的不一样”——有这棵树的文本表示整个调试过程就完全不同了。
返回列表