ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从逆波兰到表达式求值:简化版计算器练手项目的核心设计与实现

从逆波兰到表达式求值:简化版计算器练手项目的核心设计与实现 你是不是也刷到过那种“一行代码实现计算器”的短视频号称几秒钟就能跑起来看着很爽但真落到自己手上想加点连续运算、处理个优先级、弄个括号支持立刻就露馅了。我的第四个练手项目就是这个“简化版计算器”从头到尾走了一遍反而觉得这是目前收获最大的一个。这个项目表面上叫“简化版”本质上却是在回答一个问题一个计算器最核心的骨架到底是什么带着这个问题去倒推你会发现计算器远远不止“按钮 显示结果”那么简单它背后藏着表达式解析、状态管理、边界处理这一串东西。这篇文章会把我的设计思路、踩坑记录、代码实现一条龙整理出来给正在选练手项目的朋友一个参考也帮自己做个阶段性复盘。如果你最近也在纠结“下一个项目做什么”或者在写类似工具时总觉得逻辑绕不清楚那这篇内容应该能给你一些具体的启发。1. “简化版”到底简化了什么1.1 先想清楚什么才算“做完”一个计算器我见过太多人做计算器需求列表写得比毕业论文还长要支持三角函数、要能算进制转换、要做成APP带皮肤。结果写到最后核心的“计算逻辑”反而一塌糊涂十次有八次结果不对。所以这次我做“简化版”的第一件事就是给自己画一条硬边界。简化版计算器只要满足四个条件就算合格第一能处理 - * /四则运算第二能正确处理运算符优先级不能从左往右傻算第三支持括号第四支持小数和负数。除此之外什么历史记录、按键音效、深色模式统统不做。这个边界画完之后整个项目的复杂度立刻降下来了。你不用去啃“计算器三级嵌入式”那种竞赛题里才用得到的花活也不用搞什么GUI一上来就被布局折腾半天。先让逻辑在一个命令行窗口里百分百正确这才是“简化版”的正确打开方式。这里我想多说一句很多教学项目之所以烂尾不是代码写不出来是需求自己都没想清楚。今天加一个功能明天加一个功能项目永远是半成品。先把边界画清楚做完了再谈扩展这是个特别重要的习惯。1.2 为什么是第四个项目才轮到计算器“no.4”这个编号其实挺有讲究的。我的第一个练手项目是TODO清单学会了最简单的增删改查第二个练手项目是天气查询学会了调第三方API和处理JSON第三个练手项目是静态博客生成器学会了模板渲染和文件操作。到第四个我刻意挑了一个跟前三个完全不同的类型——它几乎没有UI也不依赖外部服务纯靠数据结构和算法来撑场面。这个选择逻辑很简单基础的操作类和接口对接类的项目做多了人容易产生一种“我也会写程序”的错觉。但计算器会把你拉回现实——核心逻辑稍微有一点瑕疵结果就是错的UI做得再好看也救不回来。它会逼着你老老实实去思考数据怎么流转、状态怎么管理、边界怎么处理。如果你也是写了好几个项目但总觉得都是在“调包”那我强烈建议你试试这种逻辑驱动的类型。它不是最简单的项目但一定是最能让你有“我真的在编程”的感觉的项目。1.3 功能边界的三个层次做完之后我把“简化版”的边界总结成了三个层次给后续做扩展留好了台阶。第一层是“能算对”这是底线比如23*4必须等于14而不是20。第二层是“能容错”输入不合法时不能崩溃得给出清晰的提示。比如用户敲了一段23程序不能炸要告诉用户表达式哪里有问题。第三层是“能扩展”代码结构上保留接入更多运算符的余地比如后面想加%取模、**幂运算改起来不伤筋动骨。这三个层次正好对应了新手到进阶的成长路径。初始版本我建议你死磕第一层把核心算法彻底搞懂稳定了之后再去补第二层的健壮性第三层属于结构设计上的远见看代码的时候带着这个意识去写就行不必一步到位。2. 核心设计从按键到结果的完整链路2.1 两种表达式处理路线我为什么选逆波兰处理四则运算表达式理论上有两条经典路线一条是“调度场算法”把人类书写的中缀表达式比如12*3转换成计算机更好处理的逆波兰表达式后缀表达式对应1 2 3 * 然后再求值另一条是“递归下降解析”直接递归地去匹配语法规则边解析边计算。作为练手项目我最后选的是调度场算法。原因很实际它的思路更适合线性思维转换过程可以用一个显眼的步骤打印出来方便调试。而且它把“解析”和“求值”分成了两个阶段测试时可以先验证转换是否正确再验证求值是否正确问题定位会清晰很多。逆波兰表达式的求值也特别简单粗暴准备一个栈从左到右扫后缀表达式遇到数字就压栈遇到运算符就从栈里弹出两个数字计算结果再压回去。等整个表达式扫完栈里唯一剩下的那个数字就是答案。这种“栈 线性扫描”的组合很难出错而且对新手极其友好。对了这个思路不仅仅用于计算器。很多解释器、编译器的早期版本以及你刷题时碰到的表达式求值系列问题本质都是这套东西。花一个晚上把它吃透收益是长期的。2.2 状态管理计算器不是“一次算完”那么简单我承认我刚开始犯过一个经典的错误试图在用户每按一个数字键的时候就把整个表达式重新计算一遍。结果是灾难性的。比如用户输入了123按下加号的那一刻程序立刻去算了12315然后用户继续输入4表达式变成了154程序又去算19... 这中间一旦涉及优先级和括号状态就完全乱套了。后来我去翻了几个老式计算器的设计思路才意识到真正该做的是“两段式”状态管理一段负责“录入”把用户按下的数字和符号原封不动存入表达式字符串一段负责“展示”在用户点击等号按钮或者需要实时预览时才对表达式字符串调用解析和求值逻辑。录入时不做计算计算前不做录入两边彻底解耦。这里有一组状态转换需要特别注意输入数字、输入运算符、输入小数点、输入括号、输入等于号、清空这些动作之间是有合法与非法之分的。比如用户不能连续按两个运算符不能在小数点后面紧接着再输一个小数点不能在一个没闭合的左括号后面直接按等号。把这些规则放进一个状态机里建模逻辑会清晰很多。建议画一张状态表把每个动作在每种状态下是否合法标注出来写代码时照着表来就行。2.3 数字与精度整数、浮点、格式化的三角关系计算器里最容易被忽视的就是数字类型。用户输入1/3你拿什么类型存结果如果存成浮点数那显示0.3333333333333333看起来非常不“计算器”如果学会计那样固定两位小数那算23.1415926又没法看了。我采用的方案是内部统一用双精度浮点数参与运算但显示时做一个聪明的人性化处理。具体规则是这样如果结果的浮点误差在极小范围内尽量用整数形式展示如果结果确实有小数位则最多保留10位有效数字并去掉末尾多余的零如果结果绝对值太大超过一定阈值再用科学计数法兜底。这个处理不是可有可无的装饰。写完之后你会发现没有这层格式化0.10.2会显示成一个让你怀疑自己是不是不会写代码的诡异数字。这不是Bug是浮点数存储的固有限制但一个合格的计算器必须在显示层把这种“技术真相”包装得像个正常计算结果。后来我把这套格式化规则单独抽成了一个函数因为我在调试时发现能让结果“看起来正常”的逻辑往往比计算本身还复杂。这也是个经验数据展示和业务逻辑分开写后面维护能省不少心。3. 实操实现命令行版本从零到可发布3.1 项目结构与模块划分虽然是个“简化版”但我没有把所有代码揉在一个文件里。最终的项目结构是这样的main.py入口文件负责读取用户输入、调用解析和求值模块、打印结果。parser.py负责中缀表达式到逆波兰表达式的转换也就是调度场算法的实现。evaluator.py负责对逆波兰表达式求值并把结果格式化成适合展示的字符串。validator.py负责对原始输入做合法性检查比如括号是否匹配、运算符是否连续。tests/放单元测试的目录。这个划分看着简单但它遵循了一个重要的原则文件的职责单一。parser.py里不应该出现任何“打印结果”的代码evaluator.py里也不该去管用户输入的括号是否合法。这样当某个功能出问题时你能立刻定位到该改哪个文件测试也能独立地针对某一块逻辑进行验证。3.2 核心代码调度场算法与求值器的实现调度场算法的核心逻辑可以用这几步来描述准备一个运算符栈和一个输出队列遍历输入的中缀表达式遇到数字就直接输出遇到运算符就把运算符栈中优先级不低于当前运算符的全部弹出到输出队列再把当前运算符压栈遇到左括号直接压栈遇到右括号就把栈顶元素弹出到输出队列直到遇到左括号为止。遍历结束后把栈里剩下的运算符依次全部弹出。我写的时候用的是Python具体实现大概是这样的简化掉了报错分支# parser.py import operator from collections import deque OPS { : (1, operator.add), -: (1, operator.sub), *: (2, operator.mul), /: (2, operator.truediv), } def to_rpn(expr_tokens): output deque() op_stack [] for token in expr_tokens: if token.isdigit() or . in token: output.append(token) elif token (: op_stack.append(token) elif token ): while op_stack and op_stack[-1] ! (: output.append(op_stack.pop()) op_stack.pop() # 弹出左括号 elif token in OPS: while op_stack and op_stack[-1] ! ( and OPS[op_stack[-1]][0] OPS[token][0]: output.append(op_stack.pop()) op_stack.append(token) while op_stack: output.append(op_stack.pop()) return list(output)注意我给每个运算符挂了两个信息优先级数值和对应的运算函数。这样求值的时候就不用写一堆if op 的重复代码直接查表就行。求值器的核心就更短了# evaluator.py from parser import OPS def eval_rpn(rpn_tokens): stack [] for token in rpn_tokens: if token in OPS: b stack.pop() a stack.pop() stack.append(OPS[token][1](a, b)) else: stack.append(float(token)) return stack[0]这里有一个特别值得注意的细节弹出两个操作数的时候b是先用pop()取出来的a是后面取出来的。千万别搞反否则减法变成b-a除法变成b/a结果全错。我见过不下三个人在这上面翻车。3.3 测试用例设计不要只测“112”项目写完第一版之后我干了一件事把测试文件写得比功能代码还长。这不算夸张因为计算器的坑全藏在边界情况里。我建议至少要覆盖这五类用例。第一类常规运算比如23*414、(23)*420确保基本逻辑正确。第二类连续运算比如23-456确保从左到右结合律符合预期。第三类嵌套括号比如((12)*(34))/(5-2)7确保括号处理没毛病。第四类负数与小数点比如-352、3.14*26.28。第五类错误输入比如空字符串、只有运算符、括号不匹配这些必须稳定提示错误而不是抛异常。我在测试用例里还加了一个“幂等性”检查生成的逆波兰表达式再次求值得到的结果必须和直接在原表达式上求值的结果一致。这个检查能在重构代码时提供极大的心理安全感。另外写测试时建议用断言而不是靠肉眼观察。实测下来把预期结果写进脚本一键跑完整套用例比自己在命令行里一条条试高效太多了。3.4 打包成可直接运行的程序main.py写完后我还顺手把它打包成了一个独立的可执行文件。这样在任何一台没有装Python环境的机器上双击就能跑项目才算真正“交付”。我用的打包工具是PyInstaller安装和打包命令都很简单pip install pyinstaller pyinstaller --onefile main.py打包完成后在dist/目录下就能找到可执行文件。这里有几个坑需要提醒第一打包过程中如果触发了杀毒软件报警通常是误报换个目录输出一般能解决第二--onefile打包的程序启动时会慢一点因为它要先解压到临时目录这是正常现象第三如果代码里读到了外部文件路径打包后路径会失效好在计算器项目不涉及这个直接用标准输入输出是最稳妥的。4. 常见问题排查与避坑手册4.1 字符串转数字为什么“3.14”没问题“.5”就报错你在处理用户输入时肯定绕不开把字符串转成数字这一步。Python 的float()函数对字符串的要求比较严格float(.5)会直接报错float(3.)也不行。但用户在真实输入里确实可能打出这种“反常规但心理上合法”的表达式。我的处理方案是在校验阶段做一个规范化如果某个 token 以小数点开头就在前面补一个0变成0.5如果 token 以小数点结尾就补一个0变成3.0。这步操作看起来像是在迎合用户实际上是在做“容错设计”——计算器不是编译器没必要用编译器的严格标准来要求用户。4.2 负数的“身份”问题它到底是减号还是负号负数是计算器项目里最容易引发逻辑混乱的地方。比如-35这里的-是负号不是减法运算符但2-3里的-就纯粹是减法。如果不提前设计好处理策略解析器会把负号和减号混为一谈。我采用的策略是“环境判断法”当-出现在表达式开头或者出现在左括号后面或者出现在另一个运算符后面时把它当成负号处理直接附着到后面的数字上。例如2 * -3解析阶段就把它重写成2 * (0 - 3)或直接吸收成一个数字 token-3。这个方法虽然看起来有点“取巧”但在四则运算这个范围内非常实用。我的实测经验是先用 tokenizer 把-粘到数字上再交给调度场算法优先级问题就自然消失了。4.3 除零、溢出与异常处理报错也比崩溃强如果用户输入1/0你的程序会怎样如果什么都不做Python 会抛出一个ZeroDivisionError然后整个命令行程序直接崩溃。这在“简化版计算器”里是绝对不可接受的。处理方式很简单在求值阶段对除法做保护检测到除数为零时不调用运算符函数而是直接返回一个代表错误的标记。然后在main.py里捕获这个标记给用户显示一句“除数为零无法计算”。同样的思路也适用于结果溢出即某个中间结果超出了浮点数能表达的范围此时可以显示“数值过大超出范围”。这套“先检查、再计算”的顺序比“先计算、再捕获异常”要可靠。因为很多错误在计算之前就可以预判没必要让程序走完异常处理流程再回来。4.4 界面交互回车到底该不该清空如果你按我做的那样程序是命令行交互式运行的那还得思考一个问题用户每次算完一个表达式是继续在同一会话里输入下一个还是程序直接退出。我最终做成的是可持续输入用户输入exit或q时才退出。这样连续算十个表达式就不用重启十次程序了。这里还有一个交互细节值得琢磨用户输入一个表达式按下回车结果展示出来之后下次输入前应不应该自动清空旧内容我的做法是保留一个简单的“输入提示条”每次打印结果后自动将表达式字符串重置为空。这样能够避免上一次的字符污染下一次输入。5. 从简化版到热搜里的那些计算器5.1 嵌入式计算器当逻辑从软件走向硬件做完成软件版计算器之后我顺手研究了一下“计算器三级嵌入式”相关的硬件方案发现思路完全不同。软件版里你可以随意用一个栈内存管够但到了单片机比如STM32、51单片机上资源紧张到你可能需要重新考虑数据结构和扫描方式。典型的嵌入式计算器方案是矩阵键盘负责按键扫描比如4x4矩阵能支持16个按键数码管或LCD负责显示主程序在一个无限循环里轮询按键事件。难点从“表达式解析”转移到了“按键防抖”和“时序控制”。比如你没有专门的按键消抖电路时程序层面得多做一次延时检测否则按一个数字会跳出来两三个。如果你已经做完了软件版强烈建议再去碰一碰嵌入式版本。同一个“计算器”名词在不同平台上会把你推向完全不同的思维方式这种对比体验很宝贵。5.2 GIS字段计算器计算器作为“批量处理工具”热搜词里还有一个“GIS字段计算器”这是地理信息软件里用来批量计算属性数据的模块。它的底层逻辑跟我们的简化版计算器其实是相通的给一个表达式给出多个输入值批量算出结果。比如你在一个点图层里有一个“人口”字段想给每个点加上“人口密度”字段只需要在字段计算器里写一句类似人口 / 面积的表达式软件会自动对每一个图斑执行计算。这背后的原理依然是“解析表达式、替换变量、求值输出”这一套东西。这个例子给我们的启发是计算器项目的核心价值并不仅仅在于“算出11等于几”而在于它教会了你表达式的解析与执行框架。以后你做自动化脚本、做Excel宏、做数据处理工具遇到“用户输入规则、程序批量执行”的需求底气会足很多。5.3 计算器设计的通用方法论最后我强烈建议大家把“计算器设计”当成一个方法论去理解而不是一个孤立的玩具项目。在各大技术问答平台上“如何写一个计算器”这类问题经久不衰用Java写、用Rust写、用JavaScript写的都大有人在。这个问题的通用答案也早就不止一种调度场算法、递归下降、表达式树、运算符重载每一条路线都能通向正确答案。之所以推荐大家把它当成“通用方法论”是因为它牵涉的模块化设计、边界测试、状态管理、错误处理放在任何项目里都是通用的。你能把简化版计算器写利索了再去上手更复杂的解释器、模板引擎甚至在表单里做动态校验思路都会顺畅不少。如果你按照我上面的步骤做完了自己的版本还可以试着给自己加一个“扩展挑战”给表达式增加%取模运算或者增加单目负号的无缝支持再或者做一个带括号自动补全的交互界面。每一次扩展都会让你对这套框架的理解再深一层。我在实际做这个项目的过程中最大的体会是那些看起来“简单”的需求往往隐藏着最多的边缘情况。用户不会按你预想的脚本去输入程序也不会因为你只测试了“11”就保证((23))*4是对的。给每一步逻辑都配上测试把每个边界都想一遍多花半小时能让后面的调试省下好几个小时。如果你也想找一个“从会写代码到写好代码”的过渡项目简化版计算器是个极好的起点动手吃完这一整套收获绝对不止一个可以算数的命令行工具。
RELATED READING

延伸阅读

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