91行代码,能写出一个什么样的Java计算器?我最近把这个项目翻来覆去打磨了好几遍,最后确认:一个带完整Swing界面、支持四则运算、清除、退格、正负号、开根号、百分号的计算器,完全可以压进91行。很多人觉得计算器项目太简单,不值得动手,但当你真的开始硬性控制代码量时就会发现,这其实是验证Java基础功底的绝佳训练场。界面布局、事件监听、状态管理、字符串处理、异常防御,全部被压缩在一个极小的范围里,每一行代码都必须有它存在的理由。这篇分享就把这个91行Java基础计算器的完整实现过程拆开讲清楚,包括界面设计、事件分发逻辑、运算状态管理,以及我调试过程中踩过的几个真坑。适合学完Java基础语法、想动手做第一个图形界面程序的开发者参考,也适合用来当课程作业或者面试前的快速练手项目。
1. 项目概览:这个计算器到底解决了什么问题
1.1 为什么是91行:约束倒逼设计
91行这个数字不是拍脑袋定的,它是我从“一个能用的GUI计算器的最小骨架”反推出来的。如果只做控制台版本,输入一个表达式然后打印结果,二三十行就能搞定,但那只是模拟运算,根本不叫“计算器项目”。如果没有行数限制,大部分人会习惯性堆代码,比如给每个按钮单独注册一个监听器,或者把所有组件全部写成类字段,最后轻松突破五六百行。代码越长,初学者越难看清核心逻辑到底在哪里。
91行的意义在于,它逼着你做减法。你可以仔细审视每一个字段、每一个分支,问自己一句“删掉它会不会出问题”。比如我用一个布尔变量start来标记“当前是否处于等待新数字输入的状态”,如果删掉它,连续输入数字时屏幕上就会拼出“12345”这样一串,没法区分用户是在逐位输入还是在输入单独的数字。也就是说,91行代码更像是一个筛子,把可有可无的代码全部滤掉,留下的全是必需品。这种“约束倒逼设计”的思路,放到真实项目里同样适用——当资源有限时,你才会真正学会排优先级。
1.2 技术选型:Swing不是老土,而是最优解
这个项目用的是Swing而不是JavaFX,原因很朴素:JDK自带,不需要额外安装插件,不需要处理module-info,写完直接javac就能编译运行。对于大多数刚学完Java语法的朋友来说,“能立刻看到窗口弹出来”是很重要的正反馈。JavaFX确实界面更现代,但从配置SDK到配置模块化参数,每一步都在消耗学习热情,对基础练习来说完全没必要。
还有人会问,为什么不用AWT直接写?AWT是Swing的前身,组件是重量级组件,跨平台外观有很大差异,而且很多功能已经被Swing完整取代了。Swing的组件模型也足够经典——JFrame、JPanel、JButton、JTextField,这四个类就能搭出一个完整的窗体应用。学这一个项目,你对Java图形界面的组件层级、布局管理、事件模型就有了直观认识,之后再去看JavaFX或者Android界面,思路都是相通的。
1.3 功能范围界定:做减法,也要知道减掉了什么
这个91行版本明确支持的功能有这些:
- 数字0到9和小数点的输入
- 加减乘除四则运算
- 等号计算结果
- 清除键C、退格键←
- 正负号切换±
- 开根号√
- 百分号%
它也明确不做这些事:
- 不处理运算符优先级,
1 + 2 * 3会得到9而不是7 - 不支持括号
- 不支持连续按等号重复上一次运算
这种取舍在91行的前提下是合理的。我见过有人把这个版本“不支持优先级”当成缺陷来批判,我觉得这是没搞清楚教学项目的定位。这个项目的核心目标是让人理解事件驱动编程和状态管理,而不是复刻一个完整的计算器。运算符优先级解析本身就够单独写一个练手项目了,硬塞进来只会让代码急速膨胀,失去“基础练习”的意义。想清楚边界,你才知道这个项目到底能教会你什么。
2. 核心设计思路拆解:91行怎么组织逻辑
2.1 三个状态变量撑起整个运算状态机
整个类的核心状态只有三个字段,没有多余的东西。
double result保存的是当前累计的运算结果。String op保存的是上一次按下的二元运算符。boolean start是最关键的一个标志位,它表示“当前显示框里的内容是否已经是一个完整的数字,可以参与下一次运算了”。
我用一个生活化的类比来解释这三个变量的关系。可以把计算器想象成一条流水线:result是工人手里攥着的半成品,op是工人脑子里记住的“下一步要做什么操作”,start则像是一个指示牌,告诉工人“当前料已经可以传下去了,还是正在组装中”。比如你按下5,显示框显示“5”,此时start为false,表示这里有一个完整数字;你再按+,工人把5收进result,记下操作是加法,然后把指示牌翻成start=true,意思是“下一个数字来了之后,我会用新数字和result做加法”。这套状态模型是整个计算器所有按钮行为的底层逻辑,理解它,后面所有代码都顺理成章。
2.2 一个actionPerformed分发所有按钮事件
这个类实现了ActionListener接口,所有按钮都通过addActionListener(this)注册到同一个监听器对象上。当任意按钮被点击,actionPerformed方法就会被调用,通过e.getActionCommand()拿到按钮上的文字,再用字符串匹配去分发不同逻辑。
有一个小技巧非常符合“91行”的气质:判断数字键和小数点键,用的不是一长串if-else,而是"0123456789.".indexOf(cmd) >= 0。这个表达式写起来只有一行,就能覆盖11个按键。如果不这么写,你要写11个if (cmd.equals("1"))、if (cmd.equals("2"))这样的判断,代码量直接翻倍。这就是在行数约束下自然逼出来的高效写法,平时你可能不会想到用indexOf去判断按钮类型,但在这个场景下它确实是最合适的。
事件分发的顺序也经过设计:先处理数字和小数点,再处理一元操作符(C、←、±、√、%),最后处理二元运算符和等号。这个顺序保证了一元操作不会触发二元运算的合并逻辑,也保证了数字输入优先于其他所有操作。
2.3 顺序运算模型:取舍与边界
这个版本采用的是“顺序运算”模型,也就是每按一次二元运算符,如果当前显示框里已经有一个完整数字,就先把result和当前数字做一次运算,再把结果显示出来,然后记录新的运算符,把start置为true。
举个例子,输入1 + 2 + 3的完整过程是:按1,显示屏显示1;按+,此时result变成1,记录运算符为加号,start变为true;按2,显示屏显示2,start变为false;再按+,此时执行1+2,结果3显示出来,同时记录新的运算符还是加号,start再次变为true;按3,显示屏显示3;按=,执行3+3,最后显示6。整个过程就像边输入边计算,非常直观。
但这种模型天然没有优先级概念。如果要支持1+2*3=7,就必须引入表达式解析,比如把中缀表达式转成后缀表达式,再用栈去计算,或者用递归下降解析成语法树。这些方案单独写一个项目都很值得,但随便实现一下就是两三百行打底,完全不适合塞进一个91行的基础项目里。所以这里的选择很清晰:先掌握简单状态机,下一个项目再做解析器,学习曲线会更平滑。我建议你先接受这个局限,把当前版本跑通、理解透,这才是最有价值的收获。
3. 完整代码实现与逐段解析
3.1 界面初始化:从窗口到每一个按钮
先看界面部分。Calculator继承JFrame,在构造方法里完成所有初始化工作。第一件事是设置窗口标题、关闭行为,以及布局方式。这里选择BorderLayout,因为整个窗口只有上下两大块:上面是显示框,下面是一整块按钮面板,用BorderLayout的NORTH和CENTER两个区域就能干净地分开。
显示框是一个JTextField。它要设成不可编辑,防止用户直接往输入框里敲文本破坏状态机逻辑;同时把文字对齐方式设置为右对齐,这样更接近日常计算器的显示习惯;字体大小设成28像素,保证长数字也能清晰展示。
按钮面板用的是GridLayout(5, 4, 4, 4),表示5行4列,组件之间水平间隔4像素、垂直间隔4像素。所有按键文字放在一个String[]数组里,然后循环创建JButton、设置字体、注册监听器、添加到面板。这种“一维数组驱动界面生成”的写法非常省代码,这也是91行能塞下20个按钮的关键。如果每个按钮都手动创建,光按钮初始化就要二十多行。
值得提醒一句:GridLayout(5, 4, 4, 4)的第一个参数是行数,第二个是列数,很多初学者会搞反,写成4行5列之后整个布局方向全乱了。这是一个我亲眼见过很多次的小坑。
最后,窗口要调用pack()来根据组件尺寸自动调整窗口大小,再用setLocationRelativeTo(null)让窗口居中显示,最后setVisible(true)把窗口展示出来。
3.2 事件处理逻辑:数字、运算符、功能键的分支设计
事件处理是整个项目的核心,actionPerformed方法里通过一系列分支把按钮分成几类。
数字和小数点分支:如果start为true,说明当前显示的是上一轮运算的结果或者等待新输入的状态,此时直接覆盖显示为按下的数字;如果start为false,说明正在逐位输入一个多位数,那就把新数字追加到显示文本后面。紧接着把start置为false,表示现在显示框里是一个完整的数字。另外我在数字分支开头加了一行防御代码if (cmd.equals(".") && display.getText().contains(".")) return;,连按小数点不会导致1..2这种非法数字出现。
C(清除)分支:把result、op、start三个状态全部重置,显示框恢复为0。注意start必须重新变为true,否则按清除后直接输入数字会拼接到残留文本后面。
←(退格)分支:删除显示文本的最后一个字符。但必须处理两个边界:一是退到空字符串时显示0并把start置为true;二是显示内容只剩一个负号-时,同样恢复为0。这两个边界如果不处理,后续解析数字时会非常痛苦。
±(正负号)分支:把当前显示内容解析成double,取负后显示。这个分支不需要改start,因为正负号被视为当前数字的一部分,它的作用是修改数值本身而不是开启新数字。
√(开根号)分支:解析当前值,调用Math.sqrt(),显示结果。处理完以后要把start置为true,否则用户接着输入数字就会把新数字追加在开方结果后面,产生类似2.23606797749979后面再拼几个数字的混乱显示。
%(百分号)分支:解析当前值,除以100后显示,同样把start置为true。比如输入50再按%,显示0.5,接着输入3会重新开始显示3,这更符合一般计算器的交互习惯。
二元运算符和等号分支:先判断!start,也就是当前显示的是一个完整数字时,才把显示值合并到result里。合并时根据op记录的上一次运算符,对result和当前值做对应运算。如果op为空,直接把当前值赋给result。做完之后,无论实际按的是+ - * /还是=,都统一记录为新的op,并把start置为true。
这里有个巧妙之处:等号也被当作一种“运算符”处理。这样设计带来的好处是,按完等号得到结果后,再按运算符可以直接用结果继续运算。比如5 + 3 =得到8,接着按+、按2、按=,就能得到10,整个状态机不需要额外处理,非常自然。
3.3 主函数与Swing线程模型
入口函数写得很简短,就一行核心调用:SwingUtilities.invokeLater(Calculator::new)。这行代码不是炫技,它背后有一个很重要的原因:Swing组件不是线程安全的,UI组件的创建和操作必须在事件分发线程(EDT)上完成。invokeLater会把传入的任务投递到EDT的任务队列里,由EDT负责执行,从而保证所有组件都在正确的线程里初始化。
当然,在这么小的项目里直接new Calculator()通常也能跑起来,但养成好习惯要从一开始。等你以后碰到真正复杂的并发界面应用时,这个习惯能帮你躲开很多难以排查的线程问题。这也是为什么我建议即使代码行数紧张,也要保留这一行的线程调度调用。
3.4 完整源代码与运行方法
下面是完整代码。我把界面初始化、事件处理、入口函数都放在一个类里,去掉空行后大约在91行左右。这个体量对一个“带界面的完整计算器”来说,已经是相当紧凑的写法了。
import javax.swing.*; import java.awt.*; import java.awt.event.*; public class Calculator extends JFrame implements ActionListener { private final JTextField display = new JTextField("0"); private double result = 0; private String op = ""; private boolean start = true; public Calculator() { setTitle("计算器"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLayout(new BorderLayout()); display.setEditable(false); display.setHorizontalAlignment(JTextField.RIGHT); display.setFont(new Font("Dialog", Font.BOLD, 28)); add(display, BorderLayout.NORTH); JPanel panel = new JPanel(new GridLayout(5, 4, 4, 4)); String[] keys = { "C", "←", "±", "√", "7", "8", "9", "/", "4", "5", "6", "*", "1", "2", "3", "-", "%", "0", ".", "=" }; for (String key : keys) { JButton button = new JButton(key); button.setFont(new Font("Dialog", Font.BOLD, 20)); button.addActionListener(this); panel.add(button); } add(panel, BorderLayout.CENTER); pack(); setLocationRelativeTo(null); setVisible(true); } @Override public void actionPerformed(ActionEvent e) { String cmd = e.getActionCommand(); if ("0123456789.".indexOf(cmd) >= 0) { if (cmd.equals(".") && display.getText().contains(".")) return; if (start) display.setText(cmd); else display.setText(display.getText() + cmd); start = false; } else if (cmd.equals("C")) { result = 0; op = ""; start = true; display.setText("0"); } else if (cmd.equals("←")) { String s = display.getText(); if (!start && s.length() > 0) { display.setText(s.substring(0, s.length() - 1)); if (display.getText().isEmpty() || display.getText().equals("-")) { display.setText("0"); start = true; } } } else if (cmd.equals("±")) { double x = Double.parseDouble(display.getText()); display.setText(String.valueOf(-x)); } else if (cmd.equals("√")) { double x = Double.parseDouble(display.getText()); display.setText(String.valueOf(Math.sqrt(x))); start = true; } else if (cmd.equals("%")) { double x = Double.parseDouble(display.getText()); display.setText(String.valueOf(x / 100)); start = true; } else { if (!start) { double x = Double.parseDouble(display.getText()); if (op.equals("+")) result += x; else if (op.equals("-")) result -= x; else if (op.equals("*")) result *= x; else if (op.equals("/")) result /= x; else result = x; display.setText(String.valueOf(result)); } op = cmd; start = true; } } public static void main(String[] args) { SwingUtilities.invokeLater(Calculator::new); } }运行方法很简单。把代码保存为Calculator.java,在命令行执行:
javac Calculator.java java Calculator窗口弹出来就能正常使用了。
4. 实操中的常见问题与排查技巧实录
4.1 界面布局失控?先检查这三个细节
我调试这个项目时遇到过几次布局问题,总结下来最常见的有三种。
第一种是GridLayout的行列参数写反了。比如你想做5行4列,结果写成了new GridLayout(4, 5, 4, 4),整个界面就变成了4行5列,按钮顺序和预期完全不同。这种问题肉眼一眼就能看出来,改参数即可。
第二种是忘记把按钮面板加到窗口上。如果你写了JPanel panel = new JPanel(...),也把所有按钮都往面板里加了,但最后忘了add(panel, BorderLayout.CENTER),整个窗口只有一个光秃秃的输入框,按钮全部不显示。这个错误很隐蔽,因为代码不会报错,只是界面不对。
第三种是pack()和setSize()混用。pack()会根据组件的首选尺寸自动调整窗口大小,而setSize()是强行指定窗口尺寸。如果先setSize(300, 400)再pack(),后者会把前者覆盖,导致你设置的尺寸失效。我建议这个项目里只用pack(),让按钮和显示框的自然尺寸决定窗口大小,简单省事。
4.2 连续运算结果不对?分清预期行为和bug
很多人第一次跑这个计算器会发现1 + 2 * 3的结果是9而不是7,然后觉得自己代码写错了。这个认知需要纠正:这是顺序运算模型的正常行为,不是bug。要支持四则运算优先级,你需要的是表达式解析器而不是状态机。
不过有一个现象需要警惕:连续按等号。比如5 + 3 = =,第二次按等号时因为start已经是true,代码不会重新计算,结果仍然是8,不会变成11。如果你的需求是“连续等号重复上一次运算”,那需要额外加两个字段去记忆上一次的操作数和运算符,在按等号时优先复用这些记忆值。这不是基础版本该做的事,但知道了原理,扩展起来并不难。
另外,如果你输入5 + + 3 =,结果是8而不是报错。这是因为第二个+按下时,start已经是true,不会触发运算合并,只是把op重新覆盖为加号。这个行为其实和很多实体计算器是一致的,算是一个隐藏特性。
4.3 小数点和空输入的两个隐藏坑
小数点的问题我已经在代码里做了防御:重复点击小数点不会产生1..2这样的非法字符串。如果你自己从零开始写,很容易漏掉这个判断,结果在按下运算符时触发NumberFormatException。这种异常的表现非常突然,让人摸不着头脑,但实际上就是在输入阶段埋下的隐患。
退格键也有类似问题。如果显示框里只有一个字符,比如5,按退格后会变成空字符串,紧接着继续输入数字,就会得到7这样的显示吗?不对,我的代码里处理了空字符串的情况,会把显示恢复成0并重置start。如果你没有这个处理,空字符串被解析时直接抛异常。还有一个不太容易注意到的点:当显示内容只剩一个负号-时,也应该恢复成0。比如输入5,按±变成-5,再按退格会变成-,此时如果再按数字,正常的做法是重新开始输入而不是在负号后面追加,所以同样需要重置。
4.4 除零、负数开根号与其他边界值处理
Java的double运算对除零的处理和数学上的直觉不一样。直接用5 / 0不会抛ArithmeticException,而是得到Infinity。-9开根号也不会报错,会得到NaN。这两个值如果出现在计算器屏幕上,其实不会立即崩溃,因为显示之后start已经变成了true,下一次输入数字会直接覆盖显示内容。
但有一个情况需要注意:如果你把√分支里的start = true漏了,按了负数的开根号之后显示NaN,此时start还是false,继续输入数字就会把数字追加到NaN后面,变成NaN5这种字符串,再按运算符时Double.parseDouble("NaN5")就会抛异常。这就是为什么我反复强调一元操作之后一定要把start置为true,它不仅是交互习惯问题,更是异常防御的关键。
4.5 从91行到完整计算器:可以往哪些方向扩展
这个91行版本完成后,我建议你把它当作一个“地基”,继续往下面几个方向扩展,每个方向都是很好的独立练手项目。
第一个方向是支持运算符优先级。你可以用两个栈分别存数字和运算符,按优先级规则弹出计算,或者先把中缀表达式转成后缀再计算。做完这一步,1 + 2 * 3就能正确得到7,计算器会从“基础版”变成“标准版”。
第二个方向是支持键盘输入。给窗口添加键盘事件监听,数字键和运算符键直接映射到对应的逻辑分支。这个扩展能让你完全脱离鼠标操作,体验感和交互效率都会提升不少。
第三个方向是加入括号支持。有了括号,就需要更完整的表达式解析,甚至可以考虑用递归下降法构建语法树,这会把你从“状态管理”带到一个全新的“语法分析”领域。
第四个方向是历史记录。用一个列表记录每次运算的表达式和结果,点击某条历史可以重新载入。这个功能会涉及列表组件和集合的综合使用,也很适合练习。
我个人最推荐先做第一个方向,因为优先级是计算器最核心的能力,做完之后你会有非常明显的成就感。
写在最后
这个91行计算器项目我前前后后调试过很多遍,最深刻的一个体会就是:行数限制并不会闷死你的思路,反而会逼出很多平时想不到的精巧写法。比如用indexOf判断数字键、用数组驱动按钮生成、用统一的二元操作分支处理等号和运算符,这些技巧在几百行的冗余代码里反而不容易冒出来。
最后再分享一个小细节。如果你把按钮文字设置成√、±这种特殊字符,在某些操作系统自带的字体下可能会显示成方框,这通常不是代码问题,而是字体缺少对应字形。遇到这种情况,可以给按钮统一换一个字体,比如new Font("SansSerif", Font.BOLD, 20),SansSerif字体对特殊字符的覆盖率更高。换完字体再跑一次,基本上就能正常显示了。这也是我在实际操作中踩过一次坑之后才总结出来的经验。