深入解析算术运算符:从整数除法到浮点精度边界问题
最近批改作业时发现一个有意思的现象只要题目涉及加减乘除大部分同学都能写对但一旦混入7 / 2、-7 % 3、0.1 0.2正确率就断崖式下跌。你问任何一个刚学编程的人“算术运算符是什么”他都能背出加减乘除可你再追问一句“5 / 2在 Java 里等于多少”答案就开始五花八门了。这一节要聊的就是这个“看似人畜无害、实则到处是边角问题”的算术运算符。文章会以 Java 作为主语言讲解Python、C/C 的差异也会顺带标注出来。适合刚学完基本语法的读者也适合那些“代码能跑但总在边界情况翻车”的同学。名字虽然叫 7.1但今天的内容量足够顶得上别人三节。1. 算术运算符的完整家谱远不止加减乘除很多人提到算术运算符第一反应就是、-、*、/顶多再加一个%。但实际写代码时你还会碰到、--以及、-这一票复合赋值运算符。它们虽然看起来不起眼但用错的地方一点都不少。1.1 从五个基本符号开始 - * / %先看一张最基础的对照表把每个符号的形式语义摆清楚运算符名称示例结果假设 a10, b3加法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运算结果必然还是int。5 / 2的精确值是 2.5但2.5不是整数放不进int这个容器里语言就选择直接把小数部分丢掉了。注意是“丢掉”不是“四舍五入”。5 / 2结果是 27 / 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 % nint 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。这就是为什么long和float运算时结果是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 规定byte、short、char参与算术运算时会先提升为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。处理方案有三条用BigDecimal做精确计算适合金额等场景。用整型分、厘来存金额避免浮点运算比如 9.9 元存成 990 分。只做比较或展示时用Math.round()或者格式化保留指定位数。用BigDecimal时有个容易踩的坑必须用字符串构造器不能直接传doubleBigDecimal 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.addExact、Math.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 5a 变为 6 // 后缀表达式的值是自增之前的值所以 b 拿到旧值 5 int c 5; int d c; // d 6c 变为 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 会直接抛出ArithmeticExceptionint 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-Infinity0.0 / 0NaNNot a Number1.0 / 0.0Infinity为什么会这样因为 IEEE 754 标准专门定义了Infinity和NaN来表示“无穷”和“不是一个数”这种情况。Infinity可以继续参与运算比如Infinity 1还是InfinityInfinity * 2还是Infinity。NaN就比较诡异了它不等于任何数包括它自己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。我习惯在每个除法方法里先写一个校验同时把浮点计算的NaN和Infinity也列入检查范围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程序可以反复执行直到用户输入退出指令。这个项目虽然小但你需要考虑几个问题输入用Scanner处理字符串和数值转换。用switch或if-else根据运算符分发到不同分支。除法分支要判断除数是否为 0。用循环包裹整个流程支持多次计算。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来解析输入好处是用户直接输入5、3.5都能被识别。虽然浮点数运算会有精度问题但在控制台演示场景下足够用了。如果你要把结果用于金额计算建议把数据类型换成BigDecimal把运算符分支里的算术运算全部改成对应的方法调用这是一个很好的扩展练习。6.3 边界测试与复盘写完代码不是终点测试才是。我建议至少跑这样一组用例输入期望输出实际输出说明5 38.08.0基础加法5 / 22.52.5用double运算不是整数除法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 的情况抛异常和整数取模行为一致。这个细节让我意识到边界测试必须覆盖到所有运算符和所有特殊输入。最后分享一个个人的检查习惯每次写完包含除法或取模的代码我会专门写一组“负数和零”的测试用例。这花不了几分钟但能挡掉很多线上才暴露的问题。算术运算符看起来是最基础的内容可真正决定代码质量的往往就是这些最基础规则的理解深度。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →