ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

简单计算器并不简单:运算符优先级与健壮性编程实战

简单计算器并不简单:运算符优先级与健壮性编程实战 1. 一道“简单习题”为什么值得认真做它的价值和坑点“习题2.1 简单计算器”看到这个题目的人通常有两种反应。第一种是想“这不就是做个加减乘除嘛半小时写完收工”第二种是刚学编程没多久连基本的输入输出还没理顺担心自己搞不定。我在带新人和做项目评审的时候见过太多人在这道题上翻车或者做出来的东西根本没法用。表面上它是一个非常入门的练手题目但它的含金量比大部分人想象的要高。先说它为什么值得认真对待。一个计算器程序表面上要处理的只是“读入两个数做一次运算输出结果”但实际做的时候会牵扯到表达式解析、运算符优先级、除法除零、输入格式校验、数据类型的边界问题等一堆细节。这些细节恰恰是后续做更复杂系统时反复要面对的。很多人学编程学到后面发现写的代码老出bug回头一看根子往往是在处理这种“简单逻辑”的时候没有形成一套严谨的思维方式。再说说这道题常见的坑。篇幅有限我先把最核心的几个列出来后面再逐个展开只按输入顺序从左到右算完全不考虑乘除优先于加减。这是初学者最容易犯的错误也是这道题真正考察的核心。判断除数为零的时候有些人只判断整数零结果输入小数0.0或者0.00时照样除零崩溃。输入格式不校验用户随便输一个“ab”或者只输一个“5”程序直接报错退出。没有考虑减号既可以当二元运算符也可以当一元负号比如输入“-35”。使用浮点数比较是否等于零时踩了精度坑导致判断结果和预期不一致。这篇文章不打算只给你一个可以交差的代码。我会把从需求拆解、方案设计、完整源码、到测试与排错的整条链路都过一遍并且把我在实际教学中看到的高频问题揉进去讲。适合正在学编程基础、准备应对老师或面试官追问“为什么这么写”的同学也适合那些想通过这道小题重新梳理自己代码习惯的初学者。2. 先想清楚再动手需求拆解与两种常见设计的取舍2.1 原始需求到底要我们做什么大多数教材的“简单计算器”习题原文通常就一句话写一个程序输入两个数和一个运算符、-、*、/输出计算结果。看起来约束很少但这是一道典型的“需求看似清楚、实际边界模糊”的题目。我们先把最基本的功能范围定下来输入两个操作数和一个运算符操作数可以是整数也可以是小数。运算加、减、乘、除四种。输出运算结果。就这么点要求我课堂上让学生做的时候会先让大家把自己的程序当成一个“产品”来想如果有人乱输入怎么办如果用户输入除号且除数为0怎么办如果用户输入的不是这四种运算符怎么办如果我输入了“5”而不是“5.0”输出应该是什么样这些思考不需要多高深的技术但能立刻把“交差型代码”和“有工程意识代码”区分开。2.2 求值顺序上的两种思路从左到右 vs 按优先级计算针对“简单计算器”行业里有两级做法我建议你从一开始就刻意去做那级更高一点的。做法一从左到右顺序计算就是读到一个数、一个符号、一个数马上就算算出结果再和下一个数继续算。比如输入“12*3”就按(12)*39处理。这个写法非常简单但它偷换了一个重要的概念——它默认用户的每一步操作都是依次执行的像老式计算器一样。可是几乎所有日常数学场景里用户默认运算法则是先乘除后加减。所以但凡你把输入改成“12*3”用顺序算法就会得到9而数学正确答案是7。这道题如果老师要求必须正确处理优先级顺序算法直接不及格。做法二按运算符优先级处理核心是先把所有乘除运算处理掉再处理加减。实现方式可以分两遍扫描也可以借助栈结构做一次扫描。复杂度看起来高了但它更接近现实中“用户输入一串表达式”的真实场景。我的建议很明确直接按做法二实现。理由有三个题目虽然是“简单计算器”但很多后续练习比如带括号的计算器、表达式求值都是在优先级处理基础上扩展的。你现在把地基打好后面学栈、学递归、学抽象语法树才不会懵。用做法二写出来的代码面试官或老师问你“为什么这个代码能正确处理乘除优先”你能回答到点子上这是加分项。做法一在某些特定版本习题里也能满足要求教材明确说“依次计算”的情况确实存在但你在不清楚习题要求边界时做到优先级更稳妥。就算老师只需要顺序计算你交一个优先级正确的版本也绝不会被扣分。2.3 一次性读完整个表达式还是边读边算这里还牵涉到一个输入策略的问题程序是每次只接收“数字 符号 数字”三个东西还是接收一整段表达式例如“12*3-4/2”一次性算完严格对应原题“输入两个数和运算符”只要读三个信息就够了。但那样做出来的程序功能上只是一个二元运算器谈不上“计算器”而且它没法把优先级问题暴露出来学不到东西。我的建议是做成一个能接收完整表达式只支持加减乘除、不支持括号的命令行小计算器这样既覆盖了原题需求又顺手把优先级问题练到位还比原题多一个应用场景。如果你只想保底通过老师验收那也不必慌源码里我会给出最简单的二元运算分支你按需裁剪就行。3. 核心源码逐步拆解从最简单版本到高完成度版本为了照顾不同基础的人我按三个阶段给出代码。每个阶段的代码都能运行、都能完成“简单计算器”的基本任务只不过完成度和健壮性不同。3.1 最省事的版本只做一次二元运算这个阶段我认为适合那些刚刚学会if、switch、input这些基础语法、想先跑通一个最小程序的同学。逻辑上没有任何技巧就是把用户给的三个输入读进来然后用分支判断运算符。# 简单计算器基础版单次二元运算 num1 float(input(请输入第一个数: )) op input(请输入运算符(, -, *, /): ) num2 float(input(请输入第二个数: )) if op : result num1 num2 elif op -: result num1 - num2 elif op *: result num1 * num2 elif op /: if num2 0: print(错误除数不能为0) else: result num1 / num2 print(结果是:, result) else: print(无效运算符)这个版本能跑但有几个明显的槽点没有处理运算符之外的输入输个“x”会走else。除零判断只拦了num2 0如果输的是0.0也能拦但在某些语言或某些写法里可能判断不准后面单说。没有考虑连续运算算完一次就结束。它只适合用来和“教科书写法”互相印证——你至少知道了程序骨架长什么样。从下一节开始我们进入真正的核心逻辑。3.2 处理单次输入但做足健壮性防御式编程的入门一次输入的程序虽然简单但我们可以把“防御性”加进去。先明确要防御什么输入的不是数字、输入的运算符不在范围内、除数为零。下面是加强版我建议所有刚学完基础语法的同学至少写到这个水平# 简单计算器健壮版单次二元运算 输入验证 def safe_float(value): 安全的字符串转浮点数失败返回None try: return float(value) except ValueError: return None num1_str input(请输入第一个数: ) num1 safe_float(num1_str) if num1 is None: print(输入无效第一个数必须是数字) exit(1) op input(请输入运算符(, -, *, /): ) if op not in [, -, *, /]: print(无效运算符仅支持 - * /) exit(1) num2_str input(请输入第二个数: ) num2 safe_float(num2_str) if num2 is None: print(输入无效第二个数必须是数字) exit(1) if op / and abs(num2) 1e-12: print(错误除数不能为0) exit(1) if op : result num1 num2 elif op -: result num1 - num2 elif op *: result num1 * num2 else: result num1 / num2 print(结果是:, result)这段代码比基础版多了三个能力尝试把字符串转成浮点数并捕获异常、严格限制运算符集合、用绝对值和极小阈值来判断除零。你可能会想为什么除零判断不直接写成num2 0这就是浮点数的经典问题。3.3 为什么除零判断不要直接写“等于0”在数学里判断一个数是不是零就看它等不等于0。但在计算机浮点数世界里0.0确实能精确表示但0.0000000001这种“几乎等于零”的数有时候计算过程中会产生而用户直接输入时也可能会输入极小值。如果程序把1e-300当除数浮点数运算会直接得到溢出值如果你不拦后续再拿这个值做别的计算结果就会变得不可预测。于是我用了abs(num2) 1e-12。这个阈值的意思是当除数的绝对值小于一亿分之一10的负12次方时我就认定它“在计算器应用场景下等同于零”。为什么不是1e-100因为如果一个数真的极端到10的负100次方用户通常不会是有意输入多半是中间计算产生的不良数据。在实际计算器程序里用一个合理的阈值比精确等于0更稳。当然这里有个注意点如果题目明确要求“只判断是否等于0”那你按教科书写0就够。但加了阈值不会影响正常输入因为没人会真的输入0.000000000001作为除数并期望得到一个天文数字结果。稳妥起见我推荐保留阈值判断。3.4 完整版纯表达式计算正确处理优先级接下来是这篇文章的重头戏。我们把“两个操作数一个符号”扩展到“完整表达式”并且遵循先乘除后加减的规则。暂时不处理括号因为习题2.1根本不需要括号但代码结构为后面扩展括号留好了路。设计思路如下读入一行表达式字符串比如“35*2-8/4”。先用一个解析器把字符串中的数字和运算符拆成列表同时区分负号和减号。第一遍扫描遇到乘号或除号就把前后两个数字算出来替换成新数字。第二遍扫描遇到加号或减号从左到右依次加减。输出最终结果。这里第一步的“区分负号和减号”是个容易踩坑的细节。在表达式里“-”既可能是减法也可能表示负号比如“-53”。所以解析时得用个状态判断如果“-”前面是数字或右括号它才是减号否则是负号。源码实现我会用tokenize函数来处理# 简单计算器表达式版支持加减乘除先乘除后加减 def tokenize(expr): 把表达式字符串拆成token列表区分负号和减号 tokens [] i 0 n len(expr) # 标记当前token的前一个有效字符是什么用于判断-的语义 prev_token None while i n: ch expr[i] if ch : i 1 continue if ch.isdigit() or ch .: # 读取完整数字 j i while j n and (expr[j].isdigit() or expr[j] .): j 1 tokens.append(expr[i:j]) prev_token number i j elif ch in -*/: if ch -: # 如果前一个token是空、运算符或左括号表示负号 if prev_token is None or prev_token in (, -, *, /, (): # 将负号和后面的数字合并解析先临时标记 tokens.append(neg) prev_token neg else: tokens.append(-) prev_token - else: tokens.append(ch) prev_token ch i 1 elif ch ( or ch ): tokens.append(ch) prev_token ch i 1 else: raise ValueError(f无法识别的字符: {ch}) return tokens然后是两个核心求值函数。def apply_operator(left, right, op): 应用单个运算符, left/right是数字字符串或数字 left float(left) right float(right) if op : return str(left right) elif op -: return str(left - right) elif op *: return str(left * right) elif op /: if abs(right) 1e-12: raise ZeroDivisionError(除数不能为0) return str(left / right)def evaluate(tokens): 对token列表执行两遍扫描先乘除后加减 if len(tokens) 0: return 0 # 处理负号把neg和后面紧挨的数字合并成负数 merged [] i 0 while i len(tokens): if tokens[i] neg: if i 1 len(tokens) and tokens[i1] not in (, -, *, /): merged.append(- tokens[i1]) i 2 continue else: # 负号后面跟运算符几乎不会出现但稳妥报错 raise ValueError(负号使用错误) else: merged.append(tokens[i]) i 1 tokens merged # 第一遍处理乘除 i 0 while i len(tokens): if tokens[i] * or tokens[i] /: left tokens[i-1] right tokens[i1] result apply_operator(left, right, tokens[i]) tokens tokens[:i-1] [result] tokens[i2:] i i - 1 else: i 1 # 第二遍处理加减 result float(tokens[0]) i 1 while i len(tokens): op tokens[i] num float(tokens[i1]) if op : result num elif op -: result - num i 2 return result这个实现思路的核心是“用新的token列表替换原token列表”。第一次遇到乘除时把三个元素左操作数、运算符、右操作数替换成一个计算结果。循环结束后列表中只剩下加减运算再从左到右算一遍逻辑非常直观。需要注意这段代码把数字全部转成字符串再转成浮点数来回捣腾。好处是token列表结构统一省得类型混在一起容易出错坏处是整体稍微偏慢但计算器场景完全不在乎这点开销。如果你是初学者这种“统一用字符串表达中间结果”的方式反而更容易调试——print出列表每一步都能看见状态变化。3.5 测试上面这个完整版给你一组测试输入和期望结果先自己拿笔算一遍再跑程序对照输入表达式期望结果说明35*213乘法优先于加法8/424除法优先于加法10-2*348标准混合运算23424连续乘法12/3/22除法从左到右-352负号开头5-32加负数1/0报错除零35程序可能报错双加号逻辑未覆盖我实际跑的时候前七个都能得到正确结果第八个会抛出“除数不能为0”的异常第九个会被parse阶段拦下来。还有一个边界场景输入“5”这样的单数字表达式结果应该是5。我的实现也能处理因为tokens里只有一个数字第二遍循环直接就返回它。3.6 如果想显示“表达式 结果”怎么改上面代码在命令行直接print出结果。如果你想输出漂亮一点比如“35*2 13”最外层调用处稍微改一下expr input(请输入表达式: ) tokens tokenize(expr) result evaluate(tokens) print(expr, , result)这种改法没有任何风险只是把结果拼到原表达式后面。我个人建议做成这样因为做题和实际使用时都更直观也方便调试。4. 避坑实录我在这个习题上见过的真实翻车现场接下来这一节全是实战中积累下来的坑。我挑五个最高频的每一个都给出复现、原因和修复办法。这不算灌水因为这些问题不亲自动手写一遍基本不会碰到。4.1 坑一数字输入成“5foo”程序直接崩溃翻车现场num1 float(input(请输入第一个数: ))用户不小心输入“5foo”或者“abc”float()直接抛出ValueError程序崩溃。原因float()对非数字字符串没有任何宽容度它只认识数字、正负号、小数点、指数符号这些东西。修复用try...except包一层或者像我前面那样封装一个safe_float()函数。核心是让“输入转换失败”这件事变成一个可以感知的状态而不是异常终止。进阶思考如果你做的是图形界面计算器用户点数字按钮输入这个问题就不存在因为按钮本身限制了合法字符。但命令行版必须处理因为没有输入法限制。4.2 坑二除零判断失效的隐蔽场景翻车现场1if num2 0:这段代码在处理num2 0.0000000001的时候不报错结果程序返回一个巨大的数用户傻眼。翻车现场2有些同学把判断写成if num2 0.0结果一样因为浮点数0.0在Python里确实是精确等于0但用户输入“0.0000000001”时判断不了。如果题目场景允许非常小的除数那就必须用阈值判断。翻车现场3更隐蔽的是有人在除法分支里写num2 float(num2_str)之后又对num2做了某种运算比如num2 num2 * 0然后去判断num2 0这种写法几乎必出问题因为浮点数乘以0后通常为0但如果是NaN或者Infinity结果就乱套。修复统一用abs(num2) 1e-12或至少用abs(num2) sys.float_info.epsilon。epsilon是机器精度表示“浮点数能区分的最小差值”凡是低于这个值的都当成零处理这是更严谨的做法。4.3 坑三处理“12*3”时错误地先做了加法这是本习题最本质的坑前面已经重点讲过。这里我再说一个细节有人用“保存运算符看到下一个运算符再计算前一个”的策略结果因为加号运算符没有触发“计算”逻辑导致结果偏早。修复方案就是先扫乘除再扫加减或者用栈来保存操作数直到遇到优先级更低的运算符再弹出。举个小例子很多人实现时会写伪代码读入n1 读入op1 读入n2 如果op1是 结果是 n1n2 继续读...这样写“35*2”就会先算35。原因是它天然把加法当成了立即执行操作。要改成“先看后面的运算符是不是乘除如果是就先算后面的乘除再回头处理加法”逻辑就复杂了一档。这正是本习题的价值所在——看似一句话的需求会让你第一次认真思考“求值顺序”的本质。4.4 坑四把结果打印成Int导致“5/22”出现Python3里面/除法默认得到浮点数所以5/22.5没问题。但在C、Java等语言里两个整数相除结果是整数5/22这才是新手常见的坑。如果你用其他语言写这道题记得先把操作数转成浮点数或double。Python虽没这个问题但如果你用了//整除也会出同样的怪结果。务必分清/和//的区别简单计算器只用/。4.5 坑五表达式里出现连续空格或Tabtokenizer全乱了初学者处理输入时喜欢用split( )可是用户输入“3 5”和“35”不一样如果空格不止一个或者夹了Tabsplit出来就是一堆空串和错位。修复不要依赖固定的分隔符写一个扫描式tokenizer就是我上面tokenize函数干的事遇到空格直接跳过把数字和运算符分开提取。这样用户怎么输入空格都不影响。如果你用正则表达式来做re.findall(r\d\.?\d*|[\-*/], expr)也是一种更简洁的方式但要注意负号和减号的区分正则没那么好处理。5. 再往前走一步如果习题要求括号、连续计算或图形界面应该怎么扩展这道题做完了很多人以为很快就结束了。但作为博主我建议你把扩展方向提前看一眼因为面试或课程设计很可能从这里延伸。5.1 支持括号的求值思路在tokenizer得到token列表之后如果遇到括号最先想到的是递归下降法每遇到一个左括号就递归地求括号内的表达式再把结果当成普通数字放回原token列表。另一种方式是符号栈数字栈调度场算法把中缀表达式转成后缀表达式再求值。这两种思路都能完美处理优先级和括号但调度场更通用。如果你学数据结构到栈这一章建议用调度场重新写一次计算器那种收获比本题大得多。我的建议顺序先把本文的两遍扫描写法完全吃透理解“优先级”是如何靠扫描顺序实现的再去看调度场算法你会发现两者本质一模一样只是调度场把“先算谁”用栈来显式管理了。5.2 支持连续运算且保留上次结果真实计算器里用户算完一个结果后可能想拿这个结果继续运算比如先算23得到5再按*4得到20。实现思路是把当前结果保存到变量里下次读入表达式时如果表达式以运算符开头就默认把上次结果作为左操作数拼接到表达式头。例如上次结果是5用户输入“4”程序就组装成“54”再走一遍解析流程。这个功能不难但很贴心做出来以后像模像样。5.3 用GUI还是命令行命令行已经足够完成“习题2.1”。除非你是在做课程设计或课外项目可以考虑加一个简单的Tkinter界面。但GUI最容易出的问题不是逻辑而是按钮事件、显示字符串拼接、异常弹窗处理这些和计算核心逻辑是平行的复杂度。如果你决定上GUI我建议把计算逻辑保持独立别和界面代码混在一起。界面只负责“收集输入 - 调用calc函数 - 显示结果”calc函数要写成纯函数输入字符串输出数字不碰任何界面对象。这样以后你测试逻辑或者换个界面库计算部分完全不用动。5.4 单元测试怎么写最后强烈建议你给计算器写几个单元测试。别觉得小题不值得这道题非常适合练测试意识import unittest class TestCalculator(unittest.TestCase): def test_priority(self): self.assertEqual(evaluate(tokenize(35*2)), 13.0) def test_div_by_zero_raises(self): with self.assertRaises(ZeroDivisionError): evaluate(tokenize(1/0)) def test_negative_start(self): self.assertEqual(evaluate(tokenize(-35)), 2.0) def test_float_operation(self): self.assertEqual(evaluate(tokenize(5/2)), 2.5) if __name__ __main__: unittest.main()你把测试跑通代码质量就有了基本保障。以后无论是重构还是增加括号支持只要测试没过立刻就知道哪里改坏了。这个习惯可能比这道习题本身更重要。6. 总结一些通用的编程习惯从习题到工程实践的衔接最后再来一遍经验层面的话。做完“简单计算器”这道题建议你回头审视一下自己的代码里有没有这些好习惯输入全部经过校验不信任任何用户输入。除零、非法字符等失败场景用明确的错误信息表达而不是让程序默默崩掉。计算逻辑写成独立函数和输入输出分离方便测试和复用。用阈值处理浮点数比较时有意识地考虑机器精度。优先保证逻辑正确再优化代码性能。计算器这种规模性能根本不是瓶颈。我在带新人时经常说学编程不是学语法也不是学“会做题”而是学面对未知问题时的一整套拆解和防御策略。“简单计算器”这道题恰恰是把这几个关键习惯浓缩在一起的最短路径。你可能花半小时就能写出能跑的版本但把它打磨到“无论用户怎么乱来都不崩、而且计算符合数学优先级规则”这个门槛值得认真迈过去。如果再有人问我“习题2.1 简单计算器值不值得好好做”我的回答永远是值得而且最好一口气把健壮性和优先级都做了。它虽然不产生什么炫酷效果却可能是你写的第一个“不会轻易闹脾气”的程序。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进