Java计算器源代码详解:从界面搭建到运算逻辑

Java计算器源代码详解:从界面搭建到运算逻辑

Java计算器源代码详解:从界面搭建到运算逻辑

计算器是练习 Java 桌面开发的经典小项目。它的界面看起来简单,却能集中练到 Swing 组件布局、事件监听、输入状态管理、十进制运算和异常处理。本文从需求与边界开始,逐步解释实现思路,并提供一份可直接编译运行的完整示例。

示例采用 Java Swing 实现,支持数字、小数点、加减乘除、正负号、清除、清除当前输入、退格和等号。运算按按键顺序立即执行,例如输入 2 + 3 × 4 =,结果为 20,而不是遵循通常的乘除优先级得到 14。这是许多基础计算器的行为;如果产品要求标准优先级,需要另行设计表达式解析器。

一、适用场景与实现边界

这份程序适合学习 Swing 事件驱动开发、制作简单桌面演示程序,或作为后续扩展的计算器原型。它不是科学计算器,也不是金融或安全关键系统的计算内核。程序仅接收按钮输入,不解析用户键入的任意表达式,因此没有表达式注入这一类输入路径;但这不代表它可以直接用于任何安全用途。涉及金额、计量、医疗或其他高后果场景时,必须根据业务单位、精度、舍入规则和验证要求单独设计并测试,不能把示例代码当作已经认证的计算组件。

  • 输入长度:数字输入最多 30 个字符,限制的是正在编辑的输入文本,不是计算结果的位数。这个限制用于控制界面输入规模,不是 Java BigDecimal 的通用参数上限。
  • 小数精度:加、减、乘使用 BigDecimal;除法结果保留 12 位小数,并按 HALF_UP 舍入。这是示例明确选择的显示精度,不代表适用于所有业务。
  • 除数为零:显示错误状态,并要求用户重新开始,不返回无穷大或特殊数值。
  • 运算顺序:每次按下新的运算符时,先完成已有的待处理运算,即从左到右连续计算。
  • 键盘输入:示例只响应界面按钮。若要支持键盘,应增加键绑定,并复用同一套输入处理方法,而不是另写一套运算规则。

二、状态设计:按钮之外的核心

计算器不能只靠一个“当前显示值”完成所有操作。程序需要记住当前输入、上一阶段的结果以及等待执行的运算符。示例使用以下状态:

  • input:屏幕上显示的当前输入文本。使用字符串而非浮点数保存,可以保留用户正在输入的小数点,例如 12.。
  • storedValue:已经保存、等待参与下一次运算的数值。
  • pendingOperator:尚未完成的运算符,例如 + 或 ×。
  • startNewNumber:下一次输入数字时,是开始一个新数字,还是继续追加到当前数字。
  • error:是否处于错误状态。发生除零后,除清除操作外,其他输入会被忽略。

例如,用户输入 8、按 +、再输入 5 时,程序先把 8 保存到 storedValue,将 + 保存到 pendingOperator,再把 5 作为当前输入。按等号后,才执行保存的运算。

三、完整可运行代码

将下面代码保存为 CalculatorApp.java。使用 JDK 8 或更高版本,在命令行运行 javac CalculatorApp.java 编译,再运行 java CalculatorApp 启动。Swing 程序应从事件分发线程创建界面,因此入口使用 SwingUtilities.invokeLater。

import java.awt.BorderLayout;
import java.awt.Font;
import java.awt.GridLayout;
import java.math.BigDecimal;
import java.math.RoundingMode;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;

public class CalculatorApp {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new CalculatorFrame().setVisible(true));
    }
}

class CalculatorFrame extends JFrame {
    private static final int MAX_INPUT_LENGTH = 30;
    private static final int DIVISION_SCALE = 12;

    private final JTextField display = new JTextField("0");

    // 当前正在编辑的输入。用字符串保留末尾小数点等输入状态。
    private String input = "0";
    private BigDecimal storedValue;
    private String pendingOperator;
    private boolean startNewNumber = true;
    private boolean error = false;

