深入解析算术运算符:从整数除法到浮点精度边界问题
2026/9/11 10:23:29 网站建设 项目流程

最近批改作业时发现一个有意思的现象:只要题目涉及加减乘除,大部分同学都能写对,但一旦混入7 / 2-7 % 30.1 + 0.2,正确率就断崖式下跌。你问任何一个刚学编程的人“算术运算符是什么”,他都能背出加减乘除;可你再追问一句“5 / 2在 Java 里等于多少”,答案就开始五花八门了。

这一节要聊的就是这个“看似人畜无害、实则到处是边角问题”的算术运算符。文章会以 Java 作为主语言讲解,Python、C/C++ 的差异也会顺带标注出来。适合刚学完基本语法的读者,也适合那些“代码能跑但总在边界情况翻车”的同学。名字虽然叫 7.1,但今天的内容量足够顶得上别人三节。

1. 算术运算符的完整家谱:远不止加减乘除

很多人提到算术运算符,第一反应就是+-*/,顶多再加一个%。但实际写代码时,你还会碰到++--,以及+=-=这一票复合赋值运算符。它们虽然看起来不起眼,但用错的地方一点都不少。

1.1 从五个基本符号开始:+ - * / %

先看一张最基础的对照表,把每个符号的形式语义摆清楚:

运算符名称示例结果(假设 a=10, b=3)
+加法a + b13
-减法a - b7
*乘法a * b30
/除法a / b3(不是 3.333)
%取模(求余)a % b1

这里面的第一个陷阱就是除法。数学里10 / 3 = 3.333...,但在大部分编程语言中,两个整数做除法,结果只会保留整数部分,也就是3。这个“截断”行为和四舍五入完全是两码事,后面第 2 章会展开细讲。

另外要注意%在英文里经常叫 remainder(求余),而不是数学意义上的 mod(模)。在操作数都是正数时两者没有区别,一旦出现负数,结果就会跟着语言走的。这也是很多人在刷算法题时莫名其妙错的点。

我自己在第一次接触 Java 时,一直不理解为什么-7 % 3会得到-1。当时老师只说“记住结果符号跟被除数一致就行了”,但没有解释为什么。直到后来看语言规范,才知道这背后是“商向零取整”还是“向下取整”的差别。这个等 2.2 节专门说。

1.2 容易忽略的复合赋值运算符

复合赋值运算符长这样:+=-=*=/=%=。它的语义可以理解成“先做算术运算,再赋值回去”。

int x = 5; x += 3; // x = x + 3,结果 8 x *= 2; // x = x * 2,结果 16

到这里看起来很简单,但有一个面试常考点:在 Java 中,x *= y + 1等价于x = x * (y + 1),而不是x = x * y + 1。也就是说复合赋值右侧整体会被当成一个表达式先求值,然后才和左侧做运算。C/C++ 也有类似的规则,但 Python 没有复合赋值运算符之外的这种括号问题。

还有一个隐蔽细节:复合赋值自带强转能力。看这段代码:

short s = 1; s = s + 1; // 编译报错:不兼容的类型,从 int 转到 short 可能会有损失 s += 1; // 编译通过

原因是s + 1的结果是int,直接赋给short变量会丢精度,编译器不允许;而s += 1在字节码层面会自动做一次窄化转换,相当于s = (short) (s + 1)。这个行为很多人用了好几年都不知道,但真的能在你意想不到的地方省下不少麻烦。

2. 整除与取模的边界:新手翻车重灾区

如果说算术运算符是一座冰山,+-*是水面上的部分,那/%的边界行为就是水下的暗礁。这一章把它们单独拎出来,是因为这两兄弟几乎承包了新手写代码时一半的诡异 bug。

2.1 整数除法:为什么 5 / 2 的结果是 2 而不是 2.5

先明确一个底层逻辑:在 Java 中,两个int运算,结果必然还是int5 / 2的精确值是 2.5,但2.5不是整数,放不进int这个容器里,语言就选择直接把小数部分丢掉了。注意是“丢掉”,不是“四舍五入”。5 / 2结果是 2,7 / 2结果是 3,-7 / 2结果是 -3(商向零取整,-3.5 向零取整就是 -3)。

