ARTICLE DETAIL

资讯详情

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

从MSDN经典文档到实战:C#实现四则运算解释器模式

从MSDN经典文档到实战:C#实现四则运算解释器模式 1. 从 MSDN 的那篇经典文档说起解释器模式为什么被“劝退”提起 MSDN 上的设计模式文档解释器模式大概是劝退率最高的一篇。微软官方设计模式系列里对它的定义很精炼——给定一个语言定义它的文法的一种表示并定义一个解释器这个解释器使用该表示来解释语言中的句子。定义读起来不痛不痒但真要动手写代码很多人在第一步就卡住了什么是文法什么叫解释句子为什么对象图能表示一段表达式我当年第一次啃这篇文档时也有同样的困惑。文档里的类图逻辑上挑不出毛病——AbstractExpression、TerminalExpression、NonterminalExpression、Context 各司其职但抽象到一定程度就变成了屠龙之术看不到落地场景。后来在真实项目里遇到一种“别处改规则、代码不动”的需求才明白解释器模式真正的价值所在把一段需要频繁变化的规则从业务代码中剥离出来形成一套小型的、可解释执行的语法。换成人话说解释器模式就是给程序装上一个“理解句子的嘴”。你给它一段字符串它按照预先定义好的语法规则把这串文字翻译成一棵对象树然后逐个节点求值最终得到你想要的输出。这套机制不需要引入第三方的表达式引擎、模板引擎或者规则引擎纯靠面向对象的那套多态和递归就能实现。这就是为什么它在 GoF 经典设计模式里占了一席之地也是 MSDN 文档里把它归类为行为型模式的原因。需要先说明的是MSDN 在中文开发圈里还有个大众印象——不少新人会把微软开发者文档和某个叫“msdn 我告诉你”的镜像站混为一谈以为 MSDN 就是下载镜像的地方。其实 MSDN 全称 Microsoft Developer Network是微软官方的开发者文档体系设计模式、API 参考、技术文章都在里面。本文聊的是微软技术文档语境下解释器模式的官方讲解思路以及基于这个思路在真实工程里的用法。这篇文章适合谁看如果你正在学设计模式想搞清楚解释器模式到底是个什么东西或者你手头有类似“动态公式计算”“规则配置化”“小规模表达式解析”的需求又不想一上来就引重型框架那这篇文章能给你一套可直接抄作业的思路。我不打算复述文档里的标准类图而是直接用一份可运行的代码把 MSDN 那套抽象概念落到地面上。2. 拆开模式骨架文法、终结符、非终结符与抽象语法树要理解解释器模式先得理解“文法”这个词。文法不是英语语法那种语法它更像是一种配方规定了什么样的符号串是合法的句子。比如我们熟知的四则运算表达式可以用这样一段配方来描述expr : term (( | -) term)* term : factor ((* | /) factor)* factor : number | variable | ( expr )这种写法叫 BNF巴科斯范式每一行就是一个产生式。expr、term、factor这些左部符号叫非终结符因为它们还能继续展开成别的规则number、variable、、(这些不可再拆的符号叫终结符。文法描述了语言的全部合法句子而解释器模式的任务就是按照这套文法写一个程序让它可以“读懂”每一个合法句子。2.1 四个核心角色的职责划分MSDN 文档给出的角色划分非常清晰我在实际工程中也是照着这套职责拆的AbstractExpression抽象表达式定义解释操作的接口通常是一个Interpret(Context)方法TerminalExpression终结符表达式实现与文法中终结符相关的解释操作。它不需要再调用其他表达式是递归的出口NonterminalExpression非终结符表达式对应文法中的一条规则内部持有其他表达式递归调用它们的解释操作Context上下文存储解释器需要的全局信息比如变量值、符号表、日志状态。很多人写解释器时忘了这个角色把变量直接塞进表达式对象里结果同一个表达式在不同环境求值就只能重新构造非常别扭。初看这四类角色会觉得啰嗦一个简单的加法用得着拆这么多类吗但当你把文法扩展出变量、函数调用、布尔运算时终结符和非终结符的区分就成了组织代码的那条主线——每个文法规则对应一个类规则之间的组合关系变成了对象之间的引用关系程序结构和语法结构完全同构。2.2 从字符串到抽象语法树的两步走解释器模式的核心机制本质上分两步第一步把字符串拆成 token第二步把 token 序列组装成抽象语法树AST。AST 就是文档里说的“对该语言文法的表示”树上的叶子节点通常是数字、变量这些终结符内部节点是加、减、乘、除这些非终结符。构建好 AST 之后解释执行就变得很简单了从根节点开始每个节点把自己的子节点求值结果拼起来返回给父节点。这就是“组合 递归”的经典玩法。任何一个会写递归遍历二叉树的人都能看懂解释器模式的执行过程——它本质上就是一棵由表达式对象拼装起来的树每个对象都知道自己应该怎么解释自己。我特别喜欢解释器模式的一点是普通做法里“解析”和“执行”是焊死的但在这个模式中Interpret只是一个方法你完全可以替换成别的操作。同一棵 AST既可以做求值也可以做表达式重写、变量收集、语法树打印——只要在你的表达式类上增加新的操作逻辑就行。这也是为什么解释器模式经常和访问者模式Visitor搭配出现访问者模式负责在对象结构上增加新操作解释器模式负责定义和构建这个结构。3. 一份能跑的完整代码四则运算表达式解释器光讲概念等于没说直接上代码。我用 C# 实现一个支持变量、括号和四则运算的表达式解释器文法就是上一节那段 BNF。所有代码放在一起可以完整编译运行这是我断断续续改了快一周才定稿的版本核心思路和 MSDN 文档一致但细节上更适合工程使用。3.1 词法分析把字符串切成 token解释器第一步是词法分析。别被“词法分析”这个词吓到它的工作就是从头到尾扫描字符串把连续的数字、变量、操作符切分成一个个有意义的单元。比如输入price * 2 1分词后应该得到price、*、2、、1这五个 token。public enum TokenType { Number, Variable, Plus, Minus, Star, Slash, LParen, RParen, End } public class Token { public TokenType Type { get; } public string Text { get; } public Token(TokenType type, string text) { Type type; Text text; } public override string ToString() ${Type}({Text}); } public class Lexer { private readonly string _src; private int _index; public Lexer(string src) { _src src; } public ListToken Tokenize() { var tokens new ListToken(); while (_index _src.Length) { var current _src[_index]; if (char.IsWhiteSpace(current)) { _index; continue; } if (char.IsDigit(current)) { var start _index; while (_index _src.Length char.IsDigit(_src[_index])) _index; tokens.Add(new Token(TokenType.Number, _src[start.._index])); continue; } if (char.IsLetter(current)) { var start _index; while (_index _src.Length (char.IsLetterOrDigit(_src[_index]) || _src[_index] _)) _index; tokens.Add(new Token(TokenType.Variable, _src[start.._index])); continue; } switch (current) { case : tokens.Add(new Token(TokenType.Plus, )); break; case -: tokens.Add(new Token(TokenType.Minus, -)); break; case *: tokens.Add(new Token(TokenType.Star, *)); break; case /: tokens.Add(new Token(TokenType.Slash, /)); break; case (: tokens.Add(new Token(TokenType.LParen, ()); break; case ): tokens.Add(new Token(TokenType.RParen, ))); break; default: throw new Exception($无法识别的字符 {current}位置{_index}); } _index; } tokens.Add(new Token(TokenType.End, string.Empty)); return tokens; } }分词器的坑主要集中在数字识别和变量识别上。我在这里做了变量名允许包含数字和下划线的兼容处理比如total_price2会被整体当成一个变量名。工程里如果变量名允许更复杂的字符分词规则也得跟着改。另外要注意字符串切片_src[start.._index]在旧版 .NET Framework 里不支持用.Substring(start, _index - start)即可。3.2 抽象语法树终结符和非终结符的落地分词只是准备工作真正的模式核心在 AST 节点的设计上。我先定义抽象接口IExpression这是 MSDN 类图中的 AbstractExpression然后分别实现终结符表达式数字、变量和非终结符表达式加减乘除。public interface IExpression { double Evaluate(ExpressionContext context); }终结符表达式的实现简单得有点“配不上”它的名字public class NumberExpression : IExpression { private readonly double _value; public NumberExpression(double value) { _value value; } public double Evaluate(ExpressionContext context) _value; } public class VariableExpression : IExpression { private readonly string _name; public VariableExpression(string name) { _name name; } public double Evaluate(ExpressionContext context) { if (context.TryGetVariable(_name, out var value)) return value; throw new Exception($未定义的变量{_name}); } }数字表达式的Evaluate直接返回自身值这就是递归出口。变量表达式则需要查询上下文这也是为什么前面强调要独立设计 Context 角色——没有上下文变量表达式就只能闭死在某个环境里。非终结符表达式这边我先抽象一个二元运算基类把左右两个子表达式存下来具体的运算交给子类实现public abstract class BinaryExpression : IExpression { protected IExpression Left { get; } protected IExpression Right { get; } protected BinaryExpression(IExpression left, IExpression right) { Left left; Right right; } public abstract double Evaluate(ExpressionContext context); } public class AddExpression : BinaryExpression { public AddExpression(IExpression left, IExpression right) : base(left, right) { } public override double Evaluate(ExpressionContext context) Left.Evaluate(context) Right.Evaluate(context); } public class SubtractExpression : BinaryExpression { public SubtractExpression(IExpression left, IExpression right) : base(left, right) { } public override double Evaluate(ExpressionContext context) Left.Evaluate(context) - Right.Evaluate(context); } public class MultiplyExpression : BinaryExpression { public MultiplyExpression(IExpression left, IExpression right) : base(left, right) { } public override double Evaluate(ExpressionContext context) Left.Evaluate(context) * Right.Evaluate(context); } public class DivideExpression : BinaryExpression { public DivideExpression(IExpression left, IExpression right) : base(left, right) { } public override double Evaluate(ExpressionContext context) { var rightValue Right.Evaluate(context); if (rightValue 0) throw new DivideByZeroException(除数不能为 0); return Left.Evaluate(context) / rightValue; } }每个非终结符表达式的Evaluate都遵循同一种套路先分别求左右子节点的值再做本节点的运算。无限嵌套的(1 2) * (3 (4 / 2))都能通过这种递归方式正确求值因为你不需要关心子节点内部结构只调用它的Evaluate就行。这就是多态递归的威力。3.3 上下文角色把变量环境独立出来这里稍微停下来说说ExpressionContext。前面代码里多次用到它它的职责很简单保存变量名和值的映射关系并提供查询接口。public class ExpressionContext { private readonly Dictionarystring, double _variables new(); public void SetVariable(string name, double value) { _variables[name] value; } public bool TryGetVariable(string name, out double value) { return _variables.TryGetValue(name, out value); } }可能有人觉得这个类多余直接在VariableExpression里存一个Dictionary不行吗不行。设想一个业务场景同一套公式total * 0.8要给不同订单求值每个订单的total不同。如果变量值存在表达式对象内部表达式就只能在构造时绑定一个值失去“公式”的意义。有了独立 ContextAST 只需要构建一次然后可以反复传入不同上下文求值——这一层抽象让“公式”和“数据”彻底解耦了。MSDN 文档把 Context 单独列为角色不是没道理的它对应的是程序运行环境的概念。3.4 递归下降解析从 token 序列生成 AST分词得到 token接下来要按文法规则把它们组装成 AST。这里用递归下降解析法每个文法产生式对应一个方法public class Parser { private readonly ListToken _tokens; private int _pos; public Parser(ListToken tokens) { _tokens tokens; } public IExpression ParseExpression() { var expr ParseTerm(); while (IsMatch(TokenType.Plus) || IsMatch(TokenType.Minus)) { var op _tokens[_pos - 1]; var right ParseTerm(); expr op.Type TokenType.Plus ? new AddExpression(expr, right) : new SubtractExpression(expr, right); } return expr; } private IExpression ParseTerm() { var expr ParseFactor(); while (IsMatch(TokenType.Star) || IsMatch(TokenType.Slash)) { var op _tokens[_pos - 1]; var right ParseFactor(); expr op.Type TokenType.Star ? new MultiplyExpression(expr, right) : new DivideExpression(expr, right); } return expr; } private IExpression ParseFactor() { if (IsMatch(TokenType.Number)) return new NumberExpression(double.Parse(_tokens[_pos - 1].Text)); if (IsMatch(TokenType.Variable)) return new VariableExpression(_tokens[_pos - 1].Text); if (IsMatch(TokenType.LParen)) { var expr ParseExpression(); Expect(TokenType.RParen, 缺少右括号); return expr; } throw new Exception($意外的 token{_tokens[_pos]}); } private bool IsMatch(TokenType type) { if (_tokens[_pos].Type type) { _pos; return true; } return false; } private void Expect(TokenType type, string message) { if (!IsMatch(type)) throw new Exception(message); } }ParseExpression和ParseTerm的循环处理让加减乘除具有了正确的结合性和优先级乘法除法在ParseTerm这一层先被消化所以2 3 * 4会解析成2 (3 * 4)而不是(2 3) * 4。这个“先乘除后加减”的语义完全由解析方法的调用层级决定跟 AST 节点类型无关。使用方式也很直观var lexer new Lexer(price * 2 (base - 1)); var tokens lexer.Tokenize(); var parser new Parser(tokens); IExpression ast parser.ParseExpression(); var context new ExpressionContext(); context.SetVariable(price, 10); context.SetVariable(base, 5); double result ast.Evaluate(context); // 输出 24换个上下文同一个 AST 又可以给另一组变量求值。性能上解析构建 AST 的耗时只发生一次后续的求值就是纯递归压测下来一个几百节点的表达式求值在微秒级完全够用。4. 现实中的使用场景什么时候值得用解释器模式代码能跑通只是第一步更关键的问题是这个模式在真实项目里到底解决什么问题。我这些年接触到的解释器模式落地场景总结下来大概有六类但每一个都满足一个共同特征语言很小、语法稳定、变化频繁。三者缺一不可。4.1 我最常推荐的三个方向方向一规则引擎与计费配置。电商平台的活动规则经常变比如“满 300 减 50”“每件商品折扣率按区间浮动”。如果这类规则用 if-else 硬编码每次改活动都要改代码重新发布。用解释器模式把规则写成表达式字符串存进数据库规则变了只改配置代码不动。我做过一个优惠计算模块规则模板长这样subtotal * (category book ? 0.8 : 1.0)解释器负责解析并求值运营同学调整规则只动配置不改代码。方向二动态公式与指标计算。报表系统、指标平台里用户经常要自定义计算口径。比如“毛利 收入 - 成本”每个租户的口径还不一样。把计算口径存成表达式租户维度绑定不同上下文一套解析代码服务所有租户。这段描述不是设想是我实际做过的项目方案用这套思路让用户通过页面配置了上百个计算指标后续新增指标零代码上线。方向三查询 DSL 和小型脚本语言。很多系统允许用户输入紧凑的查询条件类似age 18 and (status active or level 3)。这种 DSL 的规模不大语法固定非常适合解释器模式。比起用正则硬抠解释器生成的 AST 还能衍生出“条件合法性校验”“查询语句格式化”等附加能力。4.2 用三个标准判断“该不该用”很多初学者问我是不是任何解析需求都套解释器模式当然不是。我给自己定了一个三步判断法判断项适合用不适合用语言规模10 个以内的产生式规则few dozen 个符号语法庞大、复杂嵌套比如通用编程语言变更多样性规则经常变化希望配置驱动规则写死一年改不了一次性能要求中等即可单次毫秒级能接受高并发、低延迟每请求成千上万次解释如果你要解析的语言超出这个范围建议直接上成熟的语法分析工具比如 ANTLR 或 Parser Generator而不是手写解释器模式。手写递归下降解析器的难度随文法规模指数级上升到后面你维护的不是业务逻辑而是编译器前端那是另一个领域的事了。4.3 很多资料没讲清楚的边界MSDN 文档很少提一句解释器模式是面向对象范式的产物它的直接竞争对手是“把数据当作数据处理的函数式写法”。比如上一节的四则运算用 C# 表达式树或者函数式组合也一样能实现而且代码更短。解释器模式的独特优势不在于“能解析”而在于“结构清晰且易扩展”——你要在 AST 上增加一种新的操作比如把表达式打印成 SQL 语句只需要新增一个访问者类不改动任何既有表达式节点。这种可扩展性是普通函数式写法很难提供的也是我最终选择解释器模式来做规则引擎的核心原因。5. 和替代方案怎么选正则、表达式树、ANTLR 还是手写 if-else到了选型层面我发现很多开发者的纠结不是“解释器模式好不好”而是“我到底要不要引入解析机制”。这个问题的答案因人而异但选型路径是清晰可循的。我把它分成四个台阶按需往上爬就行。5.1 正则表达式单行规则的首选如果规则只需要匹配某一种简单模式比如“手机号是 11 位数字”“文件路径以 .jpg 结尾”正则表达式是第一选择。正则的优点是快、短、内聚缺点是表达能力有限不适合嵌套结构和复杂算术逻辑。我见过有人用正则硬解析1 2 * 3写了四十多个字符不说还算错优先级。正则适合做词法层面的匹配不适合做语法层面的解析。5.2 C# 表达式树不是解释器模式但更现代. NET 生态里有一个跟解释器模式非常接近的机制——Expression Tree。它允许你在运行时构建表达式对象树然后编译成委托执行。比如你想动态生成price * 0.8 shipping这样的计算逻辑用表达式树写起来非常优雅。var priceParam Expression.Parameter(typeof(double), price); var multiply Expression.Multiply(priceParam, Expression.Constant(0.8)); var lambda Expression.LambdaFuncdouble, double(multiply, priceParam); var func lambda.Compile(); Console.WriteLine(func(100)); // 80表达式树的定位和解释器模式高度重叠都是把“逻辑”作为数据构建成树。区别在于表达式树是内建的、经过强类型检查的、可以编译为高效委托的而解释器模式的 AST 是你自己定义的、运行时的、解释执行的。能直接用表达式树解决的场景我不会去手写解释器模式但表达式树也有天花板——出错信息不友好、跨平台序列化麻烦一旦规则需要持久化存储表达式树就不好使了这时自定义 AST 反而更可控。5.3 ANTLR 这类生成器语法复杂时的正解再往上一个台阶是语法分析器生成器比如 ANTLR、Yacc、Bison。你只需要定义文法文件工具自动生成词法分析器和语法分析器代码。这套玩意的学习曲线陡峭得多但处理复杂语言的能力远超手写递归下降。如果你要解析的是一门接近完整编程语言的 DSL别硬用解释器模式写那会很痛苦——左递归、优先级层级、错误恢复机制、上下文相关语法每样都能耗掉你几周时间。5.4 手写 if-else 链最朴素但别羞于选择最后是手写 if-else。很多规则本质上只有两三个分支写一堆抽象反而没人能维护。我曾见过一个项目给只有两种配置的组合套了解释器模式结果半年后没人敢动那段代码。判断标准很简单规则种类能不能用一张小表格列清楚能就老老实实写 if-else数据驱动用一个Dictionary存映射就够了。设计模式不是勋章贴得越多不代表系统越好。我的选型建议可以压缩成一句口诀能用配置表解决的不做解析正则够用的不写 AST表达式树够用的不手写解释器手写解释器能扛住的不上 ANTLR。解释器模式的位置就在“正则不够用”和“完整语法分析器过重”之间的这个区间它并不可怕但也不该被随意使用。6. 实战中的坑我踩过的雷和最后的优化心得代码写完了场景也梳理清楚了接下来聊聊实战里的那些坑。解释器模式写在书上是干净的抽象放进工程里会遇到很多文档完全不会提的问题我按自己亲身踩过的顺序一个个说。6.1 递归深度的天花板递归下降解析器和 AST 求值都是递归的遇到特别深的括号嵌套会触发栈溢出。线上规则通常不会嵌套几十层括号但用户是难以预料的——我遇到过有人配公式时手滑复制了六十多层括号结果站点直接抛出StackOverflowException。解决方案有两个层面解析器层面可以增加限制解析前扫描深度超过 50 层直接拒绝并提示求值层面可以把递归改成显式栈迭代。后者代码复杂度高很多我最后的折中方案是只做深度限制被用户骂一次好过上线崩掉。6.2 左递归这个经典陷阱写解析器最容易踩的坑是文法左递归。假设我图省事把加法文法写成expr : expr term递归下降解析器会陷入死循环——ParseExpression第一行就调用自己永远无法结束。我在实现第二个解释器时差点犯这个错。解决方法也不难把左递归文法改写为右递归加循环也就是前面代码里ParseExpression的做法——先解析一个term然后 while 循环匹配和-。这个改写的本质是把左结合的运算符变成循环理解了这一点就不会再被它坑。6.3 错误定位别只说“解析失败”解释器模式的错误处理很容易被忽略但这是工程可用性的分水岭。早期的实现我把所有问题都抛成一个Exception(parse error)结果用户根本不知道错在哪客服每天收到的反馈都是“这个公式有问题”。后来我改成在词法分析和语法分析阶段携带位置信息——词法阶段记录_index语法阶段记录_pos错误信息统一输出成“第几行第几个字符附近具体原因”比如“位置 12缺少右括号”。只改了这一处线上咨询量直接少了一半。如果你的解释器面向业务人员错误提示就不仅仅是个辅助功能而是产品体验的一部分。6.4 性能优化把解析和求值彻底分离如果规则引擎每天的调用量很大性能优化是绕不开的。我在这边的核心体会是AST 一定要缓存千万别每次调用都重新解析。一个表达式从字符串到 AST 的解析耗时大概是求值的几十倍如果表达式不变、只有变量变化完全可以把 AST 对象缓存在内存里每次只新传 Context。更进一步如果希望接近编译语言的性能可以考虑把 AST 编译成委托——遍历 AST 节点生成一个FuncExpressionContext, double本质是给解释器模式加一层“JIT”。这个优化我做过性能提升是数量级的但代码复杂度也上升明显一般到缓存 AST 这步已经能满足绝大多数业务了。6.5 扩展维护和访问者模式组合解释器模式的短板是扩展文法很痛苦——新增一种运算要新增表达式类、修改解析器、还要考虑与其他节点的组合。这在长期演进的系统里会逐渐变成负担。我的实践是文法扩展势能保得住但操作扩展要交给访问者模式。比如我想给 AST 加一个打印功能不会去改每个节点的类而是写一个PrintVisitor在访问者里根据节点类型决定输出格式。这样解释器模式处理“怎么解释”访问者模式处理“怎么扩展操作”各司其职。MSDN 文档其实也有这个暗示只是没有把两者放在同一篇里讲透。最后说说我的总体心得。解释器模式是我在所有 GoF 设计模式里觉得最接近“编译器”的那一个它不复杂复杂的是什么时候用它、怎么把它设计得足够小。我的建议是从 MSDN 文档的基础示例入手把四则运算跑通然后找一个真实的业务规则去改造——只有经历过规则配置化的收益你才能真正体会这个模式的价值。我的四则运算解释器已经当成内部工具用了两年后续准备扩展出比较运算符和逻辑运算符做成一个更完整的规则引擎。就我的经验来说这套模式上手之后你会对“语法”“语法树”“解释执行”这些原本模糊的词有一个非常清晰的认知。
返回列表