不知道大家有没有遇到过这么一类需求业务方今天说提成公式要改成“阶梯式”明天又说要做“满减优先”后天干脆丢过来一张写满运算符的Excel表。一听这种需求第一反应往往是“又要写一堆if else了”可问题是规则变化的频率远超发版节奏逻辑写死在代码里每次改需求都像在给一个已经完工的楼里重新排管道又痛又蠢。这种场景正是解释器模式最能发光的地方。解释器模式是行为型设计模式中的一员核心思路是“定义一门语言并为这门语言提供解释执行的能力”。放在C里它意味着我们把“一段字符串写法”拆成“一棵语法树”再遍历这棵树完成计算、校验、转换。这篇文章会从需求出发带大家完整实现一个支持四则运算和括号的表达式计算器再把解析、求值、内存管理、性能权衡、常见坑一场场讲透。无论你是刚接触设计模式还是在真实项目里被表达式解析折磨过我相信这篇内容都能给你一点可复用的参考。1. 解释器模式到底在解决什么问题1.1 当计算逻辑需要“活”起来写业务代码时我们习惯把逻辑“固化”成代码分支。比如一个积分系统里用户等级的算法是“消费满1000元升一级”那代码里多半是这样if (total 1000) { level 2; }看似没问题可一旦运营说“下个月改成消费满1500且连续活跃30天才能升级”代码又要改。一次两次可以忍但如果这种规则隔三差五就变代码里就会堆满各种分支条件维护成本直线上升。解释器模式换个思路不把规则写成代码而是把规则写成“数据”。用户提供一个字符串表达式程序负责解析并执行。比如上面的规则可以改写成表达式total 1000 ? level 2 : level 1甚至直接用更友好的公式语法。这样一来规则的调整不再需要重新编译、重新发版改一段文本就行。本质上解释器模式是在系统与需求方之间插入了一层“可编程的语言层”。1.2 从表达式到抽象语法树要理解解释器模式就不能绕开“抽象语法树”这个概念也就是我们常说的 AST。拿最简单的四则运算举例1 2 * 3这串字符人一眼能看出先算乘法但计算机怎么知道程序需要先把这串字符拆解成一颗树。是根节点它的左右子树分别是1和2 * 3而2 * 3本身又是一棵以*为根、左右子节点为2和3的小树。当我们要计算结果时从根节点开始递归先算左右子树再把结果做加法于是自然实现了“先乘除后加减”的优先级规则。这就是解释器模式的核心——每个语法规则对应一个类每个表达式变成一个对象图。之后无论是计算结果、打印公式还是做语法检查都变成了一次对对象图的遍历逻辑清晰扩展性也强。很多第三方数学库、规则引擎内部都藏着这么一棵 AST而解释器模式就是构造这棵树的经典手法。2. 从一个表达式求值器开始领域拆解与架构设计2.1 我们先定义支持的语言这篇文章里的“语言”不打算搞得多复杂目标是做出一个“能用在生产环境边缘、但演示起来足够清晰”的四则运算表达式求值器。它需要支持以下几种元素数字整数和浮点数四种运算符加、减-、乘*、除/括号(和)用于改变运算优先级空白字符例如空格、Tab解析时可以跳过所有的表达式都遵循一个简单的文法我习惯先把这个文法写下来再开始写代码。这里用的是一种上下文无关文法的简化表述expression :: term (( | -) term)* term :: factor ((* | /) factor)* factor :: number | ( expression )你可能注意到expression里引用了termterm里引用了factor而factor又可以通过括号重新回到expression。这三层循环不是随便写的它正好对应了运算优先级加法和减法处于最外层优先级最低乘法和除法在中间括号和数字是原子优先级最高。这种递归定义还有个名字叫“递归下降文法”。写解析器的时候每一层文法对应一个函数函数之间相互调用形成一条天然的解析链条。2.2 模块怎么划分我把整个求值器拆成四个模块职责严格分开模块职责对应解释器概念Tokenizer词法分析器把字符串拆成最小的语义单元Token比如数字、加号、左括号终结符识别Parser语法解析器根据文法把Token序列构造成AST非终结符的展开AST节点描述表达式结构的对象层次文法中的符号Evaluator求值器遍历AST并返回计算结果解释动作的实现在实际工程里有人会把 Tokenizer 和 Parser 合在一个类里也有人会把 AST 节点的求值方法直接放进节点类。我的建议是如果你的项目是教学、工具类场景可以合如果要做业务规则引擎务必拆分。拆分的优点在于之后你想换一个求值方式比如先把AST编译成字节码再执行AST 这部分完全不用动。2.3 Tokenizer 的实现细节Tokenizer 的作用很纯粹忽略空白字符识别出数字和符号。下面是基于 C17 的实现#include string #include vector #include cctype #include stdexcept #include variant enum class TokenType { Number, Plus, Minus, Multiply, Divide, LParen, RParen, End }; struct Token { TokenType type; double value; // 当 type 为 Number 时有效 }; std::vectorToken tokenize(const std::string input) { std::vectorToken tokens; size_t i 0; while (i input.size()) { char c input[i]; // 跳过空白字符 if (std::isspace(static_castunsigned char(c))) { i; continue; } // 识别数字支持小数 if (std::isdigit(static_castunsigned char(c)) || c .) { size_t start i; bool has_dot false; while (i input.size()) { char d input[i]; if (std::isdigit(static_castunsigned char(d))) { i; } else if (d . !has_dot) { has_dot true; i; } else { break; } } tokens.push_back({TokenType::Number, std::stod(input.substr(start, i - start))}); continue; } // 识别四则运算符和括号 switch (c) { case : tokens.push_back({TokenType::Plus, 0}); break; case -: tokens.push_back({TokenType::Minus, 0}); break; case *: tokens.push_back({TokenType::Multiply, 0}); break; case /: tokens.push_back({TokenType::Divide, 0}); break; case (: tokens.push_back({TokenType::LParen, 0}); break; case ): tokens.push_back({TokenType::RParen, 0}); break; default: throw std::runtime_error(std::string(Unexpected character: ) c); } i; } tokens.push_back({TokenType::End, 0}); return tokens; }一个小细节是我在最后塞了一个End类型 Token。很多新手写 Tokenizer 时容易忽略“输入结束”这个概念解析器在递归下降时需要依靠End来判断是否已经阅读完所有 Token。没有它判断“表达式是否完整”会变得别扭。关于数字识别我特意处理了小数点。.5这种写法也能被识别虽然现实中不常见但解析器的健壮性往往就体现在这些边角处理上。如果你需要支持科学计数法比如1e-3解析逻辑还要调整但那属于另一个话题了。2.4 AST 节点的类层次设计AST 的节点类型不需要太多但它们之间的关系很关键。我设计一个基类Expr让所有节点继承它再重写虚函数返回计算结果class Expr { public: virtual ~Expr() default; virtual double eval() const 0; }; class NumberExpr final : public Expr { private: double value_; public: explicit NumberExpr(double value) : value_(value) {} double eval() const override { return value_; } }; class BinaryOpExpr final : public Expr { private: char op_; std::unique_ptrExpr left_; std::unique_ptrExpr right_; public: BinaryOpExpr(char op, std::unique_ptrExpr left, std::unique_ptrExpr right) : op_(op), left_(std::move(left)), right_(std::move(right)) {} double eval() const override { double l left_-eval(); double r right_-eval(); switch (op_) { case : return l r; case -: return l - r; case *: return l * r; case /: if (r 0.0) { throw std::runtime_error(Division by zero); } return l / r; default: throw std::runtime_error(Unknown operator); } } };这里所有的 AST 节点都通过std::unique_ptr持有子节点充分利用了 RAII 的机制。只要根节点析构整棵树的子节点都会自动释放不需要手写析构函数更不需要第三方指针。这算是 C 实现解释器模式时相比 Java/C# 最舒服的一点Java 里你得担心循环引用和 GC 停顿C 用智能指针一步到位。如果从设计模式的角度去对应NumberExpr就是“终结符表达式”它不再有子节点BinaryOpExpr是“非终结符表达式”它组合了多个子表达式。解释器模式里的“组合模式”影子在这里体现得很明显没有组合关系AST 根本构造不起来。3. 递归下降解析从 Token 流到 AST 的完整实现3.1 三个函数如何层层递进有了 Tokenizer 和 AST 节点下一步就是让 Parser 把 Token 流变成 AST。核心逻辑就是根据 2.1 节的文法实现三个互相调用的解析函数。class Parser { private: const std::vectorToken tokens_; size_t pos_ 0; Token peek() const { return tokens_[pos_]; } Token advance() { return tokens_[pos_]; } bool match(TokenType type) { if (peek().type type) { advance(); return true; } return false; } std::unique_ptrExpr parseExpression() { auto left parseTerm(); while (true) { if (match(TokenType::Plus)) { auto right parseTerm(); left std::make_uniqueBinaryOpExpr(, std::move(left), std::move(right)); } else if (match(TokenType::Minus)) { auto right parseTerm(); left std::make_uniqueBinaryOpExpr(-, std::move(left), std::move(right)); } else { return left; } } } std::unique_ptrExpr parseTerm() { auto left parseFactor(); while (true) { if (match(TokenType::Multiply)) { auto right parseFactor(); left std::make_uniqueBinaryOpExpr(*, std::move(left), std::move(right)); } else if (match(TokenType::Divide)) { auto right parseFactor(); left std::make_uniqueBinaryOpExpr(/, std::move(left), std::move(right)); } else { return left; } } } std::unique_ptrExpr parseFactor() { if (match(TokenType::Number)) { return std::make_uniqueNumberExpr(tokens_[pos_ - 1].value); } if (match(TokenType::LParen)) { auto expr parseExpression(); if (!match(TokenType::RParen)) { throw std::runtime_error(Missing closing parenthesis); } return expr; } throw std::runtime_error(Unexpected token); } public: explicit Parser(const std::vectorToken tokens) : tokens_(tokens) {} std::unique_ptrExpr parse() { if (tokens_.empty()) { throw std::runtime_error(Empty expression); } auto ast parseExpression(); if (peek().type ! TokenType::End) { throw std::runtime_error(Unexpected trailing tokens); } return ast; } };这里最关键的一点是parseExpression和parseTerm中的while (true)循环。你可能以为解析1 2 3时只要调用一次parseTerm就够了但实际上加法是左结合的也就是说1 2 3应该被解析成(1 2) 3而不是1 (2 3)。对于浮点数的加减乘除结合方向对结果影响不大但一旦扩展到减法1 - 2 - 3结果就完全不同了。左结合决定了“从左到右”的计算顺序循环恰好实现了这一点。3.2 求值过程与运行时错误处理求值过程非常简单调用ast-eval()即可。但这背后有几个容易忽略的运行时问题。第一是除零。我在BinaryOpExpr::eval()里显式做了除零检查。有人会问为什么不依赖 IEEE 754 的 inf 语义因为在业务场景里公式结果变成inf后错误信息会失真用户根本不知道是“数据算错了”还是“规则配错了”。显式抛异常至少能让上游捕获到一个有明确信息的错误。第二是栈溢出。如果表达式非常长比如上万个括号嵌套eval()的递归深度会非常高。C 默认栈空间在 1MB 到 8MB 之间取决于操作系统和编译器配置递归每层占用一些栈空间一旦超过限制就会崩掉。对正规场景来说通常需要在解析阶段就限制表达式长度或深度而不是等到求值阶段才后悔。第三是错误定位。当前实现里抛出异常时只带了一条字符串如果表达式很长用户无法知道错误发生在哪里。实际项目中Token 结构里最好带上position字段异常信息里附上表达式片段和位置这样排错效率高得多。3.3 用 unique_ptr 管理 AST省心但要小心移动语义很多初学 C 写解释器的人会困惑std::unique_ptrExpr没有拷贝构造函数为什么上面的代码能正常编译运行关键在于所有节点构造时都用std::move转移所有权。比如auto left parseTerm(); auto right parseTerm(); left std::make_uniqueBinaryOpExpr(, std::move(left), std::move(right));BinaryOpExpr构造函数接收的是std::unique_ptr右值内部再用std::move存入成员变量。这样所有权在环节间流动单线程下表现良好。如果你发现自己写出“把同一个std::unique_ptr传给两个节点”的代码编译器会直接报错。这在 C 里是件好事它把“同一棵子树被两个父节点共享后可能导致重复释放”的隐患在编译期就拦住了。如果你确实需要同时用一棵 AST 做求值和打印不要共享同一个unique_ptr而是在打印函数里传指针。3.4 新增运算能力的扩展设计解释器模式最大的卖点之一是新增语法功能时不用大改原有代码。假设现在要支持%取模运算需要改三处Tokenizer 里识别%并返回对应 Token 类型。文法里在term层增加对%的匹配分支。BinaryOpExpr::eval()里增加%的计算逻辑。三步都不需要改动NumberExpr也不需要改动parseExpression和parseFactor。如果在工程里大量新增二元运算符你甚至可以把运算符和解析函数做成配置表用一个工厂来创建节点进一步降低维护成本。相比之下如果要支持负数作为一元运算符例如-3改动就更复杂一些需要引入UnaryOpExpr节点并在parseFactor里增加对-的匹配。这也解释了“文法设计决定了解析器复杂程度”这句话的实际含义。4. 性能、权衡与替代方案解释器模式不是万能钥匙4.1 解释器模式慢在哪用解释器模式解析并执行表达式性能天然比直接编译成机器码慢几个数量级。拿这个四则运算求值器来说解析1 2 * 3需要Tokenize遍历一遍字符串产生 5 个 Token。解析递归调用 parseExpression、parseTerm、parseFactor构造 3 个节点。求值从根节点递归向下访问每个节点一次。整体时间复杂度是 O(n)n 为 Token 数量看起来不错但注意常数项很大每次内存分配、虚函数调用、递归调用都会产生不小的开销。如果一段表达式在一秒钟内要被调用数万次解释器模式的性能可能成为瓶颈。4.2 优化策略缓存、预编译和字节码如果只是小规模使用上面 O(n) 的复杂度完全可以接受。但要是追求高性能有几条常见优化路径。第一是 AST 缓存。把“表达式字符串 - AST”的唯一映射关系存起来字符串没变就直接复用 AST。这在规则引擎里是最常用、性价比最高的一招很多时候性能瓶颈不在求值环节而在解析字符串。第二是预编译为字节码。解析阶段不再生成 AST而是生成一系列操作码比如PUSH 1、PUSH 2、PUSH 3、MUL、ADD。求值阶段用循环顺序执行操作码避免递归和虚函数调用。很多解释器框架包括一些脚本引擎都是这么做的。第三是模板元编程。如果表达式在编译期就能确定可以用 C 模板把表达式编译成类型生成极其高效的代码。但通用性很差而且表达式一旦运行期变化就失效了。4.3 什么时候不该用解释器模式解释器模式虽好但千万不要条件反射式地套用。下面几种场景就不太合适表达式本身几乎不变比如一个系统里只有三条固定计算公式直接从配置文件里读对应函数实现即可。性能极度敏感高频交易场景里每次计算都在关键路径上解释器模式的开销不能接受。语法极其复杂比如要支持一门完整的编程语言解释器模式会膨胀到难以维护这时应该考虑成熟的语法解析生成器和虚拟机方案。表达式简单到可以用查表替代例如“客户等级 消费金额所在区间”直接用二分查找反而快得多。判断标准可以概括成一句话“语法是否稳定规则是否多变性能是否敏感”。语法不稳定而规则多变的场景才是解释器模式的舒适区。4.4 现代 C 的另一种选择std::variant上面这套基于虚函数和继承的类层次是“教科书式”的实现。如果你用的是 C17 及以后的标准也可以尝试用std::variant来表示 AST 节点再配合std::visit做求值比如struct NumberExpr { double value; }; struct BinaryOpExpr { char op; std::unique_ptrExpr left; std::unique_ptrExpr right; }; using Expr std::variantNumberExpr, BinaryOpExpr;这种实现去掉了虚函数访问节点时通过std::visit做编译期模式匹配在某些负载下可能有更好的性能表现代码也更现代。不过std::variant要求所有类型完整定义又由于BinaryOpExpr里包含std::unique_ptr递归定义需借助前置声明和指针完成写法上比虚函数版本稍复杂。两种方案没有绝对的优劣取决于你的团队对 C 新特性的接受程度。5. 常见问题与排查技巧实录5.1 问题解析器吞掉了尾部的非法字符新手最容易犯的错误是忘了检查parse()结束后的 Token 类型。假如输入是1 2 xyz如果没有peek().type ! TokenType::End的检查解析器会解析出1 2然后把xyz抛在脑后。这种错误很隐蔽因为结果看起来也没啥问题但表达式其实是不完整的。解决方案就是我在parse()里加的那个检查if (peek().type ! TokenType::End) { throw std::runtime_error(Unexpected trailing tokens); }5.2 问题负数表达式解析失败假设用户输入-3 5我们的文法不认识开头的-。因为parseFactor只接受数字或左括号。解决办法有两种一种是在parseFactor里增加一元负号的处理if (match(TokenType::Minus)) { auto operand parseFactor(); return std::make_uniqueUnaryOpExpr(-, std::move(operand)); }另一种是在 Tokenizer 阶段当-的前一个 Token 是运算符或左括号或不存在时把它识别成“负号 Token”。我建议用第一种语义更清晰。5.3 问题深递归导致栈溢出某次测试时我输入了一个由 10000 个((((...1...))))组成的表达式。Tokenizer 和 Parser 都顺利通过结果在eval()里直接栈溢出程序崩溃。后来我在 Parser 构造函数里增加最大深度限制if (depth 500) { throw std::runtime_error(Expression too deeply nested); }实际生产的规则引擎表达式深度通常不会超过几十层设置一个上限完全合理还能防御恶意输入。5.4 问题除以零的错误信息没有上下文前面提到BinaryOpExpr::eval()抛出Division by zero时调用方很难知道是哪一段表达式出的错。实践中我通常让每个 AST 节点保存自己对应的源码片段异常时带着片段一起抛出类似Division by zero in expression: 1 / 0。5.5 测试策略从测试驱动看解析器正确性不管实现多么巧妙最终还是要用大量用例验证正确性。我习惯先把测试用例写成一个表格覆盖正常、边界和异常三类情况类别表达式期望结果正常1 2 * 37正常(1 2) * 39正常2 * (3 4) / 72边界1.5 2.253.75边界1 (2 * (3 (4)))15异常1 / 0抛异常异常(1 2抛异常异常1 抛异常写完基础用例后我会随机生成大量表达式用另一个更慢但更简单的解释方式或者直接用 Python 的 eval 做对照验证两者结果一致。自动化模糊测试虽然土但真的能揪出很多手写用例发现不了的问题。某人曾经在这上面栽过跟头因为一套表达式里出现了--2这种写法恰好被 Tokenizer 当成两个减号解析结果整条公式计算结果错得离谱排查了整整一个下午。6. 工程化改造从玩具到能用的解释器6.1 支持变量和自定义函数LeetCode 风格的表达式求值器只能算玩具真正要落地到业务系统几乎必然需要支持变量。比如提成公式里amount表示订单金额有就比没有实用得多。支持变量的思路很简单Tokenizer 识别标识符Parser 在parseFactor里遇到标识符时生成VariableExpr节点eval()时从外部传入的上下文一个std::unordered_mapstd::string, double里取数即可。我想强调一点千万不要让求值器自己去查数据库或外部配置这会严重耦合业务逻辑。正确的做法是调用方评估前把全部变量值准备好塞进上下文对象解释器只负责计算。6.2 错误报告先收集不要一错就停我们的解析器一遇到错误就抛异常这在交互式场景里不够友好。如果用户一次输入1 * 2我们希望报告“在位置5附近遇到了非法的运算符 *”而不是只给一句话。做法是定义一个ParseErrorCollector解析过程中遇到错误时先记录下来继续尝试解析其余部分最后统一抛出带全部错误的异常。我见过的许多商业规则编辑器都这么做用户打开后能一次性看到所有标红的提示。6.3 把 AST 序列化和反序列化另一个常见的需求是把用户配置的表达式持久化。把 AST 转成 JSON 存储下次再从 JSON 恢复这样既能方便版本对比也方便在不同模块间传递。序列化本质上是另一棵树遍历解释器模式已经提供了天然的对象树只要加上serialize()虚函数即可。这一步做完之后这个解析器的能力边界就从“表达式计算”扩展到了“规则配置平台”。7. 最后分享几条实操体会写到这里这个 C 解释器模式的项目实现和踩坑过程基本讲完了。我最后再分享几条比较实际的体会谈不上结论就是用过之后的一些积累。第一点设计模式落地时不要硬搬类图。解释器模式在经典书里长着一张“全是抽象类”的脸但到了现代 C 里用std::unique_ptr持有子节点、用虚函数eval()暴露行为已经完全够用。类图是给人理解用的不是给编译器看的。第二点写解析器一定要先写文法。我见过太多人上来就写 parser改来改去最后括号优先级都是错的。先把文法用 BNF 写到注释里再照着注释翻译成代码两个小时的活能压缩到二十分钟。第三点不要忽略异常路径的测试。解析器成功解析一千条表达式没什么值得骄傲的真正能证明工程质量的是面对一万条非法表达式时它能稳定地给出可读的错误信息而不崩溃。以上希望这篇手记能帮到正在写规则引擎、公式解析器或配置解释功能的你也欢迎在实践后回来聊聊你的实现思路。