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.3,Double.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得2,2*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挪到0,ch变成字符'1'。
- parse调用parseExpression(),parseExpression先执行parseTerm()。
- parseTerm()先执行parseFactor(),parseFactor看到字符'1',通过while循环把'1'吞掉,然后解析出数字1.0。
- parseTerm()再调用eat(''),发现当前字符是'+',不是''也不是'/',直接return x。此时x=1.0。
- parseExpression()在parseTerm()返回后,调用eat('+'),成功,于是执行x += parseTerm()。
- 这次新的parseTerm()会先经过parseFactor(),把所有数字和运算符按同一种方式解析,先得到2.0,再判断当前字符是'*',继续算出3.0,最终x变成6.0。
- 两层递归回来之后,parseExpression返回7.0,parse接着检查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.0,log(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)^2=64。这里通过递归调用自身而不是循环,天然实现了右结合。这一处细节,如果面试时能主动说出来,绝对会加分。
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 Map<String, Double>环境变量表,遇到形如 x = 1 + 2 的表达式时,先解析右边,再把值存入map。下一次再遇到x,就能从变量表里取出值参与计算。
这个扩展涉及到一个新概念:赋值语句和表达式语句的区分。好在递归下降解析天然适合做这件事,只需要在parse入口处判断有没有等号,有就解析右侧并赋值,没有就正常解析。
支持变量以后,这个项目就可以往“表达式引擎”方向走了,这是很多规则引擎、报表系统的核心功能。如果写到简历上,含金量会明显提升。
7. 常见问题速查表与调试经验
7.1 五个高频问题,一次排查完
| 现象 | 可能原因 | 修复思路 |
|---|---|---|
| 输入“1 + 2”报“意外的字符” | 没处理空格,或者pos越界 | 检查eat方法里是否跳过了空格,检查parse结束后pos是否等于input.length() |
| 输入“(1+2”不报错但结果不对 | 括号不匹配时缺少提示 | 在parseFactor里吃掉右括号后,可以判断一下是否存在右括号缺失 |
| 输入“3/0”得到Infinity | double除法本身的行为 | 在parseTerm除法分支里判断除数是否为0,然后抛出业务异常 |
| 输入“1..2”得到NumberFormatException | 多个小数点 | 在数字解析循环里限制点号只能出现一次 |
| 想让“2*-3”也能计算 | 因子层未处理一元负号 | 在parseFactor开头增加eat('-')和eat('+')分支 |
这些问题我实际在教学生写计算器的过程中全遇到过,多数不是算法设计的问题,而是边界处理不到位。
7.2 我给初学者的调试建议
一旦解析器写出来,调试它最简单的方法是在每个方法入口打印日志。比如在parseExpression开头打印“进入表达式解析,当前字符: x”,调用parseTerm前打印“解析项,当前字符: x”,这样你就能看到递归调用链。
当时我调试这个程序,遇到一个特别隐蔽的bug:parse()方法开头必须先调用一次nextChar(),才能把pos从-1移动到0,让ch成为第一个字符。如果忘记这一句,ch永远是-1,parseFactor会直接把-1当成非法字符抛出异常。这个错误只在初始化时出现一次,不打印调试信息的话,会花很长时间才能找到。
所以我强烈建议:哪怕是这种91行的小程序,也需要保留“打印中间状态”的能力。你在调试阶段可以在每个方法入口加上System.out.println,验证完逻辑之后再去掉,或者用一个全局debug开关控制。这个小习惯,在以后写更大的项目时,能省下大量排查时间。
8. 从计算器项目延伸出去,Java学习还能怎么做
写完这个计算器,如果你觉得意犹未尽,说明你已经跨过了“语法入门”到“工具思维”的坎。接下来有几个自然的延伸方向,你可以按照兴趣选。
- 如果你对算法感兴趣,可以研究如何用这个递归下降的思路,去实现JSON解析器、Cron表达式解析器。解析JSON的规则和解析算术表达式在原理上是一样的,只是多了一些键值对、数组和引号的处理。
- 如果你对面向对象设计感兴趣,可以把“表达式的词法分析、语法分析、求值”拆成三个独立的类,每个类只负责一件事,再通过接口连接。这个小重构,能让你直观理解单一职责原则。
- 如果你对Java集合框架感兴趣,可以把后缀表达式版本的求值器用ArrayDeque实现一遍,顺便对比LinkedList和ArrayDeque的性能差异,写一篇细微的Benchmark笔记。
我的经验是,计算器是Java基础阶段性价比最高的一个项目,因为它的体量足够小,门槛足够低,但是打通的知识链路足够长。从字符处理到递归,从异常到接口设计,从小工具到表达式引擎,都能从这91行里长出来。
最后分享一个我个人的小习惯:写完任何一个项目,不管多大,我都会在代码顶部注释里写三句话——这个项目解决了什么问题,核心设计是什么,还有哪些地方是自己不满意、后续想重构的。91行的计算器也不例外。这个习惯让我在半年后回看时,能清楚看到自己成长的轨迹。你可以试试看,说不定也能打开自己的新世界。