    CalculatorFrame() {
        super("Java 计算器");
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setSize(320, 420);
        setLocationRelativeTo(null);
        setLayout(new BorderLayout(8, 8));

        display.setEditable(false);
        display.setHorizontalAlignment(JTextField.RIGHT);
        display.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 28));
        add(display, BorderLayout.NORTH);

        String[] labels = {
            "CE", "C", "退格", "÷",
            "7", "8", "9", "×",
            "4", "5", "6", "−",
            "1", "2", "3", "+",
            "±", "0", ".", "="
        };

        JPanel panel = new JPanel(new GridLayout(5, 4, 5, 5));
        for (String label : labels) {
            JButton button = new JButton(label);
            button.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 20));
            button.addActionListener(event -> handle(label));
            panel.add(button);
        }
        add(panel, BorderLayout.CENTER);
    }

    private void handle(String key) {
        if ("C".equals(key)) {
            clearAll();
            return;
        }
        if ("CE".equals(key)) {
            clearEntry();
            return;
        }
        if (error) {
            return;
        }

        if (isDigit(key)) {
            enterDigit(key);
        } else if (".".equals(key)) {
            enterDecimalPoint();
        } else if ("退格".equals(key)) {
            backspace();
        } else if ("±".equals(key)) {
            toggleSign();
        } else if (isOperator(key)) {
            chooseOperator(key);
        } else if ("=".equals(key)) {
            calculateResult();
        }
    }

    private boolean isDigit(String key) {
        return key.length() == 1 && key.charAt(0) >= '0' && key.charAt(0) <= '9';
    }

    private boolean isOperator(String key) {
        return "+".equals(key) || "−".equals(key)
                || "×".equals(key) || "÷".equals(key);
    }

    private void enterDigit(String digit) {
        if (startNewNumber) {
            input = digit;
            startNewNumber = false;
        } else {
            if (input.length() >= MAX_INPUT_LENGTH) {
                return;
            }
            if ("0".equals(input)) {
                input = digit;
            } else if ("-0".equals(input)) {
                input = "-" + digit;
            } else {
                input += digit;
            }
        }
        showInput();
    }

    private void enterDecimalPoint() {
        if (startNewNumber) {
            input = "0.";
            startNewNumber = false;
        } else if (!input.contains(".")) {
            if (input.length() >= MAX_INPUT_LENGTH) {
                return;
            }
            input += ".";
        }
        showInput();
    }

    private void backspace() {
        if (startNewNumber) {
            return;
        }
        if (input.length() <= 1 || (input.length() == 2 && input.startsWith("-"))) {
            input = "0";
        } else {
            input = input.substring(0, input.length() - 1);
            if ("-".equals(input)) {
                input = "0";
            }
        }
        showInput();
    }

    private void toggleSign() {
        if ("0".equals(input) || "0.".equals(input)) {
            return;
        }
        if (input.startsWith("-")) {
            input = input.substring(1);
        } else {
            input = "-" + input;
        }
        startNewNumber = false;
        showInput();
    }

    private void chooseOperator(String operator) {
        BigDecimal current = parseInput();

        if (pendingOperator != null && !startNewNumber) {
            BigDecimal result = apply(storedValue, current, pendingOperator);
            input = format(result);
            storedValue = result;
            showInput();
        } else if (pendingOperator == null) {
            storedValue = current;
        }

        pendingOperator = operator;
        startNewNumber = true;
    }

    private void calculateResult() {
        if (pendingOperator == null || startNewNumber) {
            return;
        }

        try {
            BigDecimal result = apply(storedValue, parseInput(), pendingOperator);
            input = format(result);
            storedValue = result;
            pendingOperator = null;
            startNewNumber = true;
            showInput();
        } catch (ArithmeticException ex) {
            showError();
        }
    }

    private BigDecimal apply(BigDecimal left, BigDecimal right, String operator) {
        switch (operator) {
            case "+":
                return left.add(right);
            case "−":
                return left.subtract(right);
            case "×":
                return left.multiply(right);
            case "÷":
                if (right.compareTo(BigDecimal.ZERO) == 0) {
                    throw new ArithmeticException("division by zero");
                }
                return left.divide(right, DIVISION_SCALE, RoundingMode.HALF_UP);
            default:
                throw new IllegalArgumentException("未知运算符:" + operator);
        }
    }

    private BigDecimal parseInput() {
        return new BigDecimal(input);
    }

    private String format(BigDecimal value) {
        BigDecimal normalized = value.stripTrailingZeros();
        if (normalized.compareTo(BigDecimal.ZERO) == 0) {
            return "0";
        }
        return normalized.toPlainString();
    }

    private void clearEntry() {
        input = "0";
        startNewNumber = true;
        showInput();
    }

    private void clearAll() {
        input = "0";
        storedValue = null;
        pendingOperator = null;
        startNewNumber = true;
        error = false;
        showInput();
    }

    private void showInput() {
        display.setText(input);
    }

    private void showError() {
        error = true;
        display.setText("错误:除数为零");
    }
}

四、界面搭建与事件分发

CalculatorFrame 继承 JFrame,负责窗口和计算器状态。显示区使用只读的 JTextField,并设置右对齐,让数字的视觉位置更接近常见计算器。按钮放在 GridLayout(5, 4) 面板中;它会把组件排列成五行四列,适合这种固定矩阵布局。

每个按钮都绑定同一个监听器,将按钮文字传给 handle。统一入口有两个好处:界面创建代码简洁,所有输入又能经过相同的状态检查。按钮文字本身也是操作标识,例如界面上的 × 会传给运算逻辑,而不是使用 Java 表达式中的乘号字符。

Swing 组件应在事件分发线程中创建和更新。示例通过 SwingUtilities.invokeLater 启动窗口;后续如果加入耗时操作,不应在按钮监听器里执行长时间任务,否则窗口可能失去响应。当前计算只涉及少量本地十进制运算,直接在监听器中处理即可。

五、输入处理中的几个细节

1. 为什么输入先保存为字符串

如果每按一个数字都立即转成数值,用户输入 3. 时,数值本身无法表达“用户刚打完小数点、还没输入小数位”这一编辑状态。保留字符串可以分别处理开头的零、小数点、负号和退格,直到需要运算时再用 new BigDecimal(input) 转换。

示例只允许一个小数点,且按小数点时如当前是新数字,就生成 0.。这样单独按小数点后再按 5,得到的是 0.5。当输入长度达到 30 个字符时,程序拒绝继续追加;这个检查针对普通数字输入和小数点,不约束运算结果文本。

2. 退格与清除不是一回事

CE 将当前输入清为零,但保留尚未执行的运算状态;C 则连同保存值和待处理运算符一起清除。退格只在当前数字正在编辑时生效,若屏幕内容已经是一次计算的结果,按退格不会修改它。真实产品可以按需求调整这些按键语义,但应明确区分并测试。

± 只切换当前输入的正负号,零不做切换。它不是一元运算表达式解析器的一部分。示例允许当前输入包含负号,因此负数输入同样受 30 字符长度限制。

六、运算精度与除法处理

Java 的 double 使用二进制浮点表示,许多十进制小数无法精确表示。对于需要按十进制规则计算的场景,直接使用 BigDecimal 更容易明确精度策略。应从字符串创建它,例如 new BigDecimal("0.1");若先把十进制值放进 double 再构造,二进制浮点误差可能已经进入计算过程。

除法可能产生无限循环小数。对 BigDecimal.divide 不指定精度和舍入模式时,某些结果会抛出 ArithmeticException。示例先判断除数是否为零,然后调用带小数位数和舍入模式的重载方法:最多保留 12 位小数,采用四舍五入规则 HALF_UP。例如 1 ÷ 3 会得到有限位数的近似结果。

stripTrailingZeros 用于去掉结果尾部多余的零,toPlainString 则避免结果以科学计数法显示。零另行格式化为 0,避免出现不直观的负零或带指数的零。

这里的 12 位是显示与计算策略的一部分,并非所有计算器或所有 Java 程序的固定精度。若计算金额,应明确货币最小单位及每一步的舍入时机;若计算测量值,应明确有效数字和单位换算规则。仅仅换成 BigDecimal,并不自动保证业务结果正确。

七、运算状态的转移方式

按下运算符时,chooseOperator 会根据状态决定接下来做什么。如果已有待处理运算符,且用户已经输入了右侧数字,就先计算旧运算,再把结果作为新的保存值;如果是第一次按运算符,则保存当前输入。随后记录新的运算符,并把 startNewNumber 设为真。

按等号时,只有“存在待处理运算符且当前确实输入了右侧数字”才计算。因此在输入 9 + 后直接按等号,不会意外把某个未输入的操作数当成有效数字。计算成功后清除待处理运算符,结果留在屏幕上。示例没有实现重复按等号重复执行上一次运算,这是一项可按需求增加的功能。

连续运算采用顺序计算。输入 2 + 3 × 4 = 时,按下乘号的那一刻先算出 2 + 3,然后用结果乘以 4。若要求乘除优先于加减,不能只在当前逻辑上增加几条条件判断;通常应先把输入表示成运算符与操作数序列,再实现解析、优先级和括号规则。

八、常见错误与排查方法

  • 用字符串直接比较数值。字符串 "2.0" 与 "2" 文本不同,但数值相等。判断除零应将输入转换为 BigDecimal 后比较,示例使用 compareTo。
  • 用 double 判断除数是否为零。混用浮点数与十进制输入,会使精度策略不清晰。整个运算链应采用一致的数值类型和舍入规则。
  • 忽略输入状态。如果不区分“继续输入”和“开始新数字”,按完运算符后再输入数字可能变成拼接,例如把预期的 5 错误接到前一个数字后面。
  • 把显示文本当成唯一状态。显示区只是界面;待处理运算符、保存值和输入阶段都要单独维护。用屏幕文本反复推导全部状态,容易在退格、清除和连续运算时出错。
  • 除法不处理精度或异常。无限小数可能导致异常;除数为零也必须明确处理。应在运算边界检查,并向用户显示可理解的反馈。
  • 误以为按钮上的符号就是 Java 运算符。界面使用乘号 ×、除号 ÷、减号 −,与代码中的标准字符并不完全相同。比较时应统一使用界面传入的字符,或将界面标签映射到内部枚举。
  • 在主线程创建界面。简单示例偶尔也能运行,但 Swing 推荐在事件分发线程中创建和操作组件,程序入口应使用 invokeLater。

九、建议验证的边界情况

运行后可按以下序列检查状态与边界是否符合预期:0.5 + 0.25 =;8 ÷ 4 =;1 ÷ 0 =;5 + 2 × 3 =;输入 12.3 后连续按两次小数点;输入数字后退格至零;输入超过 30 个字符;发生错误后按数字,再按 C 恢复。

测试时不仅要看最终结果,还应检查中间行为:输入运算符后是否能开始新数字,连续运算是否按预期从左到右,清除当前输入后待处理运算是否保留,以及除零后界面是否能通过清除恢复。对于边界条件,最好把运算逻辑进一步拆到独立类中编写单元测试;这样可以在不打开窗口的情况下覆盖精度、格式和异常规则。

十、后续扩展方向

这份实现刻意保持简单。扩展时可以考虑键盘映射、百分比、平方根、重复等号、历史记录、标准运算优先级和括号。增加功能前,建议先定义按键语义与状态转移,再补测试用例;否则功能之间容易互相影响。例如,百分比在不同计算器中的含义并不完全一致,不能只按按钮名称推断规则。

当运算逻辑逐渐复杂时,可将界面类与计算模型拆分:界面只负责显示和接收操作,模型负责输入状态及运算。进一步需要表达式优先级时,可使用词法分析与解析器,将表达式转换为语法树或逆波兰表达式。不要通过 eval 之类的动态执行方式计算用户输入;自行解析可以让可执行语法和边界行为保持明确。

一个可维护的计算器,关键不在按钮数量,而在于把输入、运算和显示三类职责分清楚,并对精度、顺序、除零和输入长度作出明确约定。示例代码提供了一个可运行的起点;将它用于实际业务前,应根据真实需求调整规则,并对边界与舍入结果进行充分验证。

© 版权声明

相关文章