如果想让结果保留小数,最简单的办法是“至少让一个操作数变成浮点数”:

double result1 = 5 / 2.0; // 2.5 double result2 = 5.0 / 2; // 2.5 double result3 = (double) 5 / 2; // 2.5

新手最容易踩的坑是写平均值:

int total = 95; int count = 6; double average = total / count; // 你以为等于 15.833,实际是 15.0

因为total / count是整数除法,先得到15,再被赋值给double,所以平均值无论如何都只有15.0。正确写法是先转double,再做除法。这个坑在学校作业里出现频率极高,在真实业务代码里也会导致统计报表数据对不上。

2.2 负数的取模:-7 % 3 在不同语言里的结局

直接看对比表,同一句-7 % 3

语言结果说明
Java-1结果符号跟随被除数
C/C++-1结果符号跟随被除数(C99 起)
Python2结果符号跟随除数
JavaScript-1结果符号跟随被除数

为什么会有这种差异?核心在于“取模”到底怎么定义。Java 遵守的规则是商向零取整:-7 / 3 = -2(向零取整),余数就是-7 - (-2 * 3) = -1。Python 不一样,它的商向下取整:-7 / 3 = -3(向下取整),余数就是-7 - (-3 * 3) = 2

所以在 Java 里判断奇偶,用n % 2 == 1在负数下会出问题。-3 % 2结果是-1,不会等于1,于是你写出来的“奇偶数判断”遇到负数就会失灵。要么改成n % 2 != 0,要么用(n & 1) == 1这种位运算。Python 则没有这个烦恼,-3 % 2统一得到1

这里想补充一个实际开发中的体验:跨语言迁移代码时,取模逻辑是最容易“静默出错”的地方之一。两边都编译通过、运行不报错,但结果就是差 1 或差 3。做数据分片、哈希取模、循环队列的时候,一定要先确认你用的语言是哪种取模规则。

2.3 取模的三大实战场景

取模运算符在刷题和工程里用得很多,最常见的三个场景:

判断奇偶n % 2 == 0是偶数,!= 0是奇数。在 Java 里建议用n % 2 != 0防止负数坑。

环形索引。比如列表长度是n,你想让下标在 0 到 n-1 之间循环递增,可以直接i % n

