☰
用Java手写四则运算计算器:从分支循环到逆波兰表达式
2026/10/16 11:21:07 网站建设 项目流程

我见过不少Java初学者,学到if和else、for循环这一块就卡住了——每个语法单独拎出来好像都会,但一合上书,又不知道能拿它们做什么。这种时候,我一般会建议做一个四则运算计算器。java四则运算计算器这个项目,难度不高,但非常贴近日常需求:输入“2+3*4”,程序就能算对。它能完整覆盖输入输出、分支判断、运算符优先级、栈、异常处理这些Java核心基础,作为零基础入门项目再合适不过。

这篇文章会从最简单的“两个数加减乘除”版本讲起,一路升级到支持括号、多位小数、负数输入的完整表达式求值器。每一步都有可以直接复制的代码,也会讲清楚为什么要这么写。你不需要提前会什么数据结构,只需要照着思路一步步敲下来就行。

1. 为什么计算器是串起Java基础语法的最佳练手项目

很多教材的课后题都是“计算圆的面积”“输出九九乘法表”,这类题目练习单一知识点还可以,但无法让你感受到“原来这些东西是能组合在一起干一件完整事情的”。四则运算计算器恰恰相反,它像一根线,把Java基础语法里最常用的那些珠子全串了起来。

最小可用的版本只需要输入两个数字和一个运算符,但已经涉及了变量声明、Scanner输入、switch或if分支、除零判断。再往后走,你需要处理一整个字符串表达式,于是自然然就要用到列表、方法封装、循环扫描、异常捕获。等做到支持括号和优先级时,栈这个数据结构也被顺带拿下了。

这样说可能还不够直观,我把三个阶段列成一张表,你可以直接对照自己的水平决定从哪开始。

版本主要任务涉及知识点输出成果
版本一两个数字的四则运算变量、Scanner、switch、条件判断能算但有限制的命令行小程序
版本二表达式拆分字符串处理、列表、循环、方法能把“12.5+3*2”拆成一个个数字和符号
版本三完整表达式求值栈、运算符优先级、异常处理支持括号和多位小数的通用计算器

别看最终版本在命令行里跑起来很朴素,它内部解决的是真正的解析问题。很多实际的软件,比如公式编辑器、电子表格程序、报表引擎,底层都在做类似的事——把一串用户可读的文本变成计算机可以执行的计算过程。你能通过一个练手项目摸到底层逻辑,这笔账是很划算的。

2. 先写一个两数计算器:Scanner、switch、除零处理

2.1 最基础的输入输出流程

第一个版本不需要考虑什么“优美架构”,目标就是跑通完整闭环:用户输入数字和运算符,程序输出结果。

import java.util.Scanner; public class SimpleCalculator { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); System.out.print("请输入第一个数字: "); double num1 = scanner.nextDouble(); System.out.print("请输入运算符(+ - * /): "); String operator = scanner.next(); System.out.print("请输入第二个数字: "); double num2 = scanner.nextDouble(); double result = 0; boolean valid = true; switch (operator) { case "+": result = num1 + num2; break; case "-": result = num1 - num2; break; case "*": result = num1 * num2; break; case "/": if (num2 == 0) { valid = false; System.out.println("除数不能为0"); } else { result = num1 / num2; } break; default: valid = false; System.out.println("无法识别的运算符: " + operator); } if (valid) { System.out.println("结果: " + result); } scanner.close(); } }

这段代码里有三个细节值得零基础读者停下来想一想。

第一,数字类型我用的是double而不是int。为什么?因为整数除法会直接丢弃小数部分,10除以3在整数运算里得到3这个结果,而不是3.333...。对计算器来说,这显然不能接受。用double接收输入,后续除法结果就是小数。

第二,除零一定要单独判断。如果你的程序在用户输入“10 / 0”时直接崩溃,那是很不友好的体验。这里把除零当成一个预期的分支处理,而不是程序异常。

