如果要给 C 的设计模式排一个“听说过的人多、真正上手用过的人少”的榜单解释器模式绝对能进前三。GoF 那本书把二十多个模式写得明明白白可大家一翻到解释器模式这章多半的反应是这不就是写编译器吗我平时又不去造一门语言学了能干嘛我之前也这么想直到某次需要给项目做一个动态规则引擎被逼着用 C 实现了一个可配置的表达式计算器才真正明白这个模式的底气在哪里——它把一段“可以在运行时被解释执行的文本”变成一棵语义明确的对象树让程序自带一门微型 DSL。这篇不堆理论直接讲怎么在 C 里把解释器模式写出来、跑起来再把我实际踩过的坑一并交代清楚适合所有写过一点 C、想在项目里做规则配置或小语言解析的人。1. 解释器模式到底是什么为什么它值得专门写一篇1.1 模式的定义与四个关键角色解释器模式的定义很简短给定一个语言定义它的文法的一种表示并定义一个解释器这个解释器使用该表示来解释语言中的句子。翻译成人话就是——你要处理一段有固定语法规则的文本那就把这个文本先切碎再拼成一棵树最后在树上做递归计算。这个过程中会出现四个固定角色抽象表达式AbstractExpression声明一个解释操作的公共接口在 C 里通常是一个带虚函数的抽象类。终结符表达式TerminalExpression对应语法规则中不能再往下拆的原子元素比如一个数字、一个变量名。非终结符表达式NonterminalExpression对应语法规则里的组合规则比如“两个表达式相加”、“括号括起来的表达式”。上下文Context保存解释过程中需要用到的全局信息最常见的例子就是变量表。生活化一点理解把“3 5 * 2”当成一句话词法层把这句话拆成“3”“”“5”“*”“2”这些单词语法层按规则判断谁是主语、谁是谓语最后构成一棵语法树。解释器模式的全部工作就是完成从“字符串”到“语法树”再到“结果”的两次转换。第一次转换在 C 里通常叫解析parsing第二次转换就是递归求值。1.2 它和编译器的关系以及 C 里为什么少见很多教材提到解释器模式都会说它是编译器前端的基础。这话对但不全面。编译器和解释器最大的共同点是都需要先把源代码变成抽象语法树AST。区别在于后续动作编译器遍历 AST 生成目标代码解释器直接在 AST 上求值。模式书里那个经典例子是“布尔表达式求值”本质上就是一棵 Mini 语言 AST 的解释执行跟编译器做的事情是同一套思维方式。那 C 跨入实际工程后为什么大家很少主动用这个模式我总结了三个现实原因。第一C 项目的定位偏向系统级和性能敏感型解释器模式天然带一层虚函数分发和递归调用开销量虽然不大但架不住它把每步计算都变成了动态调度在热点路径上确实不合适。第二真正要解析复杂语言的时候工程上直接用 Flex/Bison、ANTLR、PEG 这类生成器更划算手写解析器反而容易出 bug。第三解释器模式最容易让人误解的一点它适合的是“文法简单但变化频繁”的场景可大部分 C 项目连“变化频繁的文法”都没有。需求不够自然就没人提。但这不代表它没用。恰恰相反你要是理解了它后面看 json 解析器、spdlog 的格式串解析、各种模板引擎的源码都会有一种“原来都是这套路”的豁然开朗感。1.3 什么时候该用什么时候千万别用我把自己的判断标准列成一个简单清单你可以直接对照适合用你需要提供一套可配置的规则或表达式且语法本身不复杂比如“金额 1000 且 状态 已审核”这种 DSL。适合用同样的语法会在多个地方出现你希望语法扩展时只要改一处。适合用你在写教学项目、验证某个编译原理想法需要一套手写可读的解析和求值流程。千万别用语法规则极其庞大甚至需要上下文敏感分析这时候老老实实上生成器或现成库。千万别用这段“解释执行”逻辑是性能瓶颈每秒钟要执行几十万次解释器模式会撑不住。千万别用你只是解析一两串固定格式文本写个正则匹配就够了没必要为它建一座 AST 大厦。我在实际项目里见过最合适的场景是配置中心的下发规则。线上跑着一套 C 服务运维需要动态调整“哪些请求要采样、哪些样本要丢弃”又不想发版。这时候在配置里放一段表达式服务启动时解析成 AST每次请求过来直接对 AST 求值就是解释器模式的标准用法。代码量不大但可维护性极高。2. 用解释器模式写一个四则运算表达式引擎2.1 整体设计从 Token 到 AST 再到求值的三层结构我建议你从一个最小但完整的四则运算表达式引擎入手目标很简单输入3 5 * 2 - 8 / 4输出11。这份代码虽然小但五脏俱全后续加变量、函数调用都在这套骨架上改。整个设计分三层词法层Lexer把原始字符串拆成 Token 流。Token 是词法单元包含类型和值例如数字3、运算符、左括号(。语法层Parser把 Token 流按文法规则组合成抽象语法树。这一步是解释器模式的核心它决定3 5 * 2到底理解成3 (5 * 2)还是(3 5) * 2。求值层Eval递归遍历 AST把每个节点算成具体数值。很多刚接触这个模式的人会犯一个错误一上来就写“语法分析”结果词法 Token 和字符判断混在一起最后表达式只要多一个空格就崩。正确做法是先把“切词”和“组树”彻底分开。词法层不过问语法规则语法层也不关心原始字符串里到底有几个空格职责边界清楚了代码才好维护。2.2 词法解析把字符串变成 Token 流先定义 Token 的类型和结构。C 里我通常用enum class表达 Token 类型这样比字符串比较要快得多也不容易打错字#include string #include string_view #include vector #include stdexcept #include cctype enum class TokenType { Number, Plus, Minus, Star, Slash, LParen, RParen, End }; struct Token { TokenType type; double value; size_t pos; };Token 里存一个pos用于记录它在原始字符串中的位置这个小字段在后续报错时非常有用后面会细说。接下来是 Lexer 类class Lexer { public: explicit Lexer(std::string_view src) : src_(src), pos_(0) {} std::vectorToken tokenize() { std::vectorToken tokens; while (true) { skipWhitespace(); if (pos_ src_.size()) { tokens.push_back({TokenType::End, 0.0, pos_}); break; } char c src_[pos_]; if (std::isdigit(static_castunsigned char(c)) || c .) { tokens.push_back(lexNumber()); } else { tokens.push_back({classify(c), 0.0, pos_}); pos_; } } return tokens; } private: void skipWhitespace() { while (pos_ src_.size() std::isspace(static_castunsigned char(src_[pos_]))) { pos_; } } Token lexNumber() { size_t start pos_; while (pos_ src_.size() (std::isdigit(static_castunsigned char(src_[pos_])) || src_[pos_] .)) { pos_; } std::string numStr(src_.substr(start, pos_ - start)); return {TokenType::Number, std::stod(numStr), start}; } static TokenType classify(char c) { switch (c) { case : return TokenType::Plus; case -: return TokenType::Minus; case *: return TokenType::Star; case /: return TokenType::Slash; case (: return TokenType::LParen; case ): return TokenType::RParen; default: throw std::runtime_error(std::string(unexpected character: ) c); } } std::string_view src_; size_t pos_; };注意lexNumber里我同时接受了数字和小数点这样3.14会被当成一个整体 Token而不是拆成3和.14。这里有个细节浮点数字符串里有多个小数点时std::stod不会直接报错它会解析到第二个点之前的位置造成静默错误。生产级的词法分析器要做更严格的小数格式校验但教学代码里这一步先用标准库兜底后面在测试阶段补上专门的规则就行。2.3 语法分析递归下降构建表达式树词法层把字符串切成 Token 流之后语法层要登场了。出发点还是那棵 AST一个节点要么是数字要么是两个子节点组成的二元运算。先用抽象基类定义接口#include memory #include map class Context { public: std::mapstd::string, double vars; }; class Expression { public: virtual ~Expression() default; virtual double eval(const Context ctx) const 0; }; class NumberExpression final : public Expression { double value_; public: explicit NumberExpression(double value) : value_(value) {} double eval(const Context) const override { return value_; } }; class BinaryExpression final : public Expression { char op_; std::unique_ptrExpression left_; std::unique_ptrExpression right_; public: BinaryExpression(char op, std::unique_ptrExpression left, std::unique_ptrExpression right) : op_(op), left_(std::move(left)), right_(std::move(right)) {} double eval(const Context ctx) const override { double l left_-eval(ctx); double r right_-eval(ctx); switch (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(unknown operator); } } }; class UnaryExpression final : public Expression { char op_; std::unique_ptrExpression operand_; public: UnaryExpression(char op, std::unique_ptrExpression operand) : op_(op), operand_(std::move(operand)) {} double eval(const Context ctx) const override { double v operand_-eval(ctx); return op_ - ? -v : v; } };这里BinaryExpression就是非终结符表达式它组合两个子表达式NumberExpression是终结符表达式是递归的出口。把Context设计成独立结构意味着后面要支持变量、函数表只要往Context里加成员即可不需要改 AST 节点定义。真正考验功力的是 Parser。递归下降解析器要按照“优先级从低到高”来分层加减法在低优先级层乘除法在中优先级层数字和括号在高优先级层。我写成三个函数class Parser { std::vectorToken tokens_; size_t cur_ 0; public: explicit Parser(std::vectorToken tokens) : tokens_(std::move(tokens)) {} std::unique_ptrExpression parse() { auto expr parseExpr(); expect(TokenType::End); return expr; } private: std::unique_ptrExpression parseExpr() { auto left parseTerm(); while (matchOneOf({TokenType::Plus, TokenType::Minus})) { char op prev().type TokenType::Plus ? : -; auto right parseTerm(); left std::make_uniqueBinaryExpression(op, std::move(left), std::move(right)); } return left; } std::unique_ptrExpression parseTerm() { auto left parseFactor(); while (matchOneOf({TokenType::Star, TokenType::Slash})) { char op prev().type TokenType::Star ? * : /; auto right parseFactor(); left std::make_uniqueBinaryExpression(op, std::move(left), std::move(right)); } return left; } std::unique_ptrExpression parseFactor() { if (match(TokenType::Minus)) { auto operand parseFactor(); return std::make_uniqueUnaryExpression(-, std::move(operand)); } if (match(TokenType::Number)) { return std::make_uniqueNumberExpression(prev().value); } if (match(TokenType::LParen)) { auto expr parseExpr(); expect(TokenType::RParen); return expr; } throw std::runtime_error(unexpected token); } bool match(TokenType tt) { if (peek().type ! tt) return false; cur_; return true; } bool matchOneOf(std::initializer_listTokenType types) { for (auto t : types) { if (peek().type t) { cur_; return true; } } return false; } const Token peek() const { return tokens_[cur_]; } const Token prev() const { return tokens_[cur_ - 1]; } void expect(TokenType tt) { if (peek().type ! tt) { throw std::runtime_error(expected a different token); } cur_; } };这段代码有个关键细节加减法我用while循环而不是递归来解析。如果你刚开始学很可能写成auto left parseExpr();这就变成了“先递归调用自己”遇到35会无限递归直到栈溢出。左结合运算符的正确处理方式就是“先解析一个高优先级的项然后循环匹配后续的同级运算符”。这也是为什么3 - 2 - 1会正确算出0而不是被诡异结合成3 - (2 - 1)。乘除同理。优先级表可以直接用这张表理解示例表达式正确解析结果说明3 5 * 23 (5 * 2)结果 13乘除优先于加减(3 5) * 2(3 5) * 2结果 16括号优先级最高-3 5(-3) 5结果 2一元负号优先级高于加减2 * -32 * (-3)结果 -6一元负号可以出现在乘法因子位置2.4 求值多态分发与 Context 的作用AST 建好之后求值本身特别简洁。你只需要调用Expression::eval每个节点自己知道怎么算自己。数字节点直接返回数值二元运算节点先求左右子节点再计算一元负号节点翻转符号。这种“消息发给谁谁自己处理”的调度方式是解释器模式最大的特点和优点新增语法节点时旧的节点类完全不用改。我用一个主函数把整套流程串起来#include iostream int main() { std::string input 3 5 * 2 - 8 / 4; Lexer lexer(input); auto tokens lexer.tokenize(); Parser parser(std::move(tokens)); auto ast parser.parse(); Context ctx; std::cout input ast-eval(ctx) std::endl; return 0; }跑一下终端会输出3 5 * 2 - 8 / 4 11。到这里一个最小可用的解释器模式就算落地了。为了让你看得更清楚我再展开一句Parser 返回的unique_ptrExpression就是那棵 AST 的根节点它的eval会递归触发整棵树的计算。整个流程没有任何全局状态也没有魔法字符串所有信息都封装在对象和Context里。项目里面如果有地方需要“解析一段表达式并在多处复用”把这个ast指针缓存起来每次求值传入不同的Context就行性能也比每次重新解析整段文本高得多。3. C 实现中的高级技巧与踩坑3.1 智能指针与对象生命周期管理如果你照着上面代码写会发现所有 AST 节点都靠std::unique_ptr持有这绝不是偷懒而是深思熟虑后的选择。第一AST 是严格的“一棵树”每个节点只有一个父节点天然符合独占所有权语义用unique_ptr不会出现两个父节点同时持有一个子节点的问题。第二不用手动delete析构时整棵子树自动释放不会泄漏。第三不会出现循环引用——树是单向的父持有子子不持有父所以不需要shared_ptr。shared_ptr在这里反而会带来循环引用隐患和额外的原子操作开销完全没必要。我自己第一次手写解析器的时候用的是裸指针BinaryExpression构造函数里存Expression* left解析完手动delete。后来加需求时发现异常路径常常还没走到delete就直接抛异常了内存泄漏冒出来还不好查。换成unique_ptr之后异常安全直接由 RAII 保证这个苦我替你吃过了别走回头路。再补充一个细节Expression的析构函数必须是virtual。否则你通过基类指针删除一个派生类对象时行为是未定义的。类里有虚函数时编译器会自动生成虚表但析构函数如果不标virtual删除操作就不会走派生类析构。这个错误很隐蔽短项目里可能不报错但在复杂继承体系下迟早出事。3.2 用 std::variant 替代继承的小尝试C 没有强推“继承是唯一解”。到了 C17 之后std::variant可以给我们第二种实现思路把 AST 节点定义成一个结构体里面放一个std::variant然后在std::visit里完成求值。思路长这样#include variant #include string #include memory struct ExprNode; using ExprPtr std::unique_ptrExprNode; struct NumberValue { double value; }; struct VariableRef { std::string name; }; struct BinaryOp { char op; ExprPtr left; ExprPtr right; }; struct ExprNode { std::variantNumberValue, VariableRef, BinaryOp data; }; double evalNode(const ExprNode node, const Context ctx) { return std::visit( [](const auto d) - double { using T std::decay_tdecltype(d); if constexpr (std::is_same_vT, NumberValue) { return d.value; } else if constexpr (std::is_same_vT, VariableRef) { auto it ctx.vars.find(d.name); if (it ctx.vars.end()) throw std::runtime_error(undefined var); return it-second; } else if constexpr (std::is_same_vT, BinaryOp) { double l evalNode(*d.left, ctx); double r evalNode(*d.right, ctx); if (d.op ) return l r; if (d.op -) return l - r; if (d.op *) return l * r; if (d.op /) { if (r 0.0) throw std::runtime_error(div by zero); return l / r; } } throw std::runtime_error(invalid node); }, node.data); }这种写法有一个明显优势求值函数集中在一个std::visit里你一眼能看全所有节点类型的分支逻辑不用像继承那样跑到各个类的eval里去翻。缺点是递归类型必须借助unique_ptr制造间接层稍微绕一点另外如果节点类型很多std::visit的编译时间会有点难熬。我会在小项目或原型阶段用variant一旦节点数量膨胀、逻辑复杂度上来还是回到继承方案更稳毕竟“对扩展开放”这点是类层次结构的传统强项。3.3 性能优化变量表查找、表达式缓存、避免深拷贝解释器模式虽然不像手写汇编那么追求极致但在 C 里做性能优化还是有不少空间。先说变量表查找上面Context用的是std::mapstd::string, double它是有序容器每次查找是 O(log n)。如果变量不多还好但一旦变量数量上百查找开销就很明显。建议改成std::unordered_map它在平均情况下是 O(1) 查找。如果变量名是编译期固定的甚至可以给每个变量分配一个整型 ID用std::vectordouble做变量表查找就是一次数组索引。这是很多脚本引擎的实际做法。再说表达式缓存。解释器模式最大的性能陷阱是“同一段文本每次都重新解析”。比如配置项里有一条规则price * count - discount每次请求进来都走一遍 Lexer Parser纯属浪费。正确做法是把 Lexer 和 Parser 的结果缓存下来后面只复用 AST。如果表达式字符串变了才重建 AST不变就直接eval。这在实际工程里往往能省下 80% 的解析开销。最后是避免递归求值时的隐式拷贝。别在eval的参数里传Context的值传const Context引用别让eval返回一个对象再拷贝直接返回double。这些听起来是常识但解释器模式因为递归调用太多任何一点隐式拷贝都会被放大。有一次我图方便把Context按值传进eval变量一多每层递归都拷贝一份 mapCPU 直接飙起来。换成引用后问题立刻消失。3.4 错误处理怎样给出定位清晰的异常信息新手写解释器最容易在错误处理上栽跟头。明明用户输入的表达式漏了一个右括号程序却提示“unexpected token”并且不带任何位置信息用户根本不知道错在哪。我在这里踩过几回总结出一套实用做法。第一Token 里一定要带原始字符串位置。Token结构里的pos字段就是干这个用的。抛异常时把当前 Token 的pos带进异常消息甚至可以把源码片段裁一段出来附上^指示出错位置。比如throw std::runtime_error( parse error at position std::to_string(peek().pos) \n src_ \n std::string(peek().pos, ) ^ );这比冷冰冰一句“parse error”实用得多。不过上面的src_需要从 Parser 传入或者把它存进 Token 流里实际项目里可以按需求调整。第二区分“语法错误”和“运行错误”。漏括号、运算符位置不对属于语法错误除数为零、变量未定义属于运行错误。如果你用同一种异常类型丢出去用户很难判断是表达式写错了还是环境配置错了。建议定义两个异常子类比如ParseError和EvalError分别继承std::runtime_error调用方可以按需捕获。第三做好浮点精度预期。0.1 0.2在 IEEE 754 下不会精确等于0.3如果你拿比较就会踩坑。表达式引擎里涉及比较运算时建议用std::abs(a - b) 1e-9这种 epsilon 判断或者在展示结果时统一保留几位小数。4. 常见问题排查与实战经验4.1 常见问题速查表我在手写这个表达引擎以及后来做规则引擎的过程中整理了一份高频问题清单直接以表的形式给出来方便你排查现象常见原因解决办法3.14被拆成3和.14词法分析没有把小数点纳入数字扫描lexNumber 中同时接受数字和小数点但要注意格式校验括号不匹配却没有报错Parser 漏写了expect(RParen)处理完左括号后强制消费右括号3 4 * 2算出 14加减和乘除的解析层级放反了严格按 加减最外、乘除中层、因子最内 分层栈溢出直接崩溃解析左结合运算符时直接递归调用parseExpr左结合用 while 循环不进递归内存泄漏肉眼不可见裸指针加异常路径没清理全面改unique_ptr变量值一直不对Context每次请求时被重新构造缓存Context或只更新部分变量空字符串表达式崩溃词法层直接返回End语法层收到空树解析前检查 Token 数量至少要有有效 Token日志里出现奇怪字符源字符串含不可见控制字符词法层先把不可见字符过滤或报错4.2 解析括号优先级踩坑实录有一回我在给规则引擎加“与或非”逻辑把布尔运算嵌进已有表达式的语法树里。第一次写完发现1 2 与 3 4总是被解析成1 (2 与 3) 4结果乱七八糟。查了半天问题出在“比较运算”和“逻辑运算”被我写成了一个层级。递归下降解析器的铁律是不同优先级的运算符必须不同层级低优先级的在外层高优先级的在内层。比如算术四则运算的正确顺序是“加减在外层乘除在内层因子在最里层”。布尔运算加进来后正确层级应该是“或在外层与在中层比较更内层算术最细”。这个错我之所以记到现在是因为它非常隐蔽语法上不会报错但结果就是不对。排查这类问题时我建议你打印 AST 的树形结构而不是直接看结果。打印时可以自己写一个递归函数或者用 spdlog 输出每层节点的类型和值能很快看出来括号到底套在哪里。有一次我在验证2 * -3 4时就是靠打印 AST 发现一元负号被错误地挂到了(3 4)上面而不是挂在3上这直接暴露了我在parseFactor里的一元负号处理层级低了。4.3 扩展性如何加入变量、函数调用甚至自定义运算符解释器模式最让人上瘾的地方就是它的“生长能力”。四则运算跑通之后我几乎没费劲就给它加上了变量支持。词法层需要多识别一种Identifier类型遇到字母开头的连续字母数字串就生成一个标识符 Token语法层在parseFactor里遇到标识符时构造一个VariableExpression求值层在eval里从Context.vars查值。三步走完表达式price * count - discount就能跑了。要加函数呢在词法层继续新增Identifier后接左括号的分支在语法层新增一个FunctionCallExpression在eval里按函数名分发。要加自定义运算符呢要么在 Lexer 里映射符号要么用标识符作为中缀运算符的名字但要注意优先级表也要同步扩展。每加一种语法特性你都是在同一个模板里填空词法层多一种 Token、语法层多一种节点或规则、求值层多一种计算逻辑。这种结构化扩展方式是解释器模式区别于“硬编码字符串匹配”的最大价值。我曾经用这套结构给项目写过一个过滤条件 DSL支持变量、比较、与或非、括号总共只花了一天时间后面业务方提新规则时我只需要确认要不要新增运算符基本不用动旧的解析逻辑。4.4 与 C 生态结合的小心机在工程里解释器模式很少单独存在。我见过最典型的组合是“配置文本 解释器模式 回调”。服务启动时读入一段规则表达式解析成 AST 缓存起来业务请求进来时把请求参数填进Context然后对 AST 求值得到布尔结果决定走哪个分支。这种模式在规则引擎、灰度发布、数据清洗里都很常见。甚至你去看某些时序数据库的 C 客户端绑定里面处理过滤条件时也经常能看到类似的“表达式解析 变量绑定”实现只是用户平常感知不到。如果你用 VS Code 写 C调试这类代码时可以给 AST 根节点打一个断点展开指针看整棵树的嵌套结构比单纯打印日志要直观得多。每层节点类型一目了然哪里挂错了立刻能发现。另外一个小建议给 Lexer 和 Parser 分别写独立的单元测试因为解释器的递归逻辑最容易在边界条件上翻车比如空输入、只有括号、连续运算符、末尾多一个点。我自己每次把测试用例一跑心里就有底了。再补充一个我在真实项目里学到的小技巧不要把解释器模式局限在“语言”里。任何“把一段结构化的输入转换成语义”的场景都可以借鉴它的分层思想。比如解析日期格式串yyyy-MM-dd、解析 SQL 的 WHERE 条件、解析模板字符串这些本质上都是一棵小语法树。理解了解释器模式你再看这类代码已经有“同构感”了。最后说一点个人体会写这个表达式引擎时我最大的收获不是背熟了“终结符、非终结符”这些名词而是学会了在任何复杂的字符串处理需求面前先问自己一句“这里是不是有一棵隐含的树”。如果有那就老老实实分层解析别用一堆 if-else 和正则表达式硬扛如果没有也别为了用模式而强行造树。解释器模式就像一把精细的雕刻刀用对了地方真能事半功倍但拿它去劈柴就纯属自讨苦吃了。回头你要是真的动手写一个建议从支持括号和变量的表达式开始跑通之后再慢慢往里面加你需要的“小语言”特性——那种看着自己的 DSL 一点点长大的感觉会让人上瘾。