ARTICLE DETAIL

资讯详情

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

C++解释器模式实战:手写四则运算表达式解析器

C++解释器模式实战:手写四则运算表达式解析器 1. 从一个业务场景开始解释器模式是什么写C的人多半会遇到这种需求一个配置规则、一段脚本、或者一个算式需要让用户在使用时自己输入下次维护时还要能改。硬编码判断当然能跑但if-else越堆越厚分支一多连自己都看不下去。这时候很多人会想到解释器模式。解释器模式在GoF设计模式里属于行为型模式它的核心思想是定义一种语言的文法并且为文法中的每一条规则都建立对应的表达式类最终把一条输入句子拆成一棵抽象语法树再让这棵树自己执行。换句话说你把“计算的逻辑”从“业务的判断”中剥离出来让每个节点都变成一个能自我解释的小对象。这个模式适合处理表达式求值、规则引擎的简单语言、SQL条件拼接等场景也是C面试里常被聊到的设计模式之一。这篇文章就围绕C中的解释器模式从原理讲起逐步手写一个可运行的四则运算解释器再分享我踩过的坑和实战中的变形。1.1 一个业务场景从“硬编码”到“可扩展”想象你接了一个计费系统的需求。刚开始只要支持金额乘以折扣率你写了一句double result amount * discount;。后来需求方说超过1000的部分要单独按0.8算于是你加了三元表达式再后来业务方想要支持自定义公式类似amount 1000 ? amount * 0.8 : amount * 0.9这种。你的代码开始变成一堆条件判断每加一个规则就要改核心函数回归风险越来越大。这就是解释器模式要解决的问题。它不是万能的但它把“一条规则/表达式”变成一组对象每个对象负责自己那部分的解释。当你需要新增一种语法比如加一个“取绝对值”的运算符只需要新增一个表达式类不需要动已有类。这种扩展性正是设计模式带来的核心价值。我再给一个更贴近生活的类比一个四则运算表达式1 2 * 3如果让你用人脑去算你自然会先乘除后加减。程序里如果只用一次函数直接return就行。但如果要处理任意长度、带括号、带变量的表达式硬编码就失控了。解释器模式把每个数字变成叶子节点把每个运算符变成分支节点整个表达式变成一棵树。计算时只需要从根节点开始每个节点都实现interpret()方法把两个子节点的结果做运算一层层返回答案就出来了。1.2 核心角色与边界在C里实现解释器模式通常涉及这几个角色AbstractExpression抽象表达式定义一个接口里面通常只有一个interpret操作。在C里我习惯用抽象基类比如class Expression { public: virtual int interpret(Context ctx) const 0; }。TerminalExpression终结符表达式文法中最小的单元。比如四则运算里的数字、变量名。数字节点直接返回它的值。NonterminalExpression非终结符表达式包含其他表达式组合比如加法、减法、乘法、除法。它的interpret负责递归解释子节点再把结果组合起来。Context上下文保存解释过程中的全局信息比如变量表std::unordered_mapstd::string, int或者一些配置项。它不是必须的但在带变量的表达式里很关键。这里要特别注意解释器模式并不是语法解析器。很多初学者把词法分析、语法分析和解释器模式搞混。解释器模式的输入是一棵已经构造好的抽象语法树AST它负责的是“解释”这棵树而不是“解析”字符串。换句话说解析字符串是Parser的工作AST构建之后用解释器模式去遍历求值是另一个阶段。虽然很多文章会把两者合在一起讲但心里要清楚边界。1.3 什么时候不该用解释器模式解释器模式有个明显缺点类的数量随文法规则数量线性增长规则一多类爆炸。而且每个表达式类都很小数量多就让项目结构显得琐碎。如果文法只有两三条或者表达式结构固定不变直接写遍历逻辑可能更简洁。另外解释器模式适合“语言规则稳定、执行方式频繁变化”的场景如果规则本身变化频繁那应该考虑规则引擎或者脚本语言而不是自己造轮子。C里如果要完整实现一个像样的表达式语言性能、错误处理、类型系统都要考虑复杂度远超一个设计模式。所以我的建议是小范围、稳定的自定义语言用解释器模式没问题一旦文法超过10条规则优先考虑ANTLR、Boost.Spirit这类现成工具或者内嵌Lua/Python脚本。2. C实现解释器模式的准备工作2.1 你需要理清的文法从BNF开始在动笔写代码之前先把文法用形式化描述写出来。我常用的写法是BNF巴科斯范式。比如我们要支持整数、加减乘除、括号、变量赋值那文法可以设计成这样expression :: term (( | -) term)* term :: factor ((* | /) factor)* factor :: number | identifier | ( expression ) assignment :: identifier expression这里有个经典坑如果表达式中出现expression :: expression term那就是左递归递归下降解析时会无限调用自己直接栈溢出。所以写成term (( | -) term)*这样的循环形式既能保持左结合又不会左递归。先写清楚文法后面实现解析器会非常顺。2.2 用类层级表达抽象语法树文法中的每个产生式对应一个表达式类。我的习惯是Expression抽象基类。NumberExpression叶子节点存一个整数。VariableExpression叶子节点存一个变量名解释时从Context查值。BinaryExpression这是非终结符节点的基类内部持有两个std::unique_ptrExpression分别对应左、右子节点再细分出AddExpression、SubtractExpression、MultiplyExpression、DivideExpression。为什么需要BinaryExpression因为加减乘除都有两个操作数解释逻辑都是“先解释左子节点再解释右子节点然后按运算符计算”。基类可以把“递归解释子节点”这个公共步骤固定下来子类只实现各自的运算。这样能减少重复代码也方便后续增加ModExpression、BitwiseAndExpression之类的新节点。2.3 上下文对象别把全局状态到处扔如果表达式里只有常量数字不需要Context。但一旦支持变量比如x 2 * y就必须有一个地方存变量值。把变量表做成Context在调用interpret时传进去好处是解释器不做全局状态假设同一个AST可以并发或多次用不同Context解释比如不同用户的环境变量不同。Context最简单的实现是struct Context { std::unordered_mapstd::string, int vars; 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; } void set(const std::string name, int value) { vars[name] value; } };这里要注意线程安全。如果多个线程共享同一个Context需要加锁或者使用thread_local副本。我在实际项目中往往直接传std::shared_ptrContext而不是裸指针生命周期管理更省心。2.4 内存管理的设计取舍C实现解释器模式最麻烦的是AST节点的生命周期。每个非终结符节点持有左右子节点最自然的做法是std::unique_ptrExpression谁持有谁释放树析构时自动递归释放不需要引用计数也不会循环引用因为方向是单向的父节点持有子节点。但如果你想让多个解释器共享同一棵AST比如缓存一个编译好的表达式那用std::shared_ptr更合适。代价是可能出现循环引用比如你在实现循环赋值语法时让某个节点反向引用父节点shared_ptr就会导致内存泄漏。我的原则是构建期用unique_ptr构建完之后如果需要共享再用shared_ptr转存别混用。C的智能指针对AST这种树状结构非常友好这也是相比Java、C#在解释器模式实现上的一个差异点。3. 手写一个四则运算解释器完整可运行3.1 定义表达式接口与节点类型先写抽象接口// expression.h #pragma once #include memory #include context.h class Expression { public: virtual ~Expression() default; virtual int interpret(Context ctx) const 0; }; using ExpressionPtr std::unique_ptrExpression;注意这里interpret的入参是Context而不是const Context因为赋值表达式需要修改变量表。如果只做只读求值写const Context也行但为了支持赋值语义我用非常量引用。然后是数字节点、变量节点和二元运算节点// nodes.h #include expression.h class NumberExpression : public Expression { public: explicit NumberExpression(int value) : value_(value) {} int interpret(Context) const override { return value_; } private: int value_; }; class VariableExpression : public Expression { public: explicit VariableExpression(std::string name) : name_(std::move(name)) {} int interpret(Context ctx) const override { return ctx.get(name_); } private: std::string name_; }; class BinaryExpression : public Expression { public: BinaryExpression(ExpressionPtr left, ExpressionPtr right) : left_(std::move(left)), right_(std::move(right)) {} int interpret(Context ctx) const override { int l left_-interpret(ctx); int r right_-interpret(ctx); return compute(l, r); } protected: virtual int compute(int l, int r) const 0; private: ExpressionPtr left_; ExpressionPtr right_; }; class AddExpression : public BinaryExpression { public: using BinaryExpression::BinaryExpression; protected: int compute(int l, int r) const override { return l r; } }; class SubtractExpression : public BinaryExpression { public: using BinaryExpression::BinaryExpression; protected: int compute(int l, int r) const override { return l - r; } }; class MultiplyExpression : public BinaryExpression { public: using BinaryExpression::BinaryExpression; protected: int compute(int l, int r) const override { return l * r; } }; class DivideExpression : public BinaryExpression { public: using BinaryExpression::BinaryExpression; protected: int compute(int l, int r) const override { if (r 0) throw std::runtime_error(divide by zero); return l / r; } };这里有一个小技巧BinaryExpression::interpret内部调用虚函数compute子类只需要实现运算规则。如果你需要支持取模、位运算只要新增一个子类就好。赋值表达式也可以作为一个非终结符节点不过它的子节点结构不一样所以单独实现class AssignmentExpression : public Expression { public: AssignmentExpression(std::string name, ExpressionPtr expr) : name_(std::move(name)), expr_(std::move(expr)) {} int interpret(Context ctx) const override { int v expr_-interpret(ctx); ctx.set(name_, v); return v; } private: std::string name_; ExpressionPtr expr_; };3.2 实现词法分析与语法解析解析器我用递归下降法。先做一个简单的词法分析把输入字符串拆成token数字、标识符、运算符、括号。核心是扫描字符合并连续数字或字母。简单实现如下enum class TokenType { Number, Identifier, Plus, Minus, Star, Slash, LParen, RParen, Assign, End }; struct Token { TokenType type; std::string text; int value 0; }; std::vectorToken tokenize(const std::string s) { std::vectorToken tokens; size_t i 0; while (i s.size()) { if (std::isspace(s[i])) { i; continue; } if (std::isdigit(s[i])) { int v 0; while (i s.size() std::isdigit(s[i])) { v v * 10 (s[i] - 0); i; } tokens.push_back({TokenType::Number, , v}); } else if (std::isalpha(s[i])) { std::string name; while (i s.size() (std::isalnum(s[i]) || s[i] _)) { name.push_back(s[i]); i; } tokens.push_back({TokenType::Identifier, name, 0}); } else { switch (s[i]) { 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; case : tokens.push_back({TokenType::Assign, }); break; default: throw std::runtime_error(unexpected char); } i; } } tokens.push_back({TokenType::End, }); return tokens; }然后写Parser。递归下降的核心是优先级嵌套class Parser { public: explicit Parser(std::vectorToken tokens) : tokens_(std::move(tokens)) {} ExpressionPtr parseAssignment() { if (peek().type TokenType::Identifier peek(1).type TokenType::Assign) { std::string name next().text; next(); // consume auto expr parseExpression(); return std::make_uniqueAssignmentExpression(std::move(name), std::move(expr)); } return parseExpression(); } private: const Token peek(size_t offset 0) const { size_t idx pos_ offset; if (idx tokens_.size()) idx tokens_.size() - 1; return tokens_[idx]; } Token next() { return tokens_[pos_]; } void expect(TokenType type) { if (next().type ! type) throw std::runtime_error(unexpected token); } ExpressionPtr parseExpression() { auto left parseTerm(); while (peek().type TokenType::Plus || peek().type TokenType::Minus) { Token op next(); auto right parseTerm(); if (op.type TokenType::Plus) left std::make_uniqueAddExpression(std::move(left), std::move(right)); else left std::make_uniqueSubtractExpression(std::move(left), std::move(right)); } return left; } ExpressionPtr parseTerm() { auto left parseFactor(); while (peek().type TokenType::Star || peek().type TokenType::Slash) { Token op next(); auto right parseFactor(); if (op.type TokenType::Star) left std::make_uniqueMultiplyExpression(std::move(left), std::move(right)); else left std::make_uniqueDivideExpression(std::move(left), std::move(right)); } return left; } ExpressionPtr parseFactor() { Token t next(); switch (t.type) { case TokenType::Number: return std::make_uniqueNumberExpression(t.value); case TokenType::Identifier: return std::make_uniqueVariableExpression(t.text); case TokenType::LParen: { auto expr parseExpression(); expect(TokenType::RParen); return expr; } default: throw std::runtime_error(unexpected token); } } std::vectorToken tokens_; size_t pos_ 0; };这种写法把优先级通过parseExpression-parseTerm-parseFactor的层级体现出来。加法优先级最低、最先被解析到树的顶层乘法次之括号和数字是叶子。3.3 解释执行遍历AST求值有了AST之后调用根节点的interpret就行int evaluate(const std::string source, Context ctx) { auto tokens tokenize(source); Parser parser(tokens); auto ast parser.parseAssignment(); return ast-interpret(ctx); }如果文法里没有赋值直接parseExpression()即可。解释执行的流程非常简单本质是后序遍历先算左子树再算右子树最后在根节点做运算。每层返回的值向上传递最终得到整个表达式的值。我在实际使用中发现给AST加一个dump()方法很有用能随时打印树结构。比如(1 2) * 3会打印成Multiply ├── Add │ ├── 1 │ └── 2 └── 3调试的时候拿这个跟你脑中的树一对比立刻能发现问题在哪。3.4 测试用例与运行效果我写了一个简单的main放了几个用例#include iostream #include vector #include parser.h int main() { Context ctx; ctx.set(x, 10); std::vectorstd::string tests { 1 2 * 3, (1 2) * 3, x 5, a x * 2 1 }; for (const auto s : tests) { try { std::cout s evaluate(s, ctx) std::endl; } catch (const std::exception e) { std::cout s error: e.what() std::endl; } } }运行结果1 2 * 3 7 (1 2) * 3 9 x 5 15 a x * 2 1 21注意最后一行的赋值语句我让AssignmentExpression在interpret时把表达式值写入Context并返回该值。这跟C赋值表达式的语义一致赋值表达式本身也有值。4. 解释器模式实战中的常见坑与排查实录4.1 优先级处理为什么我的解析结果不对最常见的bug是解析1 2 * 3得到9而不是7。原因大多是没有实现多级递归下降直接把所有运算符放在同一个循环里处理。比如你写了while (当前是或*) { left makeBinary(left, right); }那12先结合成(12)再乘3结果自然错。解决办法就是我前面说的把表达式拆成expression - term - factor三个层级。加法解析器只处理加减term只处理乘除factor是括号、数字、变量。这种层级天然实现了乘除高于加减、括号最高的规则。如果不小心把文法写成term :: factor (* factor)*也要注意左结合优先这通常靠循环内直接更新left实现。4.2 左递归问题与如何避免栈溢出递归下降解析最怕左递归。比如expr :: expr term | term在解析一个12时parseExpr会先调用parseExpr然后又调用parseExpr永远不消费token栈直接爆掉。解决方法是把左递归改成右递归或迭代。用expr :: term ((|-) term)*就没事了每次循环都先消费一个term结束后再决定是否继续不会无限制调用。如果你的语法确实需要左递归比如某些逻辑运算的结合性可以考虑在parser里加一个操作符栈先收集token再按优先级构造树或者直接用现成的解析器生成器。手写parser时记住一条铁律递归函数的第一个动作必须消费一个token或者终结符否则就是设计错误。4.3 智能指针循环引用与悬空指针很多新手喜欢在AST节点里保存父指针用来在做语法分析时跳回上层。比如在括号匹配时子节点想访问祖先节点。这样一不小心就会出现shared_ptr循环引用导致树无法析构内存泄漏。我的建议是AST节点只保存子节点不保存父节点如果确实需要父信息保存一个裸Expression*或者弱指针weak_ptrExpression并保证生命周期由唯一拥有者管理。还有一点不要在interpret里修改AST结构。解释器模式的核心是只读遍历如果你在执行阶段发现需要替换子树说明你的AST设计有问题应该先把优化做完再解释。4.4 调试技巧打印AST比打印调用栈更管用解释器的错误往往不是崩溃而是算错结果。这时候打印调用栈信息量太大反而看不出问题。我常用两个手段一是给每个节点加toString()或dump()输出树结构。 二是在BinaryExpression::interpret方法里加临时的日志输出打印每个节点的中间值比如输出left std::to_string(l) , right std::to_string(r)。等定位到问题后记得把这些临时日志去掉或者用宏控制输出。否则表达式一多控制台刷屏比业务日志还热闹。5. 进阶解释器模式在实际项目里的几种变形5.1 用std::variant干掉虚函数的继承树C17以后如果你的表达式类型不会动态增加可以用std::variant代替多态继承。比如struct Expr; using ExprVariant std::variantint, std::string, std::shared_ptrBinaryOp, std::shared_ptrVariable;然后定义BinaryOp里持有两个ExprVariant。遍历时用std::visit配合一个重载的callable实现类似访问者的效果。这种写法的好处是避免了虚函数开销和继承层级代码更紧凑坏处是扩展新类型需要改variant定义和所有访问点一旦表达式的类型集合不稳定维护成本会上升。我自己的经验是如果表达式节点类型稳定比如只做四则运算用std::variant非常清爽如果以后要不断加语法还是老老实实用继承。5.2 和访问者模式配合做类型检查与优化解释器模式本身适合“解释执行”但如果你需要做静态类型检查、符号表分析、常量折叠直接在interpret里做会很乱。经典做法是把解释器模式和访问者模式组合先用解释器模式的类结构表示AST再定义一系列访问者比如TypeCheckVisitor、ConstantFoldVisitor、PrintVisitor每个访问者通过visit重载处理每种节点。这样每个功能都集中在一个访问者类里不像在Expression基类里堆一堆纯虚函数。缺点是访问者模式的代码结构比较繁琐C里要用std::variant或std::any做分派或者手动实现accept函数。如果你只是做求值单用解释器模式就够了。5.3 直接上库Boost.Spirit还是ANTLR如果文法规格超过几十条手写解析器容易出错我强烈建议用现成的解析库。C社区里最常用的是Boost.Spirit尤其是Spirit X3版本它允许在C代码里直接内联文法描述生成AST再配合解释器模式执行。优点是零代码生成缺点是对编译器的元编程能力要求高编译错误信息惨不忍睹。如果你想要跨语言、能够生成完整解析器代码ANTLR更合适。用ANTLR定义.g4文件自动生成C词法器、语法器你只需要在生成的AST上实现自己的解释器。这适合构建正式语言的工具比如配置文件语法、DSL前端。但引入ANTLR会带来代码生成和构建链的复杂度小项目慎用。5.4 解释器模式与C新特性的融合C20的consteval、std::span、std::format这些特性可以让解释器实现更现代。比如用constexpr表达式求值处理编译期常量用std::string_view避免字符串拷贝用std::expected做错误处理替代抛异常提升性能敏感路径的稳定性。还有范围库ranges可以简化token流的遍历。但要注意C的模板和类型系统很强大也容易过度设计。我见过有人用模板元编程实现解释器每个运算符都是一个模板特化结果编译时间暴涨可读性极差。解释器模式本身就是面向对象的非要把它拧成纯模板的“编译期解释器”除了少数特化场景基本是自讨苦吃。6. 个人经验什么时候我真的会选解释器模式聊了这么多可能你已经发现解释器模式不是一个“银弹”它更适合解决问题规模固定、文法清晰的领域。我个人的判断标准很简单如果用户需要输入可配置的表达式而且这种表达式以后会升级比如加函数、加逻辑运算符我宁可花半天手写一套带解释器模式的四则运算器也不想在每个调用点堆条件判断。因为它把“解析”和“执行”拆开后续加规则时我只需要扩展节点类型和解析逻辑风险可控。另外如果表达式来源是配置文件我会格外谨慎。解释器模式让语法表达能力强了但错误处理也要跟上。变量未定义、除数为零、括号不匹配这些都得有清晰的错误提示否则用户拿一个配置文件来问你“为什么算出来是错的”你八成要花很长时间定位。我通常在interpret里抛自定义异常或者返回一个带有错误码的结果结构让调用方知道是词法、语法还是运行时错误。最后分享一个我摸索出的“小模式”不要让解释器直接接触业务对象。比如某个业务计算需要根据订单状态、VIP等级、折扣系数来算最终价格我不会让AST节点直接去查数据库。正确做法是先把业务数据一次性装配到Context里表达式只跟数字和变量名打交道。这样AST纯粹、可复用未来切换业务规则也不用动解释器核心。这也是解释器模式能真正提升可维护性的秘诀。
返回列表