从零实现数学公式四则运算计算代码:词法分析、调度场算法与避坑指南

发布时间:2026/10/13 15:22:17
从零实现数学公式四则运算计算代码:词法分析、调度场算法与避坑指南
简介这份资源提供了一套完整的四则运算表达式计算代码面向正在学习编译原理、算法解析或需要自建计算引擎的开发者尤其适合想弄懂括号优先级与递归下降思路的编程学习者。压缩包共4个文件以2个cpp源文件、1个h头文件和1个makefile为主分别承载核心计算逻辑、接口声明与编译构建脚本整体仅3KB轻量易读便于直接嵌入工程或作为教学示例。内容围绕加减乘除与括号表达式展开涉及运算符优先级处理、递归调用与退出条件设计并延伸讨论三角函数、对数等扩展方向同时提示除零异常与语法错误的处理思路。已有162人学习读者可借此掌握表达式解析与求值的完整实现路径理解栈或递归下降解析器的取舍并在此基础上扩展精度控制与更复杂的数学运算。1. 数学公式(四则运算)计算代码从一行字符串到可信结果中间隔着多少坑如果你写过计算器、做过报表公式解析、或者接过“用户输入一个表达式系统返回结果”这类需求就会知道数学公式(四则运算)计算代码远不是调个eval那么简单。用户输入3 5 * (2 - 8) / 4你得先判断括号配对、再处理乘除优先、还要防住1/0和--5这种边界。更麻烦的是一旦表达式来自外部输入直接交给语言内置的求值函数就等于把执行权交了出去轻则算错重则被注入。这篇文章面向需要自己实现表达式求值的开发者从词法分析讲到调度场算法再到完整可运行的 Python 实现把每一步的参数、边界和踩坑点都摊开。读完你能拿到一份能直接抄的代码也能明白为什么有些表达式会算出“玄学”结果。2. 拆解四则运算表达式词法、语法与求值顺序2.1 为什么不能直接用 eval很多语言都提供了把字符串当代码执行的能力Python 的eval、JavaScript 的eval、PHP 的eval都是典型。用它们算四则运算一行就能出结果# 危险示范不要在生产环境这样写 result eval(3 5 * (2 - 8) / 4) print(result) # -4.5这段代码确实能算出-4.5但它同时能执行__import__(os).system(...)这类任意代码。只要表达式来自用户输入、配置文件、URL 参数攻击者就能借你的进程做任何事。这是最典型的注入类漏洞血泪经验就是凡是能把字符串当代码跑的地方都不要碰外部输入。另一个问题是eval的语义和你想要的“四则运算”并不完全一致。比如2 ** 3在 Python 里是幂运算//是整除%是取模这些运算符用户可能没打算用但eval会照单全收。你想要的是一套受控的、只包含加减乘除和括号的求值器而不是一个通用解释器。所以自己实现一套数学公式(四则运算)计算代码核心动机不是“炫技”而是把执行范围收窄到你能审计的范围内。2.2 词法分析把字符串切成 token表达式求值的第一步是词法分析也叫 tokenize。输入是3 5 * (2 - 8) / 4输出是一串有类型的 tokentoken 类型值说明NUMBER3数字可能是整数或小数OP运算符NUMBER5OP*LPAREN(左括号NUMBER2OP-NUMBER8RPAREN)右括号OP/NUMBER4词法分析要处理的细节比想象中多。数字可能是3.14、.5、1e3这种形式运算符可能连着写--5空白字符要跳过但不能把1 2当成12。下面是一个够用的 tokenizerimport re # token 正则数字含小数和科学计数法、运算符、括号 TOKEN_RE re.compile(r \s*(?: (?PNUMBER\d\.?\d*(?:[eE][-]?\d)?|\.\d(?:[eE][-]?\d)?) | (?POP[\-*/]) | (?PLPAREN\() | (?PRPAREN\)) ) , re.VERBOSE) def tokenize(expr: str): tokens [] pos 0 while pos len(expr): m TOKEN_RE.match(expr, pos) if not m: raise ValueError(f无法识别的字符: {expr[pos]!r} 位置 {pos}) kind m.lastgroup value m.group(kind) tokens.append((kind, value)) pos m.end() return tokens这段代码用带命名组的正则一次性匹配数字、运算符和括号。lastgroup告诉你这次匹配到的是哪一类。pos记录当前扫描位置匹配失败就抛出带位置信息的异常方便定位用户输入里的非法字符。参数说明TOKEN_RE里的\d\.?\d*覆盖3、3.、3.14\.\d覆盖.5[eE][-]?\d覆盖1e3、2.5e-2。如果你不需要科学计数法可以把这段去掉正则会更简单。注意\s*放在最前面它会吞掉 token 之间的空白但不会吞掉数字中间的空白因为数字正则本身不允许空格。一个容易翻车的地方是1 2这种输入。上面的 tokenizer 会把它切成两个 NUMBER语法分析阶段会报错这是对的。但如果你在词法阶段就把所有空白删掉1 2会变成12结果就错了。所以空白处理要放在 token 边界上而不是全局删除。2.3 调度场算法中缀转后缀再求值拿到 token 序列后有两种主流求值路线。一种是递归下降直接按语法规则解析并计算另一种是调度场算法Shunting-yard先把中缀表达式转成后缀表达式逆波兰表示再对后缀表达式求值。两种都能用调度场算法的好处是逻辑线性、容易调试而且后缀表达式可以直接用栈求值不需要递归。调度场算法的核心是维护一个输出队列和一个运算符栈。规则如下遇到数字直接进输出队列。遇到运算符把它和栈顶运算符比较优先级。如果栈顶优先级不低于当前运算符就弹出栈顶到输出队列直到栈顶优先级更低或栈为空然后把当前运算符压栈。遇到左括号直接压栈。遇到右括号弹出栈顶到输出队列直到遇到左括号把左括号弹出丢弃。表达式扫描完后把栈里剩余运算符全部弹出到输出队列。优先级定义和-为 1*和/为 2。左结合运算符在优先级相等时也要弹出栈顶这是保证8 - 3 - 2算成(8-3)-2 3而不是8-(3-2) 7的关键。PRECEDENCE {: 1, -: 1, *: 2, /: 2} def infix_to_postfix(tokens): output [] op_stack [] for kind, value in tokens: if kind NUMBER: output.append((kind, value)) elif kind OP: while (op_stack and op_stack[-1][0] OP and PRECEDENCE[op_stack[-1][1]] PRECEDENCE[value]): output.append(op_stack.pop()) op_stack.append((kind, value)) elif kind LPAREN: op_stack.append((kind, value)) elif kind RPAREN: while op_stack and op_stack[-1][0] ! LPAREN: output.append(op_stack.pop()) if not op_stack: raise ValueError(括号不匹配多余的右括号) op_stack.pop() # 丢弃左括号 while op_stack: top op_stack.pop() if top[0] LPAREN: raise ValueError(括号不匹配多余的左括号) output.append(top) return output这段代码里op_stack存的是(kind, value)元组方便判断栈顶是不是左括号。遇到右括号时如果栈空了还没找到左括号说明右括号多了扫描结束后如果栈里还有左括号说明左括号多了。这两种情况都要报错不能静默处理。参数说明PRECEDENCE字典决定了运算符优先级。如果你要支持一元负号比如-5 3需要单独处理因为一元负号和二元减号在词法上都是-但优先级不同。常见做法是在 tokenize 阶段判断-前面是不是数字或右括号如果不是就标记为一元负号优先级设得比乘除还高。2.4 后缀表达式求值一个栈搞定后缀表达式求值非常直接从左到右扫描遇到数字压栈遇到运算符就弹出两个操作数计算结果再压回去。最后栈里剩下的唯一元素就是结果。def eval_postfix(postfix): stack [] for kind, value in postfix: if kind NUMBER: stack.append(float(value)) elif kind OP: if len(stack) 2: raise ValueError(f运算符 {value} 缺少操作数) right stack.pop() left stack.pop() if value : stack.append(left right) elif value -: stack.append(left - right) elif value *: stack.append(left * right) elif value /: if right 0: raise ZeroDivisionError(除数为零) stack.append(left / right) if len(stack) ! 1: raise ValueError(表达式不完整或有多余操作数) return stack[0]注意操作数的弹出顺序先弹出的是右操作数后弹出的是左操作数。对于减法和除法顺序反了结果就错了。8 - 3如果先弹8再弹3算成3 - 8 -5这是新手最容易踩的坑之一。除零检查放在这里做而不是等到 Python 抛ZeroDivisionError。自己抛异常可以带上更明确的上下文比如“表达式中的除法遇到了零除数”。如果你希望除零返回inf或NaN也可以在这里改成返回float(inf)但要在文档里写清楚。把上面四段拼起来就是一个完整的数学公式(四则运算)计算代码def calculate(expr: str) - float: tokens tokenize(expr) postfix infix_to_postfix(tokens) return eval_postfix(postfix) # 测试 print(calculate(3 5 * (2 - 8) / 4)) # -4.5 print(calculate(8 - 3 - 2)) # 3.0 print(calculate(2 * (3 4))) # 14.0这套代码大约 80 行覆盖了加减乘除、括号、小数和科学计数法。它的边界很清晰不支持函数调用、不支持变量、不支持幂运算。如果你需要这些可以在 tokenizer 和优先级表上扩展但每加一个特性都要重新审视安全边界。3. 把代码跑起来完整实现、参数调优与测试用例3.1 完整可运行版本与目录结构上一章的代码片段拼起来就能跑但实际项目里建议拆成独立模块方便测试和复用。一个常见的组织方式是一个文件expr_calc.py里面包含 tokenizer、parser、evaluator 三层再加一个main入口做命令行调用。# expr_calc.py import re import sys TOKEN_RE re.compile(r \s*(?: (?PNUMBER\d\.?\d*(?:[eE][-]?\d)?|\.\d(?:[eE][-]?\d)?) | (?POP[\-*/]) | (?PLPAREN\() | (?PRPAREN\)) ) , re.VERBOSE) PRECEDENCE {: 1, -: 1, *: 2, /: 2} def tokenize(expr): tokens, pos [], 0 while pos len(expr): m TOKEN_RE.match(expr, pos) if not m: raise ValueError(f无法识别的字符: {expr[pos]!r} 位置 {pos}) tokens.append((m.lastgroup, m.group(m.lastgroup))) pos m.end() return tokens def infix_to_postfix(tokens): output, op_stack [], [] for kind, value in tokens: if kind NUMBER: output.append((kind, value)) elif kind OP: while (op_stack and op_stack[-1][0] OP and PRECEDENCE[op_stack[-1][1]] PRECEDENCE[value]): output.append(op_stack.pop()) op_stack.append((kind, value)) elif kind LPAREN: op_stack.append((kind, value)) elif kind RPAREN: while op_stack and op_stack[-1][0] ! LPAREN: output.append(op_stack.pop()) if not op_stack: raise ValueError(括号不匹配多余的右括号) op_stack.pop() while op_stack: top op_stack.pop() if top[0] LPAREN: raise ValueError(括号不匹配多余的左括号) output.append(top) return output def eval_postfix(postfix): stack [] for kind, value in postfix: if kind NUMBER: stack.append(float(value)) elif kind OP: if len(stack) 2: raise ValueError(f运算符 {value} 缺少操作数) right, left stack.pop(), stack.pop() if value : stack.append(left right) elif value -: stack.append(left - right) elif value *: stack.append(left * right) elif value /: if right 0: raise ZeroDivisionError(除数为零) stack.append(left / right) if len(stack) ! 1: raise ValueError(表达式不完整或有多余操作数) return stack[0] def calculate(expr): return eval_postfix(infix_to_postfix(tokenize(expr))) if __name__ __main__: if len(sys.argv) 1: print(calculate(sys.argv[1])) else: print(用法: python expr_calc.py 3 5 * (2 - 8) / 4)这个文件可以直接python expr_calc.py 2 * (3 4)运行。sys.argv[1]拿到命令行参数如果没传就打印用法。注意命令行传参时表达式要用引号包起来否则 shell 会把*和括号解释掉这是很多人第一次跑会遇到的坑。3.2 关键参数精度、除零策略与括号深度这套代码里有几个参数和策略需要根据场景调整。数值精度。上面统一用float能表示大多数日常计算但0.1 0.2会得到0.30000000000000004。如果场景是财务计算应该换成decimal.Decimal并在 tokenize 阶段把数字字符串直接传给Decimal不要先转float再转Decimal否则精度已经丢了。from decimal import Decimal, getcontext getcontext().prec 28 # 设置有效位数 # 在 eval_postfix 里把 float(value) 换成 Decimal(value)getcontext().prec控制有效位数默认 28 位对大多数场景够用。如果要做高精度科学计算可以调到 50 甚至更高但性能会下降。除零策略。上面选择抛ZeroDivisionError调用方可以捕获后决定是提示用户还是返回默认值。另一种做法是返回float(inf)或float(nan)适合做数据管道时不想中断流程的场景。两种没有绝对优劣关键是要在接口文档里写清楚不要让调用方猜。括号深度。调度场算法本身不限制括号嵌套层数但递归下降实现会有递归深度限制。如果你用递归下降Python 默认递归深度约 1000超过会抛RecursionError。调度场算法用显式栈不受这个限制但极端深的嵌套会吃内存。实际业务里表达式嵌套超过 20 层就该考虑是不是用户输入有问题了可以在 tokenize 阶段加一个计数器超过阈值直接拒绝。3.3 测试用例正常、边界与恶意输入写完代码不测边界等于没写。下面这组用例覆盖了正常表达式、边界情况和恶意输入可以直接拿去当单元测试。输入期望结果考察点3 5 * (2 - 8) / 4-4.5优先级与括号8 - 3 - 23.0左结合2 * (3 4)14.0括号提升优先级10 / 42.5小数结果1 / 0抛 ZeroDivisionError除零(1 2抛 ValueError左括号多余1 2)抛 ValueError右括号多余1 抛 ValueError操作数缺失1 2抛 ValueError多余数字abc抛 ValueError非法字符--5抛 ValueError 或 5.0一元负号策略1e3 21002.0科学计数法--5这一行取决于你是否支持一元负号。如果不支持tokenizer 会把两个-都识别为 OP调度场算法在遇到第二个-时栈里只有一个运算符弹出后输出队列里没有足够操作数求值阶段会报“缺少操作数”。如果你希望支持需要在 tokenize 阶段判断-前面是不是数字或右括号不是的话标记为UNARY_MINUS并给它最高优先级。恶意输入方面除了eval注入还要注意超长表达式。一个几万字符的表达式可能让 tokenizer 和调度场算法消耗大量内存。可以在入口加长度限制比如超过 1000 字符直接拒绝。这不是算法问题是接口防护问题但很容易被忽略。4. 避坑与排查四则运算求值里最容易翻车的 5 个点4.1 现象8 - 3 - 2算成了 7原因调度场算法在优先级相等时没有弹出栈顶。-是左结合运算符遇到第二个-时栈顶的-优先级和当前相同应该先弹出栈顶保证左边的先算。如果条件写成而不是就会变成右结合结果错成8 - (3 - 2) 7。解决把比较条件改成PRECEDENCE[op_stack[-1][1]] PRECEDENCE[value]。这个是左结合的关键加减乘除都是左结合所以都要带上。4.2 现象1 / 0没有报错返回了inf原因Python 的float除法在除数为零时抛ZeroDivisionError但如果你用了numpy或者在某些语言里除零可能返回inf。另外如果除数是0.0而不是0行为可能不同。更隐蔽的是如果除数是-0.0结果可能是-inf。解决在除法分支里显式判断right 0包括0、0.0、-0.0。不要依赖语言默认行为自己抛异常或返回约定值。如果业务允许inf也要在文档里写明并确保下游能处理。4.3 现象0.1 0.2得到0.30000000000000004原因二进制浮点数无法精确表示0.1和0.2相加后误差累积。这不是代码 bug是 IEEE 754 浮点数的固有特性。解决财务场景改用decimal.Decimal并在 tokenize 阶段保留原始数字字符串。如果只是展示可以在输出时用round(result, 10)或格式化字符串控制小数位。但要注意round只是掩盖了误差不改变底层值做等值比较时仍然可能出问题。4.4 现象表达式里有空格1 2被算成了12原因tokenizer 在全局删除了空白字符导致两个独立的数字被拼在一起。或者正则里的\s*位置不对把数字之间的空格也吞了。解决空白处理只放在 token 边界上不要全局replace( , )。上面的TOKEN_RE把\s*放在每个 token 匹配的开头它只跳过 token 之间的空白不会跳过数字内部的空白因为数字正则本身不允许空格。测试用例里加上1 2这一条确保它报错而不是算出12。4.5 现象括号嵌套很深时程序卡死或内存暴涨原因调度场算法用显式栈不会递归溢出但极端深的嵌套会让栈和输出队列变得很大。如果表达式来自不可信输入攻击者可以构造几万层括号让服务端内存耗尽。解决在 tokenize 阶段加括号深度计数超过阈值比如 50 层直接拒绝。同时限制表达式总长度比如 1000 字符。这两个限制应该在接口层做不要等到算法层才处理。阈值设多少取决于业务报表公式一般不会超过 10 层50 层已经很宽松了。5. 进阶技巧把求值器扩展成可配置的表达式引擎上面这套代码只支持加减乘除和括号但实际业务里经常需要更多能力变量、函数、幂运算、取模。直接改核心算法容易把代码搞乱更好的做法是把运算符和函数做成可配置的表核心算法只负责调度具体计算交给注册的回调。# 可扩展的运算符表 OPERATORS { : (1, lambda a, b: a b), -: (1, lambda a, b: a - b), *: (2, lambda a, b: a * b), /: (2, lambda a, b: a / b if b ! 0 else (_ for _ in ()).throw(ZeroDivisionError(除数为零))), ^: (3, lambda a, b: a ** b), # 右结合优先级最高 %: (2, lambda a, b: a % b), }这个表把优先级和计算逻辑绑在一起调度场算法只需要查表拿优先级求值阶段只需要查表拿函数。加一个新运算符就是加一行配置不用改算法。注意^是右结合2^3^2应该算成2^(3^2) 512而不是(2^3)^2 64。右结合的处理方式是在弹出栈顶时遇到优先级相等不弹出只弹出优先级更高的。变量支持可以在 tokenize 阶段把标识符识别为VARIABLE类型求值时从一个上下文字典里取值。函数支持稍微复杂一点需要识别sin(x)这种形式可以在 tokenizer 里把sin识别为FUNC遇到左括号时压栈求值时弹出参数个数再调用。这些扩展会让代码量翻倍但核心的调度逻辑不变。验证扩展是否正确最直接的方法是拿标准计算器的结果做对照。比如2^3^2、10 % 3、sin(0) cos(0)先在 Python 里算出期望值再跑你的求值器对比结果。如果结果不一致先检查优先级表再检查结合性最后检查函数参数顺序。我自己的习惯是每加一个运算符就补三条测试用例正常值、边界值、错误输入。这个习惯帮我省了很多后悔药因为表达式求值的 bug 往往在很晚才暴露而且一旦算错下游可能已经拿错误结果做了决策。希望帮到你。本文还有配套的精品资源点击获取