
1. 为什么用91行写一个计算器先说一个很多初学者都绕不开的场景学完Java基础语法之后做的第一个小项目基本都是计算器。原因很简单——它麻雀虽小却能把变量、运算符、分支、循环、方法、异常处理这些基础的不能再基础的知识全串起来。而“91行代码”这个数字是我在做这个项目时给自己定的一个约束既不堆砌花活也不靠压缩代码牺牲可读性就用最朴素、最通用的方式把四则运算计算器写出来还能讲清楚每一行代码在干什么。如果你正准备Java面试或者是刚学完面向对象、集合、IO这些内容但总觉得不太会用的人这个项目非常合适。因为它能帮你把“语法”变成“工具”——语法知识是散装的计算器是第一个能把它们组装起来的实用场景。同时它也能让面试官看到你具备把需求拆成代码的能力。我见过很多面试者在简历上写“熟悉Java基础”结果一让手写一个能处理“括号 加减乘除 小数”的计算器当场卡壳。原因并不一定是技术不行而是没有人告诉他这种东西到底该从哪儿下手。所以我这篇文章的核心目的就是从零到一解构这91行代码的设计思路把手写计算器的每一个细节、每一处边界问题、每一个面试官可能会追问的点全部摊开讲清楚。你不需要是资深工程师只要跟着思路走完一遍就能亲手写出一个能用的版本并且明白为什么这么写、还能怎么改。2. 核心思路从“能算”到“算对”2.1 分清“界面”和“逻辑”是第一步很多新手一上来就想着做窗口用Swing或者JavaFX拖一个带按钮的界面出来这当然是好事但往往会陷进一个坑界面还没做完逻辑已经写得乱七八糟。原因在于计算器这种东西核心问题从来不是“长什么样”而是“怎么把一个表达式算对”。字符窗口版本的界面非常快核心逻辑也能独立出来。拆开来说计算器本质上只做三件事读入用户输入的一个表达式字符串解析这个字符串输出结果。这三件事完全可以不依赖任何界面库用纯java.util.Scanner在控制台里就能完成。等核心逻辑全部验证通过再套上一个按钮界面就是标准的模型-视图分离思路。我见过一些培训班项目一上来就是Swing 事件监听 界面布局最后核心求值逻辑只有几十行还藏着各种Bug其实这就是主次颠倒了。所以91行代码里我要做的第一件事就是把“界面层”砍掉只留一个main方法作为入口用Scanner读入用System.out打印结果。这样一来所有注意力都集中在解析和求值的核心算法上。2.2 中缀表达式为什么难算我们平时写的数学表达式比如1 2 * 3叫中缀表达式因为运算符在两个操作数中间。问题在于人的直觉和计算机的执行顺序不一样。人看到一个表达式会自动遵守“先乘除后加减括号优先”的规则但计算机从左到右扫一遍如果遇到1 2就先算结果就成了9这就是错误的根源。解决办法有两个方向。一是把中缀表达式转换成后缀表达式逆波兰表达式也就是把运算顺序彻底打散再用一个栈来求值。二是在解析过程中直接处理运算符优先级比如经典的“递归下降解析法”。两种方案都能用但如果你问哪种更适合“91行”这个体量我推荐递归下降解析。理由有三第一它不需要额外维护一个显式的运算符栈代码更容易控制第二它直接对应数学语法的层次结构读起来像在读题第三它能很自然的支持括号嵌套遇到左括号就递归调用一次子表达式解析遇到右括号就返回逻辑非常统一。2.3 递归下降解析的朴素模型递归下降解析这个概念听起来很高大上但其实背后的思路特别朴素把一个表达式拆成一层一层的“语法单元”。表达式可能包含加减运算比如 1 2 - 3。项可能包含乘除运算比如 2 * 3 / 4。因子最基本的数字或者一个带括号的表达式比如 5 或者 (1 2)。解析时从最高层表达式出发先尝试解析第一项然后判断下一个符号是加号还是减号如果是就继续解析下一项。而解析每一项时又先解析它的因子再判断符号是乘号还是除号。这个层次关系天然就实现了优先级——乘除会先于加减被解析因为项比表达式更“底层”。我用一个生活化的类比来解释你可以把表达式想象成一串珍珠项链数字是珍珠运算符是连接珍珠的线。加减法是粗线只负责把大段大段串在一起乘除法是细线先把珍珠两两勒紧绑好。递归下降解析就是先处理那些“勒得最紧”的部分再处理“绑得松散”的整体。3. 91行代码的完整实现与逐段讲解3.1 环境准备你只需要一个JDK要跑这段代码你只需要装了JDK 8或更高版本以及一个文本编辑器。不需要IDE也没关系记事本都能写因为这一段代码不依赖任何第三方库。Windows、macOS、Linux都无所谓Java的跨平台特性在这种小项目里体现得淋漓尽致。如果你还没装JDK去Oracle官网或者用包管理器装一个OpenJDK 17就行。装完之后在命令行里执行java -version能正确输出版本号环境就算准备好了。3.2 完整代码91行的四则运算计算器下面就是核心代码。我特意保持行数可控同时每行都做了注释方便你逐行对照理解。你可以直接复制到你的项目里或者新建一个Calculator.java文件保存。import java.util.Scanner; public class Calculator { private int pos -1; private int ch; private String input; public Calculator(String input) { this.input input; } private void nextChar() { ch (pos input.length()) ? input.charAt(pos) : -1; } private boolean eat(int charToEat) { while (ch ) nextChar(); if (ch charToEat) { nextChar(); return true; } return false; } private double parse() { nextChar(); double x parseExpression(); if (pos input.length()) { throw new RuntimeException(意外的字符: (char) ch); } return x; } private double parseExpression() { double x parseTerm(); for (;;) { if (eat()) x parseTerm(); else if (eat(-)) x - parseTerm(); else return x; } } private double parseTerm() { double x parseFactor(); for (;;) { if (eat(*)) x * parseFactor(); else if (eat(/)) x / parseFactor(); else return x; } } private double parseFactor() { if (eat()) return parseFactor(); if (eat(-)) return -parseFactor(); double x; int startPos pos; if (eat(()) { x parseExpression(); eat()); } else if ((ch 0 ch 9) || ch .) { while ((ch 0 ch 9) || ch .) nextChar(); x Double.parseDouble(input.substring(startPos, pos)); } else { throw new RuntimeException(意外的字符: (char) ch); } return x; } public static void main(String[] args) { Scanner scanner new Scanner(System.in); System.out.println(输入表达式(输入exit退出):); while (true) { System.out.print( ); String line scanner.nextLine().trim(); if (line.equalsIgnoreCase(exit)) break; if (line.isEmpty()) continue; try { Calculator calc new Calculator(line); double result calc.parse(); System.out.println( result); } catch (Exception e) { System.out.println(错误: e.getMessage()); } } scanner.close(); } }3.3 代码为什么这么写三个核心机制整个程序的核心是三个机制全局游标、递归下降、字符吞噬。全局游标即pos和ch两个变量。pos代表当前读到的位置ch代表当前位置的字符。每次调用nextChar()就往后移动一位并更新ch为下一个字符。这个设计等价于一个简化版字符串迭代器好处是整个解析过程都是在一个线性序列上向前扫描永远不会回头。字符吞噬即eat方法它先跳过所有空格然后判断当前字符是不是期望的那个如果是就吞掉并返回true不是就返回false。这个方法在解析循环里特别常用比如parseExpression里判断加号或减号就是靠eat。整个程序里只有这里需要关心空格问题其他地方完全不用处理因为只要词法分析这一步稳定后续只管往前扫就行。递归下降即parseExpression、parseTerm、parseFactor这三个方法互相调用。parseExpression负责处理加减它先解析一个Term然后不断向后看如果是加号或减号就继续解析下一个Term并做运算。parseTerm负责处理乘除结构和parseExpression完全一样区别只在符号和调用的下一层是parseFactor。parseFactor是最终落地的地方它处理三种情况一元正负号、括号、数字字面量。这套结构符合文法分层而且每一层只关心自己职责内的运算符。所以运算优先级不是靠if-else的嵌套顺序硬写出来的而是靠方法调用的层次结构天然保证的。4. 边界情况、优化思路和踩坑实录4.1 最容易被问到的边界情况面试官或者你自己测试时通常会拿这些输入来怼你的程序。第一类是空白字符。比如 1 2 * 3如果没处理空格解析器很容易把空格当成一个“意外的字符”直接报错。我的代码里在eat方法开头统一跳过空格所以整个表达式内任意位置有空格都能正常处理。第二类是空表达式和退出指令。用户在控制台直接按回车如果不处理Double.parseDouble会抛异常。所以main循环里先判断line.isEmpty()空行直接重新提示。退出指令exit也做了大小写兼容这个在控制台交互里算标配。第三类是一元正负数。表达式 -5 3 如果第一时间就要求parseFactor解析会遇到负号。这个在parseFactor里通过eat(-)返回-parseFactor来处理同理号直接递归进去。你能看到这里递归下降的表达力很强一个递归调用就解决了正负号连续出现的问题比如 --5 也能被正确解析为5。第四类是除数为0。Java double类型做除法时不会抛异常而是返回Infinity或NaN。严格来说这不算Bug但面试时最好能主动提一句说明你知道这个行为并且能在上层业务逻辑里做一些更友好的处理比如判断除数为0时提示“除数不能为0”。4.2 括号不匹配、非法字符和数字格式的兜底一个计算器如果只处理正确表达式那它只能算玩具。真正值得关注的是“给错了要怎么办”。我的实现里几个主要错误点都做了兜底。非数字字符比如 1 a遇到a时parseFactor里既不是正负号、不是括号、也不是数字或者点号就会抛出RuntimeException(意外的字符)。括号不匹配比如 (1 2解析完括号内的内容后外面还有一个左括号没被吃掉整个parse结束后发现pos input.length()就会抛出“意外的字符”异常。数字格式不合法比如 1.2.3在parseFactor的死循环里会连续吞并所有数字和点号最终substring出来的字符串是1.2.3Double.parseDouble会抛出NumberFormatException。这三类错误都在main方法里被catch(Exception)统一兜住了并且把错误信息打印出来程序不会崩溃可以继续等待用户输入下一条。这种“不让程序死掉”的控制台交互方式是所有命令行工具的基本功。4.3 实测效果与输出示例我直接把这段代码编译运行了一下看看真实的控制台效果 1 2 * 3 7.0 (1 2) * 3 9.0 -5 3 -2.0 1 2 3 4 10.0 10 / 4 2.5 1 2 * (3 - 1) 5.0 1 a 错误: 意外的字符: a 3 / 0 Infinity看到没有基本的四则运算、括号优先级、一元符号、错误拦截都符合预期。这里有一个小细节表达式 1 2 * (3 - 1) 的结果是5.0因为3-1得22*2得4再加1得5。如果你拿Windows自带计算器在标准模式下算它会从左往右不加括号地算得到6但在科学模式下会正确得到5——也就是说我们的程序行为跟科学模式一致。4.4 与“先转后缀再求值”的方案对比很多教程会推荐先把中缀表达式转成后缀表达式然后用一个栈来做计算。两种方案我都在实际教学中用过简单对比一下维度递归下降解析中缀转后缀 栈求值代码行数较短约80~100行稍长要写两个阶段可读性方法分层对应语法直观需要先理解栈的转换规则扩展性加新运算符只需加一个层级或分支要同时改转换和求值两部分调试难度递归调用栈比较清晰栈的状态需要自己脑补理论深度适合讲文法、语法树适合讲栈的应用、逆波兰表达式从面试角度讲两种方案都是加分项但如果时间只够准备一种我个人更推荐递归下降。因为面试官追问“你的优先级是怎么处理的”“括号嵌套怎么处理”时递归下降的答案是可以直接指到代码里对应方法上的而栈方案还要绕一层。5. 如何用这个项目反哺你的Java面试5.1 这段代码覆盖了哪些高频考点我用这个计算器项目做面试辅导时经常告诉学生不要把它只当成一个“交作业”的小项目它其实是一张很好的“面试地图”。因为每一行代码背后都藏着至少一个Java基础考点。类与对象Calculator类封装了表达式解析的状态pos、ch、input这本身就是面向对象里“状态与行为绑定”的体现。字符串处理nextChar里用三目运算符控制末尾哨兵substring用于截取数字字面量这些都会在面试的代码题里反复出现。异常处理自定义RuntimeException的抛出main方法里catch的兜底策略考察的是你对异常机制的理解而不是死记Error和Exception的区别。递归parseExpression和parseTerm互相调用是递归思想最干净的例子比计算斐波那契数列更有说服力因为它有一个明确的“计算目标”。栈与优先级虽然没有显式写栈但递归下降的本质就是用系统调用栈来替代显式栈这一点可以和面试官深聊。所以当你把这段代码写在简历上或者在面试时主动提起来面试官几乎必然会追问你的运算优先级怎么做的遇到括号怎么办除数为0呢字符串“1 2 * 3”会经过哪些方法调用这些追问正是检验你是否真正理解代码的方式。5.2 通过看方法调用链来记忆知识假设输入是 1 2 * 3整个调用过程是这样的main方法调用calc.parse()parse调用nextChar()把pos从-1挪到0ch变成字符1。parse调用parseExpression()parseExpression先执行parseTerm()。parseTerm()先执行parseFactor()parseFactor看到字符1通过while循环把1吞掉然后解析出数字1.0。parseTerm()再调用eat()发现当前字符是不是也不是/直接return x。此时x1.0。parseExpression()在parseTerm()返回后调用eat()成功于是执行x parseTerm()。这次新的parseTerm()会先经过parseFactor()把所有数字和运算符按同一种方式解析先得到2.0再判断当前字符是*继续算出3.0最终x变成6.0。两层递归回来之后parseExpression返回7.0parse接着检查pos是否越界最终打印结果。把这条链走一遍你对递归下降的理解就很深刻了。面试时如果能这样讲出来比说一句“我用了递归”要有说服力得多。5.3 如何给面试官讲这个项目我建议你准备一个30秒的电梯版介绍和一个2分钟的技术深挖版核心立足点不要放在“我写了一个计算器”而要放在“我理解表达式求值的本质”。电梯版可以这样说“我实现了一个支持加减乘除、括号、小数的计算器核心算法是递归下降解析。它把表达式按优先级拆成表达式、项、因子三层通过方法之间的相互调用完成求值总代码量控制在91行不依赖第三方库纯Java命令行运行。”2分钟的技术深挖版加一条“为了处理输入中可能的空格和非法字符我设计了eat方法统一吞掉期望字符同时在main方法里做了异常兜底。整个解析过程的状态都封装在Calculator类的字段里所以代码的复用性也还可以如果以后要扩展成界面版可以把这套逻辑原封不动地拉进Swing的按钮事件里。”这样讲面试官能感受到你是真的会写代码而不是背了一个答案。6. 从基础版到高级版四步无极进化6.1 第一步支持sin、cos、log等函数如果你不满足于91行想把它扩展得更实用那么最常见的扩展就是支持常见数学函数。在parseFactor里除了数字、括号我们可以再增加一个“函数名”分支。比如如果当前字符是一个字母就把连续的字母吞掉当作函数名然后期待一个左括号再解析括号内的表达式作为参数最后调用对应的Math函数。else if (Character.isLetter(ch)) { StringBuilder sb new StringBuilder(); while (Character.isLetterOrDigit(ch)) { sb.append((char) ch); nextChar(); } String func sb.toString(); eat((); double arg parseExpression(); eat()); switch (func) { case sin: return Math.sin(arg); case cos: return Math.cos(arg); case log: return Math.log(arg); case sqrt: return Math.sqrt(arg); default: throw new RuntimeException(未知函数: func); } }这段代码加进去以后输入 sin(0) 会得到0.0log(Math.E)会得到1.0计算器就从“四则运算”升级成了“科学函数计算器”。这一段的重点是你不仅会算还能处理函数名和参数嵌套。6.2 第二步加入幂运算幂运算的优先级比乘除还要高一层所以不能直接塞进parseTerm里面否则顺序会乱。正确的做法是在parseExpression和parseTerm之间再插入一层parsePower思路是优先拿到一个factor然后不断往后看是不是^符号如果是就递归取右边作为指数。优先级顺序调整后变为private double parsePower() { double base parseFactor(); if (eat(^)) { double exponent parsePower(); // 右结合 return Math.pow(base, exponent); } return base; }需要注意的是幂运算一般是右结合的也就是说 2^3^2 应该是 2^(3^2)512而不是(2^3)^264。这里通过递归调用自身而不是循环天然实现了右结合。这一处细节如果面试时能主动说出来绝对会加分。6.3 第三步把逻辑封装成可复用的类库目前Calculator的parse方法返回的是double如果我想把它集成到图形界面里就可以把整个Calculator当成一个独立的工具类对外只暴露一个静态方法public static double eval(String expression) { return new Calculator(expression).parse(); }然后Swing界面的按钮监听器只需要调用Calculator.eval(textField.getText())把返回结果放回文本框即可。这样一来界面层和解析层彻底解耦后续想换JavaFX、Android、Web后端都不用改核心算法。这种分层设计的思想在面试中也是高频考点。6.4 第四步记录历史表达式并支持变量到了这一步你已经不是在做简单计算器了而是在做一个mini脚本语言。可以在Calculator类里加一个static MapString, Double环境变量表遇到形如 x 1 2 的表达式时先解析右边再把值存入map。下一次再遇到x就能从变量表里取出值参与计算。这个扩展涉及到一个新概念赋值语句和表达式语句的区分。好在递归下降解析天然适合做这件事只需要在parse入口处判断有没有等号有就解析右侧并赋值没有就正常解析。支持变量以后这个项目就可以往“表达式引擎”方向走了这是很多规则引擎、报表系统的核心功能。如果写到简历上含金量会明显提升。7. 常见问题速查表与调试经验7.1 五个高频问题一次排查完现象可能原因修复思路输入“1 2”报“意外的字符”没处理空格或者pos越界检查eat方法里是否跳过了空格检查parse结束后pos是否等于input.length()输入“(12”不报错但结果不对括号不匹配时缺少提示在parseFactor里吃掉右括号后可以判断一下是否存在右括号缺失输入“3/0”得到Infinitydouble除法本身的行为在parseTerm除法分支里判断除数是否为0然后抛出业务异常输入“1..2”得到NumberFormatException多个小数点在数字解析循环里限制点号只能出现一次想让“2*-3”也能计算因子层未处理一元负号在parseFactor开头增加eat(-)和eat()分支这些问题我实际在教学生写计算器的过程中全遇到过多数不是算法设计的问题而是边界处理不到位。7.2 我给初学者的调试建议一旦解析器写出来调试它最简单的方法是在每个方法入口打印日志。比如在parseExpression开头打印“进入表达式解析当前字符: x”调用parseTerm前打印“解析项当前字符: x”这样你就能看到递归调用链。当时我调试这个程序遇到一个特别隐蔽的bugparse()方法开头必须先调用一次nextChar()才能把pos从-1移动到0让ch成为第一个字符。如果忘记这一句ch永远是-1parseFactor会直接把-1当成非法字符抛出异常。这个错误只在初始化时出现一次不打印调试信息的话会花很长时间才能找到。所以我强烈建议哪怕是这种91行的小程序也需要保留“打印中间状态”的能力。你在调试阶段可以在每个方法入口加上System.out.println验证完逻辑之后再去掉或者用一个全局debug开关控制。这个小习惯在以后写更大的项目时能省下大量排查时间。8. 从计算器项目延伸出去Java学习还能怎么做写完这个计算器如果你觉得意犹未尽说明你已经跨过了“语法入门”到“工具思维”的坎。接下来有几个自然的延伸方向你可以按照兴趣选。如果你对算法感兴趣可以研究如何用这个递归下降的思路去实现JSON解析器、Cron表达式解析器。解析JSON的规则和解析算术表达式在原理上是一样的只是多了一些键值对、数组和引号的处理。如果你对面向对象设计感兴趣可以把“表达式的词法分析、语法分析、求值”拆成三个独立的类每个类只负责一件事再通过接口连接。这个小重构能让你直观理解单一职责原则。如果你对Java集合框架感兴趣可以把后缀表达式版本的求值器用ArrayDeque实现一遍顺便对比LinkedList和ArrayDeque的性能差异写一篇细微的Benchmark笔记。我的经验是计算器是Java基础阶段性价比最高的一个项目因为它的体量足够小门槛足够低但是打通的知识链路足够长。从字符处理到递归从异常到接口设计从小工具到表达式引擎都能从这91行里长出来。最后分享一个我个人的小习惯写完任何一个项目不管多大我都会在代码顶部注释里写三句话——这个项目解决了什么问题核心设计是什么还有哪些地方是自己不满意、后续想重构的。91行的计算器也不例外。这个习惯让我在半年后回看时能清楚看到自己成长的轨迹。你可以试试看说不定也能打开自己的新世界。