int n = 4; for (int i = 0; i < 20; i++) { int index = i % n; // 0, 1, 2, 3, 0, 1, 2, 3, ... }

这在轮询任务、负载均衡、数据分页里都能用。取模本质上是在“把无限增长的数映射到一个有限范围内”。

单位换算。把秒转换成“X分Y秒”:

int totalSeconds = 97; int minutes = totalSeconds / 60; // 1 int seconds = totalSeconds % 60; // 37

如果把这两个运算符合在一起,遇到totalSeconds = 3600也能正确得到60分0秒而不是59分60秒,因为整体逻辑是“整数除法定整数部分,取模拿余数”,这是天生的搭档。

3. 类型转换的暗礁:精度丢失与整型溢出

算术运算符真正的难度不在符号本身,而在“类型”这道紧箍咒。你要先搞清楚操作数的类型是什么、结果是什么类型,才能预测代码的行为。这一章集中解决三个高频问题:自动类型转换规则、浮点精度、整型溢出。

3.1 自动类型转换:小类型并没有变成长类型

Java 里数值类型有个“自动提升”的链条,按范围从小到大排序:

byte -> short -> int -> long -> float -> double

这里有个奇怪的地方:float只有 4 个字节,long有 8 个字节,但float排在long后面,因为float的表示范围远大于long。这就是为什么longfloat运算时,结果是float,而不是long

自动类型转换的规则可以记成一句话:两个不同类型的数值做算术运算,结果至少是小范围类型自动放大到大范围类型。举例:

int a = 10; long b = 20L; long c = a + b; // a 自动提升为 long,结果是 long int x = 5; double y = 2.5; double z = x + y; // x 自动提升为 double,结果是 double

一个非常经典的考题:byte + byte的结果是什么类型?答案是int。因为 Java 规定,byteshortchar参与算术运算时,会先提升为int,所以byte变量相加的结果放回byte需要显式强转:

byte a = 1; byte b = 2; byte c = a + b; // 编译报错 byte d = (byte) (a + b); // 正确

这个提升机制的本意是防止小类型的运算结果溢出,但也给初学者带来了困惑。想避免这个问题,建议在变量命名时把类型写清楚,不要什么都用int,也不要到处强转。

3.2 浮点数陷阱:0.1 + 0.2 不等于 0.3

先在 Java 里跑一下:

double a = 0.1; double b = 0.2; System.out.println(a + b); // 0.30000000000000004

这不是 Bug,而是 IEEE 754 浮点表示法的固有特性。计算机用二进制存储小数,而 0.1 在二进制下是一个无限循环小数,就像 1/3 在十进制下是 0.3333... 一样。double只能保存 52 位有效数字,多余的部分被截断,所以 0.1 存进内存时已经不是一个精确值了,两个不精确值相加自然得不到精确的 0.3。

处理方案有三条:

  1. BigDecimal做精确计算,适合金额等场景。
  2. 用整型(分、厘)来存金额,避免浮点运算,比如 9.9 元存成 990 分。
  3. 只做比较或展示时,用Math.round()或者格式化保留指定位数。

BigDecimal时有个容易踩的坑,必须用字符串构造器,不能直接传double

BigDecimal a = new BigDecimal("0.1"); // 推荐 BigDecimal b = BigDecimal.valueOf(0.1); // 推荐 BigDecimal c = new BigDecimal(0.1); // 不推荐,依然会有尾巴

直接new BigDecimal(0.1)会把 0.1 的二进制近似值完整地“展开”,得到一长串你根本看不懂的数字。我见过不止一次因为这个写错金额校验的。

3.3 整型溢出:最大值再加 1,结果可能变成一个负数

int能表示的最大值是2147483647,也就是Integer.MAX_VALUE。当你对它加 1 时,会发生溢出,结果变成-2147483648,也就是最小值。用一个例子就能说明:

int max = Integer.MAX_VALUE; System.out.println(max + 1); // -2147483648

为什么?因为int在内存里用补码表示,最大值二进制是0111...111,加 1 后进位,符号位从 0 变成 1,解读出来就成了负数。这跟汽车里程表跑到 99999 之后归 0 是一个道理。

溢出在业务里最常见的场景是“时间戳毫秒计算”。假设用int存一个偏移量,再乘 1000 转毫秒:

int seconds = 300000000; // 约 9.5 年 int millis = seconds * 1000; // 溢出,结果变成负的

这里的seconds * 1000在数学上是 3 千亿,远超int的上限,结果就彻底错了。要避免溢出,原则是用更大的类型:

long millis = seconds * 1000L; // 至少一个操作数是 long

如果是 Java 8+,还可以用Math.addExactMath.multiplyExact这类方法,溢出会直接抛异常,方便你第一时间发现:

try { int r = Math.addExact(max, 1); } catch (ArithmeticException e) { System.out.println("溢出啦:" + e.getMessage()); }

4. 自增自减的科学:前置后缀从字节码到日常使用

++--是很多新手学算术运算符时最摸不着头脑的一对。明明都是加 1,为什么还要分i++++i?它们之间到底差在哪?这一章我先把结论给你,再带你从字节码角度看清楚本质。

4.1 前缀和后缀的表达式值差别

先记住一句话:不管是++i还是i++,变量i最终都会自增 1,区别在于整个表达式的值不同。看代码:

int a = 5; int b = a++; // b = 5,a 变为 6 // 后缀:表达式的值是自增之前的值,所以 b 拿到旧值 5 int c = 5; int d = ++c; // d = 6,c 变为 6 // 前缀:表达式的值是自增之后的值,所以 d 拿到新值 6

这个区别在println里特别容易翻车:

int x = 3; System.out.println(x++); // 打印 3,打印之后 x 才变成 4 System.out.println(x); // 打印 4

我第一次写System.out.println(x++)时,以为会打印 4,结果打印了 3。后来想明白了:println的参数是一个表达式,表达式x++的值是“自增前的旧值”。如果你真的想打印自增后的值,应该先++x,或者把自增和打印拆成两行。

4.2 字节码视角:iload 与 iinc 的先后顺序

Java 里i++++i的差别,用javap看字节码最直观。以int i = 0; i++;int j = 0; ++j;为例,核心指令就两个:iload(把局部变量压入栈)和iinc(对局部变量加 1)。

  • i++的字节码顺序是:iload先执行,把旧值压栈,然后iinc自增,表达式最终返回栈里那个旧值。
  • ++i的字节码顺序是:iinc先执行,变量已经是新值,然后iload压栈,表达式返回的是新值。

所以在字节码层面,两者就是“压栈”和“自增”的顺序兑换。这也解释了为什么在连续赋值、方法参数这类场景中,前缀后缀表现不同。

有一个建议:不要把自增嵌套进复杂的表达式里,比如arr[i] = i++ + ++i这类写法。虽然在 Java 里有明确的求值顺序,但可读性极差,也容易让人误判。写代码的第一目标是让人看懂,不是证明你懂。

4.3 循环里怎么用:别陷入性能误区

很多人会纠结for循环里写i++还是++i。从效果上看,只要i没有被用在表达式里,两者没有区别。现代编译器对这种情况会做优化,不会因为写i++就多一条指令。

真正的性能差异只在 C++ 迭代器场景里提过:理论上++it返回引用,it++返回旧值的临时副本,可能产生拷贝。但在 Java 里,这个讨论基本没有意义,普通for循环写哪个都行。

需要留意的反而是“边界下标”问题。看这段代码:

int[] nums = {1, 2, 3}; int i = 0; System.out.println(nums[i++]); // 打印 nums[0],然后 i 变成 1 System.out.println(nums[++i]); // 先 i 变成 1,然后打印 nums[1]

第一句nums[i++]用的是旧下标 0;第二句nums[++i]用的是新下标 1。如果你本意是“从头到尾遍历数组”,用i++没问题;如果你想“先移动指针再取值”,就得用++i。这种细节在写栈、队列这种数据结构时特别常见,错一个就访问越界。

5. 除零与 NaN:算术语语句中的暗雷

除法里面还有个更大的坑,就是“除数为 0”。很多人数学课上就学过“0 不能做除数”,但具体到代码里,不同数据类型跑出来的结果完全不同。这一章把除零和它的近亲NaN一次性说清楚。

5.1 整数除零:直接抛异常

在 Java 中,整数除以 0 会直接抛出ArithmeticException

int a = 10; int b = 0; int c = a / b; // Exception in thread "main" java.lang.ArithmeticException: / by zero

这个异常是运行时异常,编译器不会提前拦截。一旦线上出现,用户会看到 500 错误。所以做除法之前一定要检查除数:

if (b == 0) { System.out.println("除数不能为 0"); } else { int c = a / b; }

取模运算同样不能对 0 操作,a % 0也会抛异常。我们在 2.3 节拿取模做环形索引时,如果n为 0,也会立刻炸掉。

5.2 浮点除零:Infinity 和 NaN 的规则

浮点数除零就不会抛异常了,而是返回特殊值:

表达式结果
1.0 / 0Infinity
-1.0 / 0-Infinity
0.0 / 0NaN(Not a Number)
1.0 / 0.0Infinity

为什么会这样?因为 IEEE 754 标准专门定义了InfinityNaN来表示“无穷”和“不是一个数”这种情况。Infinity可以继续参与运算,比如Infinity + 1还是InfinityInfinity * 2还是InfinityNaN就比较诡异了,它不等于任何数,包括它自己:

double n = 0.0 / 0.0; System.out.println(n == Double.NaN); // false System.out.println(n != n); // true

我第一次看到n != n返回true时非常震惊。后来明白了,NaN代表的不是一个具体的值,而是一种“无法定义”的状态。拿它做==比较没有意义,要判断NaN只能用Double.isNaN(n)

5.3 防御式编程:把边界情况当成常规情况

写业务代码时,如果某个参数是外部传入的,你完全无法假设它不为 0。我习惯在每个除法方法里先写一个校验,同时把浮点计算的NaNInfinity也列入检查范围:

public double safeDivide(double a, double b) { if (b == 0.0) { // 根据业务决定抛异常还是返回默认值 return 0.0; } double result = a / b; if (Double.isNaN(result) || Double.isInfinite(result)) { return 0.0; } return result; }

这套代码看起来“防御过度”,但实际效果很好。我见过一个报表系统,因为某个字段偶尔为 0,导致计算结果出现Infinity,然后这个值又被写进数据库,最后前端展示出“∞”字样,排查了很久才定位到源头。与其事后救火,不如在入口处把所有异常结果挡掉。

6. 实战:手写一个控制台计算器

理论基础讲了一大堆,最终还是要落到代码上。我推荐每个初学者都亲自动手写一个控制台计算器。它看起来很简单,但要把算术运算符的各种边界问题处理好,其实能锻炼很多编程基础能力。

6.1 需求拆解与设计

先明确需求:用户输入两个数字和一个运算符,程序输出结果;支持+-*/%;除法时要检查除数为 0;程序可以反复执行,直到用户输入退出指令。

这个项目虽然小,但你需要考虑几个问题:

  1. 输入用Scanner,处理字符串和数值转换。
  2. switchif-else根据运算符分发到不同分支。
  3. 除法分支要判断除数是否为 0。
  4. 用循环包裹整个流程,支持多次计算。

6.2 完整实现代码

import java.util.Scanner; public class Calculator { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); while (true) { System.out.print("输入第一个数(输入 q 退出): "); String input1 = scanner.nextLine(); if ("q".equalsIgnoreCase(input1)) { break; } System.out.print("输入运算符(+ - * / %): "); String op = scanner.nextLine(); System.out.print("输入第二个数: "); String input2 = scanner.nextLine(); double num1; double num2; try { num1 = Double.parseDouble(input1); num2 = Double.parseDouble(input2); } catch (NumberFormatException e) { System.out.println("数字格式不正确,请重新输入。"); continue; } switch (op) { case "+" -> System.out.println("结果: " + (num1 + num2)); case "-" -> System.out.println("结果: " + (num1 - num2)); case "*" -> System.out.println("结果: " + (num1 * num2)); case "/" -> { if (num2 == 0) { System.out.println("除数不能为 0。"); } else { System.out.println("结果: " + (num1 / num2)); } } case "%" -> { if (num2 == 0) { System.out.println("除数不能为 0。"); } else { System.out.println("结果: " + (num1 % num2)); } } default -> System.out.println("无效运算符。"); } } scanner.close(); } }

这里我用Double.parseDouble来解析输入,好处是用户直接输入53.5都能被识别。虽然浮点数运算会有精度问题,但在控制台演示场景下足够用了。如果你要把结果用于金额计算,建议把数据类型换成BigDecimal,把运算符分支里的算术运算全部改成对应的方法调用,这是一个很好的扩展练习。

6.3 边界测试与复盘

写完代码不是终点,测试才是。我建议至少跑这样一组用例:

输入期望输出实际输出说明
5 + 38.08.0基础加法
5 / 22.52.5double运算,不是整数除法
5 / 0提示除数不能为 0提示除数不能为 0整数除零避免异常
5 % 0提示除数不能为 0提示除数不能为 0取模除零避免异常
-7 % 3-1.0-1.0负数取模,确认符号规则
输入q退出程序退出程序用户主动退出

跑完之后复盘一下:为什么用double而不是int?因为用户可能输入小数。为什么5 / 2结果是 2.5?因为操作数已经是double。为什么除零要单独判断?因为 Java 的double除零不报错,会返回Infinity,而整数除零会抛异常,两者行为不一致,必须由代码层面统一处理。

我在实际写这个计算器时,犯过一个特别低级的错误:忘记在%分支里处理除零。用户输入5 % 0时,程序直接抛了ArithmeticException。当时第一反应是“好奇怪,double除零不报错,取模却报错”,后来一查才发现,浮点取模num1 % num2在 Java 里也会对num2为 0 的情况抛异常,和整数取模行为一致。这个细节让我意识到,边界测试必须覆盖到所有运算符和所有特殊输入。

最后分享一个个人的检查习惯:每次写完包含除法或取模的代码,我会专门写一组“负数和零”的测试用例。这花不了几分钟,但能挡掉很多线上才暴露的问题。算术运算符看起来是最基础的内容,可真正决定代码质量的,往往就是这些最基础规则的理解深度。

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

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

立即咨询