第三,switch比if else更适合这种场景。加减乘除四个分支互不干扰,switch能让你一眼看到每个分支对应的运算符,后面如果想扩展取余、幂运算,也只需要多写几个case。

2.2 scanner的输入陷阱:nextDouble和nextLine混用

如果是靠看教程自学,很少会有人主动提这个坑,但它真的很容易绊倒新手。假设你在这个程序里先用了scanner.nextDouble()读数字,接着想用scanner.nextLine()读一行文字,你会发现读到的往往是个空串。

原因是nextDouble()只会吃掉数字本身,不会消费掉数字后面的换行符。这个换行符还留在输入缓冲区里,nextLine()一执行,直接把换行符当成了整行内容返回,看起来就是“什么都没读到”。

解决办法有两种。一种是每次读完数字后手动调用一次scanner.nextLine()把残留的换行符清掉;另一种是干脆放弃nextDouble()这种一个词一个词读的方式,改成scanner.nextLine()整行读取,然后自己做解析。第二种方式才是后面所有章节要走的方向。

我为什么会在这里就提这个坑?因为绝大多数入门者第一次写交互式控制台程序都会碰上它,早点了解能省下不少排查时间。

3. 从“读两个数”到“读一串表达式”:词法拆分

3.1 程序面对一串字符时到底需要做什么

版本一的体验其实挺差的:用一次要输入三次,用户每次都只能做一次运算。真正实用的计算器应该让用户直接输入2+3*4这样的表达式,然后回车出结果。

可问题来了:在程序眼里,2+3*4不是数字,也不是运算符,它就是一串字符。你没办法直接对字符串做加法。你需要先把这串字符拆成一个一个有意义的单元,这种单元在编译器领域有一个专门的名字——token。

对四则运算来说,token分两类:一类是数字,比如2、3、4,还有可能是12.5;另一类是运算符和括号,比如+、-、*、/、(、)。

2+3*4拆完之后应该是这样一组token:["2", "+", "3", "*", "4"]。12.5+3*2拆完则是["12.5", "+", "3", "*", "2"]。

有人可能会说,这个简单,用String.split按运算符切一下不就行了?确实,对特别简单的表达式可行,但一旦出现括号、小数点、负数,split的方向盘就握不住了。比如你想处理(2+3)*4,按什么切?按+切会得到(2和3)*4,直接乱套。正则表达式也许勉强能写,但对初学者来说既难读又难改。手写一个小小的“词法分析器”反而是最清晰、可控的做法。

3.2 手写一个词法分析器

所谓词法分析器,就是一个方法,输入原始字符串,输出token列表。我从头到尾扫描一遍字符串,遇到数字就把连续的数字字符收集成一个数字串,遇到运算符或者括号就单独存一个字符串。空格直接跳过,其他乱七八糟的字符就抛异常告诉用户输入不合法。

private static List<String> tokenize(String expression) { List<String> tokens = new ArrayList<>(); int i = 0; while (i < expression.length()) { char c = expression.charAt(i); if (Character.isDigit(c) || c == '.') { StringBuilder sb = new StringBuilder(); while (i < expression.length() && (Character.isDigit(expression.charAt(i)) || expression.charAt(i) == '.')) { sb.append(expression.charAt(i)); i++; } tokens.add(sb.toString()); } else if (c == '+' || c == '-' || c == '*' || c == '/' || c == '(' || c == ')') { // 处理一元负号:如果负号前面没有数字,先补一个0 if (c == '-') { boolean isUnary = tokens.isEmpty() || tokens.get(tokens.size() - 1).equals("(") || isOperator(tokens.get(tokens.size() - 1)); if (isUnary) { tokens.add("0"); } } tokens.add(String.valueOf(c)); i++; } else if (Character.isWhitespace(c)) { i++; } else { throw new IllegalArgumentException("无法识别的字符: " + c); } } return tokens; }

这里面最关键的循环是收集数字的while。它不满足于只要一个字符,而是会一直读下去,直到遇到不是数字也不是小数点的字符为止。比如输入12.5+3,扫描到1之后,它会继续吞掉2、.、5,最终形成一个完整的token——"12.5"。

一元负号的处理是个额外的小彩蛋。你先不需要完全理解它,只需知道:当负号出现在表达式开头、或者紧跟在左括号、运算符后面时,它并不是“相减”,而是“取负”,程序会在它前面补一个0,把-3+5改写成0-3+5。这样后续的求值逻辑就完全不用区分一元和二元了。

写到这里,你手里已经有了一套把字符串变成token的手段。但零件还只是零件——解决了“怎么把数字和符号认出来”的问题,还没有解决“按什么顺序计算”的问题。

4. 优先级和括号怎么处理:逆波兰表达式的原理

4.1 为什么不能从左到右直接算

假设现在要算2+3*4。如果程序从左到右扫描token,见到一个运算符就算一次,那就会先算2+3得5,再算5*4得20。但大家都知道正确答案是14,因为乘法的优先级比加法高,必须先算3*4。

能通过加括号人为控制顺序吗?用户在输入(2+3)*4时,就必须先算加法。问题在于,程序不知道什么时候有括号,括号里还可能再套括号。如果全靠if分支一个case一个case去拼,代码会膨胀到你根本不想维护第二次。

业界处理这个问题有一个非常成熟的标准方案:先把人习惯的中缀表达式转成后缀表达式,也就是逆波兰表达式,然后再求值。

4.2 中缀转后缀的五条规则

中缀表达式是人写的2+3*4这种形式,运算符在两个操作数中间。后缀表达式则把运算符放到两个操作数后面,2+3*4对应的后缀是2 3 4 * +。后缀表达式有个极好的性质:没有任何括号,也没有优先级问题,按顺序从左到右扫描一遍就能算完。

中缀转后缀的过程要用到一个栈,规则总共五条:

  1. 遇到数字,直接写入输出列表。
  2. 遇到左括号,入栈。
  3. 遇到右括号,不停弹出栈顶运算符并加入输出列表,直到碰到左括号,然后把这个左括号弹出丢弃。
  4. 遇到四则运算符,弹出栈顶所有优先级不低于当前运算符的运算符,再把当前运算符入栈。注意括号要单独卡住,不能混在优先级比较里。
  5. 整个表达式扫描完之后,把栈里剩余的运算符依次全部弹出。

第四条的“不仅弹更高,还要弹相同”很重要。之所以相同优先级的运算符也要弹,因为加减乘除都遵循左结合。比如8-3+2,减号和加号优先级相同。如果不把减号先弹出来,后续处理顺序就会乱,结果可能变成8减(3加2),得到3,但正确答案是7。

空讲规则不好理解,拿2+3*4举个例子。扫描2,输出列表是[2]。扫描+,栈是空的,+入栈。扫描3,加入输出,现在是[2, 3]。扫描*,*的优先级是2,栈顶+的优先级是1,不满足“不低于”条件,所以*直接入栈。扫描4,加入输出。扫描完毕,把栈里的*、+依次弹出去,最终后缀是2 3 4 * +。

细想一下这个过程:栈里+留在下面,*压在它上面,弹出的顺序自然是先*后+,也就是先算3*4再算2+》结果,优先级关系就这样被“翻译”进后缀顺序里了。

4.3 用“2+3*(4-1)”走一遍完整流程

这道题比上面的例子多了一个括号,正好可以把括号规则一并讲透。

输入:2+3*(4-1)

词法拆分后得到:["2", "+", "3", "*", "(", "4", "-", "1", ")"]

然后开始转后缀:

  • 输出2,栈为空。
  • 遇到+,入栈。栈从底到顶是[+]。
  • 输出3。
  • 遇到*,栈顶+优先级低,所以*入栈。栈变成[+, *]。
  • 遇到(,入栈。栈变成[+, *, (]。
  • 输出4。
  • 遇到-,栈顶是左括号(,左括号作为界碑绝不参与弹出,所以-直接入栈。栈变成[+, *, (, -]。
  • 输出1。
  • 遇到),开始弹栈直到碰到左括号:弹出-,弹出*,丢掉左括号。此时输出列表是[2, 3, 4, 1, -, *],栈只剩[+]。
  • 扫描结束,把栈里剩下的+弹出。

最终后缀是:2 3 4 1 - * +。

再看怎么对这个后缀求值。规则同样简单:从左到右扫描,遇数字压栈,遇运算符就弹出栈顶的两个数字做对应运算。但有一处需要注意:如果遇到-,先弹出的数其实是减号右边的操作数,后弹出的才是左边的操作数。比如后缀4 1 -,先弹出1,再弹出4,要算的是4减1,而不是1减4。除法和减法同理。

2 3 4 1 - * +的完整求值过程:

  • 2入栈,3入栈,4入栈,1入栈。栈是[2, 3, 4, 1]。
  • 遇到-,弹出1和4,算出3,压回栈。栈是[2, 3, 3]。
  • 遇到*,弹出3和3,算出9,压回栈。栈是[2, 9]。
  • 遇到+,弹出9和2,算出11。

整体思路清晰,代码也就水到渠成了。中缀转后缀和求值其实是两个独立的小方法。

private static List<String> toPostfix(List<String> tokens) { List<String> output = new ArrayList<>(); Deque<String> stack = new ArrayDeque<>(); for (String token : tokens) { if (isNumber(token)) { output.add(token); } else if (token.equals("(")) { stack.push(token); } else if (token.equals(")")) { while (!stack.isEmpty() && !stack.peek().equals("(")) { output.add(stack.pop()); } if (!stack.isEmpty()) { stack.pop(); } } else if (isOperator(token)) { while (!stack.isEmpty() && !stack.peek().equals("(") && precedence(stack.peek()) >= precedence(token)) { output.add(stack.pop()); } stack.push(token); } else { throw new IllegalArgumentException("非法记号: " + token); } } while (!stack.isEmpty()) { output.add(stack.pop()); } return output; }

这里我用Deque而不用Stack。原生的java.util.Stack是遗留类,性能和方法设计都不是很好,ArrayDeque才是现在Java生态里推荐使用的栈实现。这个细节虽然对最终结果没有影响,但写多了就会发现,好的习惯是在合适的地方用合适的类。

优先级在这里就是一个很小的辅助方法:加减返回1,乘除返回2,其他返回0。很多教程会把优先级直接用魔法数字写在if条件里,那样也能工作,但会造成代码可读性下降。

后缀求值只依赖一个数字栈:

private static double evaluatePostfix(List<String> postfix) { Deque<Double> stack = new ArrayDeque<>(); for (String token : postfix) { if (isNumber(token)) { stack.push(Double.parseDouble(token)); } else if (isOperator(token)) { if (stack.size() < 2) { throw new IllegalArgumentException("表达式格式不正确"); } double b = stack.pop(); double a = stack.pop(); switch (token) { case "+": stack.push(a + b); break; case "-": stack.push(a - b); break; case "*": stack.push(a * b); break; case "/": if (b == 0) { throw new ArithmeticException("除数不能为0"); } stack.push(a / b); break; default: throw new IllegalStateException("未知运算符: " + token); } } else { throw new IllegalArgumentException("非法标识: " + token); } } if (stack.size() != 1) { throw new IllegalArgumentException("表达式格式不正确"); } return stack.pop(); }

那个b = stack.pop(); a = stack.pop();的顺序,是所有第一次写这个逻辑的人必错一次的地方。它的背后逻辑就是后缀表达式的定义:先出现的是左操作数,后出现的是右操作数。弹栈反过来,所以先弹出的是右操作数,先把它存在b里,再弹出左操作数存进a。

5. 完整实现:一个支持括号和多位数的Java计算器

5.1 把三块零件拼成一个类

现在可以写一个完整的Calculator类了。main方法里用while循环持续接收用户输入,用户输入exit退出。每次读一整行,然后走一遍tokenize -> toPostfix -> evaluatePostfix流程。所有可能抛出的异常都用一个try-catch包住,保证程序不会因为一次错误输入直接崩掉。

import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.List; import java.util.Scanner; public class Calculator { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); System.out.println("四则运算计算器"); System.out.println("支持 + - * / 和括号( ),输入 exit 退出"); while (true) { System.out.print("请输入表达式: "); String line = scanner.nextLine().trim(); if (line.equalsIgnoreCase("exit")) { break; } if (line.isEmpty()) { System.out.println("输入不能为空。"); continue; } try { List<String> tokens = tokenize(line); List<String> postfix = toPostfix(tokens); double result = evaluatePostfix(postfix); System.out.println("结果: " + result); } catch (Exception e) { System.out.println("输入有误: " + e.getMessage()); } } scanner.close(); } private static boolean isOperator(String token) { return token.equals("+") || token.equals("-") || token.equals("*") || token.equals("/"); } private static boolean isNumber(String token) { try { Double.parseDouble(token); return true; } catch (NumberFormatException e) { return false; } } private static int precedence(String op) { if (op.equals("+") || op.equals("-")) { return 1; } if (op.equals("*") || op.equals("/")) { return 2; } return 0; } private static List<String> tokenize(String expression) { List<String> tokens = new ArrayList<>(); int i = 0; while (i < expression.length()) { char c = expression.charAt(i); if (Character.isDigit(c) || c == '.') { StringBuilder sb = new StringBuilder(); while (i < expression.length() && (Character.isDigit(expression.charAt(i)) || expression.charAt(i) == '.')) { sb.append(expression.charAt(i)); i++; } tokens.add(sb.toString()); } else if (c == '+' || c == '-' || c == '*' || c == '/' || c == '(' || c == ')') { if (c == '-') { boolean isUnary = tokens.isEmpty() || tokens.get(tokens.size() - 1).equals("(") || isOperator(tokens.get(tokens.size() - 1)); if (isUnary) { tokens.add("0"); } } tokens.add(String.valueOf(c)); i++; } else if (Character.isWhitespace(c)) { i++; } else { throw new IllegalArgumentException("无法识别的字符: " + c); } } return tokens; } private static List<String> toPostfix(List<String> tokens) { List<String> output = new ArrayList<>(); Deque<String> stack = new ArrayDeque<>(); for (String token : tokens) { if (isNumber(token)) { output.add(token); } else if (token.equals("(")) { stack.push(token); } else if (token.equals(")")) { while (!stack.isEmpty() && !stack.peek().equals("(")) { output.add(stack.pop()); } if (!stack.isEmpty()) { stack.pop(); } } else if (isOperator(token)) { while (!stack.isEmpty() && !stack.peek().equals("(") && precedence(stack.peek()) >= precedence(token)) { output.add(stack.pop()); } stack.push(token); } else { throw new IllegalArgumentException("非法记号: " + token); } } while (!stack.isEmpty()) { output.add(stack.pop()); } return output; } private static double evaluatePostfix(List<String> postfix) { Deque<Double> stack = new ArrayDeque<>(); for (String token : postfix) { if (isNumber(token)) { stack.push(Double.parseDouble(token)); } else if (isOperator(token)) { if (stack.size() < 2) { throw new IllegalArgumentException("表达式格式不正确"); } double b = stack.pop(); double a = stack.pop(); switch (token) { case "+": stack.push(a + b); break; case "-": stack.push(a - b); break; case "*": stack.push(a * b); break; case "/": if (b == 0) { throw new ArithmeticException("除数不能为0"); } stack.push(a / b); break; default: throw new IllegalStateException("未知运算符: " + token); } } else { throw new IllegalArgumentException("非法标识: " + token); } } if (stack.size() != 1) { throw new IllegalArgumentException("表达式格式不正确"); } return stack.pop(); } }

这就是一个完整的可运行计算器。你可以把上面所有代码保存到一个名为Calculator.java的文件里,然后执行:

javac Calculator.java java Calculator

编译器没有任何依赖,也不需要额外配置,JDK装好就能跑。

5.2 测一圈再放心收工

写程序最怕“看起来能跑,一测就崩”。拿到这个计算器之后,建议你亲手敲一遍下面这组测试用例:

输入表达式预期结果
2+3*414.0
(2+3)*420.0
2+3*(4-1)11.0
10/42.5
0.5+0.250.75
-3+52.0
1/0输入有误: 除数不能为0
2+输入有误: 表达式格式不正确

注意到几个细节没有:结果都是带.0的小数形式,因为内部统一用double;除零和错误表达式都被try-catch接住了,程序不会退出,还能继续接收下一条输入;负数表达式-3+5因为tokenize阶段的特殊处理,也能正常出结果。

6. 我在实际使用中踩过的坑,和后续可以加的功能

6.1 一元负号的坑:一个看起来对但有时又不对的结果

我在前面代码里用了“看到一个一元负号就补0”的方法来解决负数输入。这个方案能处理-3+5、(-3+5)*2这类常见写法,但它有一个容易被忽略的缺陷。

如果你输入5--3,也就是“5减去负3”,程序会把它处理成5-0-3,得到2,而数学上的期望结果应该是8。为什么会这样?因为第二个减号前面是一个运算符,程序按一元负号处理,先补了一个0,于是原来的中缀表达式变成了5 - 0 - 3,自然就按“减0再减3”来算了。

解决这个问题的最直接办法,是告诉用户遇到这种情况就写5-(-3)。但作为一个想要提高的程序员,你应该知道还有更完善的方案:在词法分析阶段,如果遇到一元负号且后面紧跟的是数字,就把负号和数字合并成一个负数token,例如-3作为一个整体;如果后面跟的是左括号,则把一元负号当做一个单独的运算符处理。这会引入“单目运算符优先级”,代码量会多一点,但能力也更接近真实计算器。

这个坑值得拿出来单独说,是因为它提醒我一件事:善意的兼容处理往往会在意想不到的地方反咬一口。做任何输入处理时,边界情况永远比理想情况多。

6.2 接下来可以往哪些方向扩展

现在这个计算器已经是个完整的命令行项目了,可以继续加功能的方向其实挺有意思。

先说最简单的一步:加一个图形界面。用JavaFX或者Swing把控制台输入换成窗口里的文本框和按钮。这一步不会改变任何计算逻辑,只是把System.in换成监听按钮事件,但你会第一次真实感受到“界面层”和“逻辑层”是两件可以分开的事。

再说稍微复杂一点的:支持更多数学函数。比如sin、cos、sqrt,这会改变tokenize阶段的规则——函数名由字母组成,不能直接用数字或运算符的逻辑处理。同时你需要想清楚函数调用的优先级:2*sqrt(9)应该先算sqrt(9)再参与乘法。

还可以加入变量赋值,比如支持x=10; x+2这样的输入。这会让项目从一个单纯的计算器变成一个微型“脚本解释器”,涉及变量存储、作用域、语句分隔符,已经是编译原理里解释器领域的内容了。

最后一个很推荐的扩展方向,是给核心方法写单元测试。尤其针对tokenize和toPostfix,写一批类似“输入这样,输出那样”的断言。你不需要追求什么测试覆盖率,只要保证以后改动代码时不会悄悄改坏原有功能。这个习惯越早培养,后面写任何项目都会受益。

写在最后

我在带人学Java的时候发现,真正卡住初学者的往往不是某个语法不会写,而是对着一个长字符串表达式,脑子里完全没有“先拆分、再处理、后计算”的结构化思路。想通这一层,后面所有代码都是自然而然的推导结果。

所以如果你把这个计算器当成练手项目,我的建议是:不要急着复制完整代码,先自己试着把2+3*(4-1)拆成token,再想想能不能设计一套规则让程序知道先算括号、再算乘除、最后算加减。等你想明白这层关系,再看代码会非常有感觉,甚至会觉得“原来如此”。到那时候,这个项目给你的就不只是一个能跑的程序,而是一整套拆解问题的思维方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询