资讯详情

资讯详情

表达式求值全解析:从后缀表达式到递归下降

做了多年后端和规则引擎我隔三差五就会碰到“表达式求值”这件事。小到做一个计算器、金额四舍五入规则大到订单优惠计算、风控规则匹配、报表公式解析底层都是同一个问题给你一串字符串怎么把它变成可执行的逻辑并算出结果。今天这篇就专门聊聊表达式求值的几种典型情况。我不打算只贴一段代码让人抄而是把每种方案背后“为什么这么设计”讲清楚。你会看到后缀表达式怎么求值、中缀表达式直接求值、递归下降解析以及数字之外那些逻辑表达式、字符串表达式、日期表达式的特殊处理。适合刚接触解析器的初学者也适合被各种边界条件折磨过的老手看完至少能明确你这套业务到底该选哪条路。1. 先把“表达式求值的几种情况”捋清楚1.1 三种书写形式中缀、后缀、前缀表达式怎么“写”直接决定了怎么“算”。人类平时写的是中缀表达式比如3 4 * 5运算符夹在操作数中间。这种写法符合阅读习惯但机器直接处理要考虑优先级和括号麻烦不少。后缀表达式也叫逆波兰表达式把运算符放在操作数后面3 4 5 * 。它的好处是没有括号、没有优先级问题计算时从左往右扫遇到数字就压栈遇到运算符就弹两个数字计算再把结果压回去。整个计算过程只有一个栈逻辑极其简单。前缀表达式则是运算符在前面 3 * 4 5。原理和后缀反过来遇到运算符先记下来碰到数字再处理。日常工程里前缀表达式用得少一般只在 Lisp 这类语言里常见求值思路和后缀大同小异按下不表。1.2 我遇到的几种典型求值场景表达式求值在真实项目中不是一个“方法”走天下而是分情况纯四则运算比如计算器、财务金额计算核心是 - * /和括号数字是浮点数或定点数。带变量的公式比如绩效公式base * performance bonus变量从业务上下文里取值。带函数调用的表达式比如max(amount, 100) min(rate, 0.5)甚至自定义函数。逻辑表达式比如风控里的amount 1000 blacklist false要求短路求值。混合类型表达式比如订单号: orderNo、date 7d涉及字符串拼接和日期运算。这些场景用的核心算法不一样但有一条主线把中缀表达式的歧义消掉转成某种容易计算的中间结构再做求值。区别只在于中间结构是什么以及在哪里消歧义。1.3 从项目角度选型而不是从算法角度选型我刚入行时看到表达式求值第一反应是“写一个递归下降解析器”觉得这才是正宗做法。后来在项目里被性能、可维护性、扩展性反复教育之后才明白选型顺序应该是先看业务复杂度再看团队维护水平然后才是算法本身。如果只是固定格式的四则运算后缀表达式求值最简单、最不容易写错。如果表达式里变量和函数很多递归下降或解析树更合适因为它们天然支持嵌套和上下文。如果是安全要求很高的场景那还要考虑表达式注入、限制递归深度等问题。所以“几种情况”不仅仅是表达式写法的不同也是场景驱动的方案取舍。2. 后缀表达式求值最稳的入门路线2.1 为什么后缀表达式好算后缀表达式的核心优势是消除歧义。中缀表达式3 4 * 5如果按从左往右的顺序算会得到35但数学规定乘法优先级高结果应该是23。后缀表达式3 4 5 * 把运算顺序固化了4 * 5先算再把20和3相加。计算时用到一个栈规则只有两条扫描到操作数压入栈。扫描到运算符弹出两个操作数先弹出的是右操作数再弹出的是左操作数计算压回结果。反复执行最后栈里只剩一个数那就是表达式的值。这其实就是“后进先出”结构对嵌套计算的天然匹配。括号里嵌套的子表达式在后缀表达式里表现为一段连续操作数计算顺序正好对应栈的弹压顺序。2.2 中缀转后缀调度场算法后缀表达式虽然好算但用户不会写后缀输入的永远是中缀。所以要从“中缀转后缀”这一步入手。经典算法是 Edsger Dijkstra 提出的调度场算法Shunting-yard名字很形象像火车站调度轨道一样把操作数送到输出轨道把运算符暂存在操作符栈里。算法的关键点是优先级和左结合性遇到操作数直接输出。遇到(压入操作符栈。遇到)把操作符栈里的运算符弹出并输出直到遇到(左括号弹出但不输出。遇到运算符如果当前运算符的优先级小于或等于栈顶运算符的优先级就不断弹出栈顶并输出直到栈顶优先级更低或遇到(然后把当前运算符压入栈。扫描结束后把操作符栈里剩余的运算符全部弹出并输出。这里有个细节容易被忽略比较优先级时用还是决定了左结合还是右结合。普通四则运算都是左结合也就是8 - 3 - 2要算成(8 - 3) - 2而不是8 - (3 - 2)所以用。如果将来要支持指数^右结合就要单独处理不能用同一个条件。2.3 完整的 Python 实现与测试我直接给一个可跑的版本包含分词、转换、求值三步方便在你本地验证。# -*- coding: utf-8 -*- import re def tokenize(s): 把中缀表达式字符串切成操作数、运算符、括号 # 这里用正则拆出数字支持小数和负号前缀处理由上层做、运算符、括号 pattern r\d\.?\d*|\.\d|[()*/-] return [t for t in re.findall(pattern, s) if t.strip()] def is_number(token): try: float(token) return True except ValueError: return False def infix_to_suffix(tokens): priority {: 1, -: 1, *: 2, /: 2} output [] op_stack [] for tok in tokens: if is_number(tok): output.append(tok) elif tok (: op_stack.append(tok) elif tok ): while op_stack and op_stack[-1] ! (: output.append(op_stack.pop()) # 左括号弹出但不进入输出 op_stack.pop() else: # 运算符 while (op_stack and op_stack[-1] ! ( and priority[tok] priority[op_stack[-1]]): output.append(op_stack.pop()) op_stack.append(tok) while op_stack: output.append(op_stack.pop()) return output def eval_suffix(tokens): stack [] for tok in tokens: if is_number(tok): stack.append(float(tok)) else: b stack.pop() a stack.pop() if tok : stack.append(a b) elif tok -: stack.append(a - b) elif tok *: stack.append(a * b) elif tok /: if b 0: raise ZeroDivisionError(除数为0: {} / {}.format(a, b)) stack.append(a / b) return stack[0] def eval_infix(s): tokens tokenize(s) suffix infix_to_suffix(tokens) print(后缀表达式:, .join(suffix)) return eval_suffix(suffix) if __name__ __main__: tests [3 4 * 5, (3 4) * 5, 3 * (4 5) - 6 / 2] for t in tests: print({} {}.format(t, eval_infix(t)))我用实际输出验证过3 4 * 5得到23(3 4) * 5得到353 * (4 5) - 6 / 2得到24。这三条用例分别覆盖了优先级、括号、混合运算三个维度建议你接手任何解析代码时都先拿这批用例做回归。上面代码里有个环节是“分词”。真做项目时不建议用正则硬拆所有情况至少要考虑负数、科学计数法、小数点开头的.5这类写法后面专门讲。2.4 后缀求值容易踩的坑第一弹出的顺序。遇到减法和除法栈顶弹出来的是右操作数再弹一个才是左操作数。写反了会出现5 - 3变成3 - 5结果完全错。我见过不少初学代码在这一点上翻车排查的时候又不容易看出来因为加减法碰巧对称。第二中缀转后缀时括号处理。右括号触发弹出但左括号本身不能进入输出。很多实现写到最后栈里还留着左括号或者右括号被当作普通运算符压栈了。建议在转换后打印一次后缀表达式用肉眼核对(12)*3是否变成1 2 3 *。第三空栈问题。表达式不合法时比如1 * 2求值阶段会从空栈pop()直接抛异常。生产环境建议在分词或转换阶段做一次语法校验或者给求值阶段加异常捕获返回业务可读的“表达式格式错误”不要裸抛底层越界。3. 中缀直接求值双栈方案3.1 双栈求值的核心思想后缀表达式求值好理解但有的人觉得“先转后缀再求值”多了一步代码量翻倍。于是还有一种常规做法维护操作数栈和操作符栈直接扫描中缀表达式边扫描边计算。核心逻辑其实和调度场很像只不过把“输出后缀队列”换成了“直接计算”遇到数字压入操作数栈。遇到运算符确保栈顶运算符优先级不低于当前运算符时先弹栈计算再把当前运算符压栈。遇到(压栈。遇到)一直弹运算符并计算直到遇到(。每次“弹运算符并计算”都从操作数栈弹两个数算出结果再压回去。扫描结束后操作符栈如果还有运算符继续弹出计算最后操作数栈顶就是结果。这个概念很多人写成“表达式求值双栈法”用起来确实顺手尤其是只需要处理四则运算的小功能写成一个函数就能交付。3.2 直接求值的代码实现def eval_direct(s): tokens tokenize(s) nums [] ops [] priority {: 1, -: 1, *: 2, /: 2} def apply_top(): op ops.pop() b nums.pop() a nums.pop() if op : nums.append(a b) elif op -: nums.append(a - b) elif op *: nums.append(a * b) elif op /: if b 0: raise ZeroDivisionError(除数为0) nums.append(a / b) for tok in tokens: if is_number(tok): nums.append(float(tok)) elif tok (: ops.append(tok) elif tok ): while ops and ops[-1] ! (: apply_top() ops.pop() # 弹出左括号 else: while (ops and ops[-1] ! ( and priority[tok] priority[ops[-1]]): apply_top() ops.append(tok) while ops: apply_top() return nums[0]这个方案的优点是少一步转换直接出结果。代码量比“转换 求值”要少。缺点是计算和语法检查混在一起如果表达式非法往往是在计算到一半的时候才发现报错位置不直观。而转后缀方案可以在转换阶段就发现括号不匹配等问题认知上更清爽。3.3 转后缀 vs 直接求值怎么选我的习惯是只要单位代码量小、维护的人少用转后缀方案因为中间多了一个“后缀表达式”这个可调试的中间产物。一旦计算结果不对打印后缀表达式比调试双栈每一步容易得多。双栈方案问题出在状态全在栈里执行到哪一步、为什么触发这个计算肉眼不好定位。反过来如果是写算法题、做一个一次性的脚本解析双栈更省事。两者时间复杂度都是 O(n)差别主要在可维护性不在速度。4. 递归下降解析可扩展的求值方式4.1 一个能看懂的四则运算文法如果表达式里要加变量、加函数、加比较运算用栈式方案就开始痛苦了。栈式方案的优先级表可以不停扩但括号嵌套下的复杂语法、带参数的函数调用、条件选择这些东西硬用栈写容易写成一坨。这时候我推荐递归下降解析。它不是靠一个栈模拟而是用一组递归函数对应语法规则。先定义文法最简单的四则运算可以写成这样BNF描述expression : term (( | -) term)* term : factor ((* | /) factor)* factor : NUMBER | ( expression )理解这个文法的关键是“优先级通过递归层级体现”expression 层对应加减法term 层对应乘除法factor 层是原子项。解析1 2 * 3时parse_expression 读到加号后调用 parse_termparse_term 会贪婪地吃掉2 * 3所以乘法自然比加法先算。4.2 递归下降解析的代码实现class RecursiveDescentParser: def __init__(self, tokens): self.tokens tokens self.pos 0 def current(self): if self.pos len(self.tokens): return self.tokens[self.pos] return None def consume(self): tok self.current() self.pos 1 return tok def parse_expression(self): left self.parse_term() while self.current() in (, -): op self.consume() right self.parse_term() if op : left left right else: left left - right return left def parse_term(self): left self.parse_factor() while self.current() in (*, /): op self.consume() right self.parse_factor() if op *: left left * right else: if right 0: raise ZeroDivisionError(除数为0) left left / right return left def parse_factor(self): tok self.consume() if tok (: value self.parse_expression() # 解析完括号子表达式后要消费右括号 if self.current() ! ): raise SyntaxError(缺少右括号) self.consume() return value if is_number(tok): return float(tok) raise SyntaxError(无法识别: {}.format(tok)) def eval_recursive(s): tokens tokenize(s) parser RecursiveDescentParser(tokens) result parser.parse_expression() if parser.current() is not None: raise SyntaxError(表达式末尾有未消费的内容) return result递归下降相比前两种写法有一个隐性优势对语法错误更敏感。它天然知道“这里需要一个右括号”或“这里出现未知字符”。另外由于每个表达式边界有明确函数调用调试时可以单步进入 parse_term、parse_factor直观看到当前解析位置。4.3 扩展变量和函数递归下降最大的价值是方便扩展。支持变量时只要让 parse_factor 遇到标识符时去环境上下文查值def parse_factor(self): tok self.consume() if is_number(tok): return float(tok) if is_identifier(tok): if tok in self.env: return self.env[tok] raise NameError(未定义变量: {}.format(tok)) # 函数调用 if self.current() (: func_name tok self.consume() # ( args [] while self.current() ! ): args.append(self.parse_expression()) if self.current() ,: self.consume() self.consume() # ) return self.call_func(func_name, args)这里 env 是一个字典把变量名映射到值。函数表则是 name - callable 的映射调用时校验参数个数。比如max(1, 2, 3)可以转成内置 max 的变长参数调用。4.4 递归下降的适用范围递归下降不是银弹它有一个明显代价处理优先级多层嵌套时代码会比较“啰嗦”。加减乘除还只是三层加入比较、逻辑、取反、函数调用可能要写六七层。而且它默认不支持左递归文法比如a - b - c直接写成expression : expression - term会无限递归所以工程上都要像我上面那样改写成迭代式循环。我的建议是项目里已经依赖第三方表达式库或者表达式数量、复杂度已经到了手写解析痛苦的程度优先选现成的库。但如果你正在写题库系统、规则引擎或者公式引擎需要控制依赖、深度定制错误提示递归下降是长期维护最舒服的方案。5. 各种特殊情况的求值细节5.1 负号运算项和运算符要分清困扰很多初学者的是-3 5里的负号。它既像减号又是一个“把 3 取负”的一元运算符。如果不处理分词阶段会把它当成二元减号于是-3被拆成空、减号、3没法算。常见处理方式有两种分词阶段判定如果-出现在表达式开头或者前一个 token 是运算符或左括号就把-和后面的数字合并为一个 token或插入一个neg标记。解析阶段判定在 parse_factor 里允许一元负号遇到-就递归解析一层 factor再取负。第二种更通用处理- (3 4)这种组合负号也自然def parse_factor(self): tok self.consume() if tok -: return -self.parse_factor() if tok (: # ...注意这样处理后5 - 3的减号仍然走 parse_expression 层不会冲突。赋值逻辑是负号是一元操作因子本身可以是带负号的因子。5.2 浮点精度与除零处理表达式求值结果经常拿去比较或者落库最经典的坑就是0.1 0.2 ! 0.3。这是二进制浮点表示的固有误差任何语言都一样。我的建议是先明确数值精度要求再决定类型。金额计算用 DecimalPython或 BigDecimalJava或者把单位换算成最小单位整数再算普通科学计算用 float/double 就行。但比较结果时不要给一个 epsilon 范围EPSILON 1e-9 def nearly_equal(a, b): return abs(a - b) EPSILON除零要早做防护在后缀求值、双栈、递归下降每个除法分支都检查分母。工程上我习惯把“除数为0”变成业务可识别的异常码而不是让底层 ZeroDivisionError 冒泡到接口层。5.3 逻辑表达式的短路求值很多规则引擎需要表达式amount 100 check_user() true。这里的核心不只是布尔运算优先级而是短路如果前半截已经为假后半截就不该求值。这不是性能优化问题而是语义正确性问题——后半截可能依赖前端提供的安全环境或者执行有副作用。实现短路最简单的方式是在解析层直接把逻辑运算符当普通二元运算处理但求值时加判断def parse_or(self): left self.parse_and() while self.current() ||: self.consume() right self.parse_and() if left: # 短路左边为真就不求右边 continue left left or right return left注意这里的写法比较讲究不能写left left or right就完了因为 python 的or本身也会短路但你在解析层面没求右边的值。实际要看语言如果是解释器场景要确保语法树右边的节点在短路时不需要“执行”。最好的做法是解析树保留执行器访问右边节点前先判断。5.4 混合类型与自定义函数表达式求值做到后期基本都是“类型系统”的问题。比如金额: 100是否允许字符串拼接date(2024,1,1) 30d怎么理解我的经验是不要偷偷做隐式转换宁可显式抛错让调用方知道类型不匹配也不要自动“帮我转成字符串”然后得到诡异结果。自定义函数是扩展性的重头戏。我会维护一张注册表FUNCTIONS { max: max, round: round, abs: abs, # 业务自定义 calc_tax: calc_tax, }调用函数时校验参数个数、类型抛出“函数 X 参数错误”而不是 Python 原生 TypeError方便集成方定位。5.5 表达式注入与资源限制表达式如果来自用户输入要防备几类风险超长表达式比如几万层的括号嵌套递归下降会爆栈。循环或死循环自定义函数里不小心写了死循环。资源占用表达式里包含大量运算可能拖慢服务。我在生产环境做三件事限制表达式最大长度、限制解析递归深度比如 100 层、给求值设置超时或总步数。看起来粗暴效果却非常实在。安全不是表达式库的默认卖点而是接入方的责任别指望一个库替你挡住所有恶意输入。6. 常见问题与排查技巧实录6.1 一张表定位典型问题现象可能原因处理思路1 2 * 3算成 9优先级处理反了或者转后缀时写成了用测试用例对拍打印后缀表达式(12)*3解析报错右括号处理逻辑没匹配到左括号检查右括号分支是否弹出左括号打印操作符栈负数表达式算错一元负号被当成二元减号在 factor 层处理负号而不是停在分词小数精度不对浮点表示误差明确精度必要时切 Decimal函数参数解析出错参数分隔逗号没处理检查函数调用循环里逗号消费逻辑表达式末尾多字符不报错解析完后没检查是否消费完每个入口函数最后检查 current() 是否为 None括号不匹配没报错栈式方案在栈空时弹栈异常增加显式格式校验转换前做括号计数除零出现 inf除法未检查分母除法分支显式判断并抛业务异常6.2 实用排查方法排查表达式求值问题我的一般顺序是先打印分词结果。很多人忽略这一步。2.53如果被切成2、.、5、、3后面一切算法都是徒劳。打印中间结构。转后缀方案打印后缀队列递归下降方案在 parse_factor 里打印当前 token 和调用栈。不用 printf 式调试很多一眼看不出的问题在中间产物面前直接现形。构造最小复现用例。把(34)*5-(2/1)这种长表达式不断删减找出哪个单元触发错误。通常一个 3 到 5 个 token 的用例就能锁定问题。用等价表达式做交叉验证。比如(34)*5等价于3*54*5两边分别求值应该相等不等说明解析或运算有偏差。6.3 批量测试的惯用套路我一直坚持给表达式求值器建一个用例表而不是每次手敲几个表达式试试就完。用例分几类优先级1 2 * 3 7、(1 2) * 3 9括号嵌套((1 2) * (3 - 4)) -3负数-3 5 2、2 - -3 5小数0.1 0.2和 0.3 做近似比较除以零1 / 0必须抛异常不完整表达式1 必须报错变量函数max(1, 2) x 5环境里 x3把这些用例放到 CI 里每次改动代码跑一遍。表达式解析这类代码看似小改一处优先级可能引发连锁问题。有自动化用例兜底比什么都强。我在实际项目里经常遇到“这个表达式库好像不太对”“我换个写法就算出来了”的反馈最后大部分都是优先级或负号处理的问题。表达式求值的几种情况说到底就是几种处理模型的选择。记住一点不管选后缀、双栈还是递归下降中间结构越清晰问题越容易被发现。批量测试和错误提示做足剩下的都只是实现细节。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →