尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java Swing高级计算器实战:MVC架构与表达式求值算法详解

Java Swing高级计算器实战:MVC架构与表达式求值算法详解 1. 从零到一为什么选择Java Swing来造一个计算器如果你是一个Java开发者或者正在学习Java想找一个能串联起核心语法、面向对象思想和GUI编程的实战项目那自己动手实现一个计算器尤其是带点“高级”功能的绝对是个经典又有效的选择。市面上教程很多但大多停留在“112”的按钮点击层面一旦涉及到连续运算、括号优先级、错误处理代码就容易变得混乱不堪。今天我就以一个过来人的身份跟你聊聊怎么用Java Swing从设计到实现打造一个逻辑清晰、功能健壮、代码可维护的“高级计算器”。这里的“高级”我们定义为超越基础四则运算。它应该能处理复杂的表达式比如(3 5) * 2 - 10 / 5能识别并处理用户的输入错误比如除零、非法字符拥有一个清晰的历史记录面板并且整个代码结构要经得起推敲而不是一堆if-else的堆砌。选择Java Swing作为实现工具原因很直接它是Java标准库的一部分无需引入任何第三方依赖纯粹、标准能让你聚焦于业务逻辑和GUI事件处理的核心机制。虽然现在更流行的桌面技术可能是JavaFX但Swing的轻量、直接以及对MVC模型-视图-控制器模式的天然契合度使其成为理解桌面应用架构的绝佳起点。2. 架构设计如何让计算器代码不变成“面条代码”在动手敲第一行代码之前花点时间思考架构至关重要。一个糟糕的设计会让后期添加功能比如添加三角函数sin、cos变得异常痛苦。我们的目标是高内聚、低耦合。最直接有效的模式就是MVC。模型Model这是计算器的大脑负责核心计算逻辑。它不应该知道任何关于按钮、文本框的事情。它的职责就是接收一个字符串表达式如35*2然后正确地解析并计算出结果。我们将创建一个CalculatorModel类它内部会封装一个表达式求值器。这个求值器的实现是关键我们稍后详细拆解。视图View这是计算器的脸负责所有UI组件的创建和布局。我们用Swing的JFrame作为主窗口里面包含显示输入和结果的JTextField一排排的JButton以及一个用于显示历史记录的JTextArea。视图的代码应该只关心“怎么摆好看”和“用户点了哪里”不包含任何计算逻辑。控制器Controller这是连接大脑和脸的神经。它监听视图按钮上发生的所有动作ActionListener然后将用户的动作翻译成对模型的调用“请计算这个表达式”或者对视图的更新“把结果显示在文本框里”。控制器会持有模型和视图的引用。这样的分离带来的好处是巨大的你可以轻易地更换视图比如从Swing换成JavaFX而模型代码几乎不用动你也可以独立测试模型的计算逻辑无需启动整个GUI。很多初学者写的计算器代码往往把按钮监听事件和计算逻辑全部写在主类里导致一个类长达数百行各种状态变量交织这就是典型的“面条代码”难以阅读和维护。2.1 视图层构建Swing组件布局的艺术我们先从最直观的视图开始。一个高级计算器的界面通常比基础版更复杂我们需要合理规划空间。import javax.swing.*; import java.awt.*; public class CalculatorView extends JFrame { private JTextField inputField; // 输入表达式显示框 private JTextArea historyArea; // 历史记录显示区 private JButton[] buttons; // 所有按钮的数组 public CalculatorView() { // 1. 基础窗口设置 setTitle(高级计算器); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLayout(new BorderLayout()); // 2. 顶部输入显示区 inputField new JTextField(); inputField.setFont(new Font(Monospaced, Font.BOLD, 24)); inputField.setHorizontalAlignment(JTextField.RIGHT); inputField.setEditable(false); // 通常通过按钮输入禁止直接键盘输入可选 add(inputField, BorderLayout.NORTH); // 3. 中央按钮面板 - 使用GridLayout JPanel buttonPanel new JPanel(); // 设计一个5行4列的网格可以根据需要调整 buttonPanel.setLayout(new GridLayout(5, 4, 5, 5)); // 行列水平间距垂直间距 // 定义按钮标签顺序就是它们出现在面板上的顺序 String[] buttonLabels { 7, 8, 9, /, 4, 5, 6, *, 1, 2, 3, -, 0, ., , , C, (, ), ⌫ // C清空⌫退格 }; buttons new JButton[buttonLabels.length]; for (int i 0; i buttonLabels.length; i) { buttons[i] new JButton(buttonLabels[i]); buttons[i].setFont(new Font(SansSerif, Font.PLAIN, 18)); // 为等号、运算符等设置不同颜色提升可读性 if (.equals(buttonLabels[i])) { buttons[i].setBackground(new Color(66, 133, 244)); buttons[i].setForeground(Color.WHITE); } else if (-*/.contains(buttonLabels[i])) { buttons[i].setBackground(new Color(218, 220, 224)); } else if (C⌫.contains(buttonLabels[i])) { buttons[i].setBackground(new Color(232, 67, 53)); buttons[i].setForeground(Color.WHITE); } buttonPanel.add(buttons[i]); } add(buttonPanel, BorderLayout.CENTER); // 4. 底部历史记录区 historyArea new JTextArea(5, 30); // 5行高度 historyArea.setFont(new Font(Monospaced, Font.PLAIN, 14)); historyArea.setEditable(false); JScrollPane scrollPane new JScrollPane(historyArea); add(scrollPane, BorderLayout.SOUTH); // 5. 窗口最终设置 pack(); // 自动调整窗口大小以适应组件 setLocationRelativeTo(null); // 窗口居中显示 setVisible(true); } // 提供给控制器获取组件引用的方法 public JTextField getInputField() { return inputField; } public JTextArea getHistoryArea() { return historyArea; } public JButton[] getButtons() { return buttons; } }注意这里将等号()、清除(C)、退格(⌫)按钮的颜色做了区分这是一个非常重要的用户体验细节。它能帮助用户快速定位关键功能键减少误操作。在实际项目中UI的微小改进往往能极大提升使用感受。视图代码看起来不少但结构清晰初始化窗口、创建组件、设置布局、添加组件。GridLayout非常适合计算器这种规整的按钮排列。历史记录区使用JTextArea配合JScrollPane可以自动滚动查看多条记录。3. 核心引擎表达式求值器的两种实现路径这是整个项目的技术核心。如何让程序理解并计算3 5 * 2等于13而不是16你需要一个能处理运算符优先级乘除优先于加减和括号的求值器。这里我提供两种主流的实现思路并详细分析其优劣。3.1 路径一调度场算法Shunting Yard Algorithm这是编译原理中用于将中缀表达式人类习惯的写法如35*2转换为后缀表达式逆波兰表示法如3 5 2 * 的经典算法。后缀表达式的一大优点是无需括号通过栈就能轻松且无歧义地计算。算法步骤简述准备一个输出队列存放最终的后缀表达式和一个运算符栈。从左到右扫描中缀表达式的每个元素数字或运算符。如果是数字直接加入输出队列。如果是运算符记为o1当栈顶存在运算符o2且o2的优先级高于o1或优先级相同但o1是左结合性时将o2弹出栈并加入输出队列。最后将o1压入运算符栈。如果是左括号(直接压入栈。如果是右括号)则将栈顶运算符弹出并加入输出队列直到遇到左括号(左括号弹出但不加入输出队列。表达式扫描完毕后将栈中剩余的所有运算符依次弹出并加入输出队列。后缀表达式计算准备一个操作数栈。从左到右遍历后缀表达式队列。如果是数字压入操作数栈。如果是运算符则从操作数栈弹出两个元素注意顺序对于减法和除法第二个弹出的是被减数/被除数进行运算将结果压回操作数栈。遍历结束后操作数栈顶的元素即为最终结果。这个算法的优点是逻辑严谨能完美处理优先级和括号是“教科书式”的解决方案。实现起来代码量中等但需要对栈的操作有清晰的理解。3.2 路径二递归下降解析Recursive Descent Parsing这种方法更贴近“理解”表达式。我们定义表达式的文法规则然后编写相互递归的函数来解析它。例如我们可以定义expression term { (‘’ | ‘-’) term }表达式由项通过加减连接term factor { (‘*’ | ‘/’) factor }项由因子通过乘除连接factor number | ‘(’ expression ‘)’因子是一个数字或者一个括号括起来的子表达式实现伪代码思路// 全局索引指向当前正在解析的字符位置 int index 0; String expr; double parseExpression() { double result parseTerm(); while (index expr.length()) { char op expr.charAt(index); if (op || op -) { index; // 消费运算符 double nextTerm parseTerm(); if (op ) result nextTerm; else result - nextTerm; } else { break; // 不是加减号结束本层表达式的解析 } } return result; } double parseTerm() { double result parseFactor(); while (index expr.length()) { char op expr.charAt(index); if (op * || op /) { index; double nextFactor parseFactor(); if (op *) result * nextFactor; else result / nextFactor; // 注意除零检查 } else { break; } } return result; } double parseFactor() { char ch expr.charAt(index); if (ch () { index; // 消费( double result parseExpression(); // 递归解析括号内的表达式 index; // 消费)这里需要检查是否是)否则表达式错误 return result; } else { // 解析数字可能包含小数点 int start index; while (index expr.length() (Character.isDigit(expr.charAt(index)) || expr.charAt(index) .)) { index; } String numStr expr.substring(start, index); return Double.parseDouble(numStr); } }递归下降的代码更直观仿佛直接在代码中体现了运算的优先级先调用parseTerm在parseTerm里处理乘除而parseTerm又被parseExpression调用处理加减。它不需要显式地转换表达式形式边解析边计算。但对于复杂的运算符比如一元负号、函数调用如sin文法会变得复杂。我的选择与建议对于第一个高级计算器项目我强烈推荐使用调度场算法。原因有三第一它的流程非常标准化网上有大量可参考的正确实现第二它将“解析”和“计算”分离结构清晰调试方便你可以先打印出转换后的后缀表达式看看对不对第三它为未来扩展预留了空间比如要添加sin、cos、log等函数只需要在算法中增加对这些“函数运算符”的处理规则即可。而递归下降虽然优雅但在处理一些边界条件和错误恢复时对初学者挑战更大。4. 模型层实现构建健壮的CalculatorModel基于调度场算法我们来构建模型层。这个类将是计算器无状态的“大脑”。import java.util.Stack; import java.util.LinkedList; import java.util.HashMap; import java.util.Map; public class CalculatorModel { // 定义运算符的优先级 private static final MapCharacter, Integer PRECEDENCE new HashMap(); static { PRECEDENCE.put(, 1); PRECEDENCE.put(-, 1); PRECEDENCE.put(*, 2); PRECEDENCE.put(/, 2); // 未来可以在这里添加 ^ (幂运算优先级3) 等 } /** * 计算表达式的主方法 * param expression 用户输入的表达式字符串 * return 计算结果如果出错则抛出IllegalArgumentException */ public double calculate(String expression) throws IllegalArgumentException { if (expression null || expression.trim().isEmpty()) { throw new IllegalArgumentException(表达式不能为空); } // 1. 中缀转后缀 LinkedListString postfix infixToPostfix(expression); // 2. 计算后缀表达式 return evaluatePostfix(postfix); } /** * 调度场算法中缀表达式转后缀表达式 */ private LinkedListString infixToPostfix(String infix) { LinkedListString output new LinkedList(); // 输出队列 StackCharacter operatorStack new Stack(); // 运算符栈 // 用于临时拼接多位数和小数 StringBuilder numberBuffer new StringBuilder(); for (int i 0; i infix.length(); i) { char ch infix.charAt(i); if (Character.isDigit(ch) || ch .) { // 是数字或小数点放入缓冲区 numberBuffer.append(ch); } else { // 遇到非数字字符先将缓冲区内的数字作为一个整体输出 if (numberBuffer.length() 0) { output.add(numberBuffer.toString()); numberBuffer.setLength(0); // 清空缓冲区 } if (ch () { operatorStack.push(ch); } else if (ch )) { // 弹出栈顶运算符到输出队列直到遇到左括号 while (!operatorStack.isEmpty() operatorStack.peek() ! () { output.add(String.valueOf(operatorStack.pop())); } if (operatorStack.isEmpty()) { throw new IllegalArgumentException(括号不匹配缺少左括号); } operatorStack.pop(); // 弹出左括号 ( } else if (PRECEDENCE.containsKey(ch)) { // 是运算符 // 当栈顶运算符优先级 当前运算符且不是左括号时弹出到输出 while (!operatorStack.isEmpty() operatorStack.peek() ! ( PRECEDENCE.getOrDefault(operatorStack.peek(), 0) PRECEDENCE.get(ch)) { output.add(String.valueOf(operatorStack.pop())); } operatorStack.push(ch); } else if (ch ! ) { // 忽略空格其他字符视为非法 throw new IllegalArgumentException(非法字符: ch ); } } } // 循环结束后处理缓冲区可能残留的数字 if (numberBuffer.length() 0) { output.add(numberBuffer.toString()); } // 将栈中剩余的所有运算符弹出到输出 while (!operatorStack.isEmpty()) { char op operatorStack.pop(); if (op () { throw new IllegalArgumentException(括号不匹配缺少右括号); } output.add(String.valueOf(op)); } return output; } /** * 计算后缀表达式 */ private double evaluatePostfix(LinkedListString postfix) { StackDouble operandStack new Stack(); for (String token : postfix) { if (token.matches(-?\\d(\\.\\d)?)) { // 匹配数字包括负数、小数 operandStack.push(Double.parseDouble(token)); } else { // 是运算符需要两个操作数 if (operandStack.size() 2) { throw new IllegalArgumentException(表达式不完整或格式错误); } double b operandStack.pop(); // 第二个操作数 double a operandStack.pop(); // 第一个操作数 double result 0; switch (token) { case : result a b; break; case -: result a - b; break; case *: result a * b; break; case /: if (Math.abs(b) 1e-12) { // 处理除零错误 throw new ArithmeticException(除数不能为零); } result a / b; break; default: throw new IllegalArgumentException(未知运算符: token); } operandStack.push(result); } } if (operandStack.size() ! 1) { throw new IllegalArgumentException(表达式格式错误无法计算出唯一结果); } return operandStack.pop(); } }踩坑实录在infixToPostfix方法中处理数字缓冲区是关键。我们必须把连续的数字和小数点当作一个完整的数字来处理。很多初学者在这里会犯错将123拆成了1、2、3三个独立的数字入栈导致计算完全错误。另外除零检查必须在计算时进行而不是在解析时因为除数可能是一个子表达式计算的结果如5 / (3-3)。这个模型类现在可以独立测试。你可以写一个简单的main方法输入35*2看它是否输出13.0。这是MVC模式带来的第一个好处——核心逻辑的可测试性。5. 控制器层粘合事件处理与状态管理控制器是MVC中的协调者。它需要做以下几件事获取视图中的按钮和文本框引用。为所有按钮添加动作监听器ActionListener。在监听器中根据按钮的类型数字、运算符、等号、清除等更新输入框的文本或者在用户按下等号时调用模型进行计算并更新视图显示结果、记录历史。这里有一个设计决策输入框的文本是直接由控制器拼接还是由模型维护一个“当前表达式”的状态为了简单和清晰我们采用前者即控制器直接操作inputField的文本。模型始终保持无状态每次计算都接收完整的表达式字符串。import javax.swing.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; public class CalculatorController { private CalculatorModel model; private CalculatorView view; public CalculatorController(CalculatorModel model, CalculatorView view) { this.model model; this.view view; attachListeners(); } private void attachListeners() { JButton[] buttons view.getButtons(); JTextField inputField view.getInputField(); JTextArea historyArea view.getHistoryArea(); for (JButton button : buttons) { button.addActionListener(new ActionListener() { Override public void actionPerformed(ActionEvent e) { String command e.getActionCommand(); // 获取按钮上的文本 String currentText inputField.getText(); switch (command) { case : // 执行计算 try { double result model.calculate(currentText); // 格式化结果如果结果是整数则不显示小数点 String resultStr; if (Math.abs(result - Math.round(result)) 1e-10) { resultStr String.format(%d, Math.round(result)); } else { // 限制小数位数避免浮点数精度问题显示过长 resultStr String.format(%.10f, result).replaceAll(0*$, ).replaceAll(\\.$, ); } // 更新历史记录 historyArea.append(currentText resultStr \n); // 将结果显示在输入框作为下一次计算的起点 inputField.setText(resultStr); } catch (IllegalArgumentException | ArithmeticException ex) { JOptionPane.showMessageDialog(view, 计算错误: ex.getMessage(), 错误, JOptionPane.ERROR_MESSAGE); // 错误时不清空输入方便用户修改 } break; case C: // 清空输入框 inputField.setText(); break; case ⌫: // 退格键 if (!currentText.isEmpty()) { inputField.setText(currentText.substring(0, currentText.length() - 1)); } break; default: // 数字、运算符、括号、小数点直接追加到输入框 inputField.setText(currentText command); break; } } }); } } }控制器逻辑相对直接但有几个细节值得深究错误处理计算可能失败表达式非法、除零等。我们使用try-catch捕获异常并通过JOptionPane弹窗友好地提示用户而不是让程序崩溃。这是桌面应用必备的健壮性设计。结果格式化直接输出double值可能会显示13.0或13.0000000001浮点数精度问题。我们做了一个简单处理如果结果非常接近整数就显示为整数否则保留一定小数位数并去除末尾无意义的零。这大大提升了显示效果。历史记录每次成功计算后我们将表达式和结果追加到historyArea中。这里使用append方法并加上换行符\n可以实现自动滚动的历史列表。6. 启动与集成组装完整的应用程序最后我们需要一个入口点来组装MVC三者并启动应用。public class CalculatorApp { public static void main(String[] args) { // 在事件调度线程(Event Dispatch Thread)中创建GUI这是Swing的最佳实践 SwingUtilities.invokeLater(new Runnable() { Override public void run() { CalculatorModel model new CalculatorModel(); CalculatorView view new CalculatorView(); new CalculatorController(model, view); // 控制器建立连接 // 视图JFrame已经在构造函数中setVisible(true)所以窗口会显示出来 } }); } }使用SwingUtilities.invokeLater来启动GUI是至关重要的。Swing不是线程安全的所有对UI组件的创建、修改都必须在事件调度线程EDT中进行。这是Swing开发中一个必须牢记的规则违反它会导致界面绘制错乱或随机崩溃。7. 功能增强与进阶思考让计算器更“高级”一个基础的高级计算器已经完成。但我们可以让它更强大、更健壮。这里有几个方向7.1 输入验证与实时反馈目前的实现错误只有在按下等号后才会被发现。我们可以增加实时验证。例如在控制器中每当输入框内容变化可以尝试进行简单的语法预检查比如括号是否闭合、运算符是否连续出现两个。更高级的做法是在用户输入时禁用当前语法状态下不合法的按钮例如在输入左括号后等号按钮可以暂时禁用。7.2 支持更多运算符和函数扩展模型层的PRECEDENCE映射和evaluatePostfix方法。例如添加幂运算^优先级设为3并在计算时调用Math.pow(a, b)。添加函数如sin,cos,log。这需要在调度场算法中识别这些函数名它们不是单个字符并为其定义优先级和计算逻辑。函数通常具有最高优先级。7.3 处理负数与一元运算符当前的算法能处理像5 * -2这样的表达式吗不能。因为我们的词法分析会将-2识别为运算符-和数字2这不符合调度场算法的输入。处理一元负号即负号是一个进阶话题。一种常见方法是在解析中缀表达式时如果遇到-且它前面是另一个运算符或左括号或开头则将其视为一元负号我们可以将其转换为一个特殊的标记如#代表取负并赋予其较高的优先级。在计算后缀表达式时遇到#就弹出一个操作数进行取负运算。7.4 内存功能MRC, M, M-, MS实现内存功能需要为模型或控制器增加一个memory变量。然后添加对应的内存按钮M, M-, MC, MR。在控制器中为这些按钮添加监听器执行对memory变量的加、减、清空、读取操作。读取MR通常是将memory的值插入到当前输入光标位置这需要JTextField支持获取和设置光标位置稍微复杂一些。7.5 界面美化与用户体验键盘支持为窗口添加键盘监听器KeyListener让用户可以直接用键盘输入数字和运算符提升效率。字体与颜色进一步调整字体、大小、颜色方案使其更符合现代审美。窗口缩放使用更复杂的布局管理器如GridBagLayout或嵌套面板使窗口大小可调时按钮能按比例缩放。实现这个计算器的过程远不止是学会怎么摆按钮和做加减乘除。它强迫你去思考数据结构栈的运用、算法调度场、软件设计模式MVC、事件驱动编程、异常处理、字符串处理以及用户体验。每一个看似简单的功能背后都可能藏着需要仔细处理的边界情况。当你成功运行它并能够用它计算复杂表达式时你所获得的是对Java面向对象和GUI编程一次深刻而系统的实践理解。
返回列表