9.2 数字和数学运算
本节深入讨论 Java 对数字类型的支持。具体来说,我们会讨论 Java 使用二进制补码表示的整数类型,还会介绍浮点数的表示方式,以及由此引起的一些问题。最后,还会举些例子,说明如何使用 Java 库提供的函数做标准的数学运算。
9.2.1 Java表示整数类型的方式
在 2.3 节说过,Java 的整数类型都带符号,也就是说,所有整数类型既可以表示正数,也可以表示负数。因为计算机只能处理二进制数,所以唯一合理的方式是把所有可能的位组合分成两半,使用其中一半表示负数。
我们以 Java 的 byte 类型为例,说明 Java 是如何表示整数的。byte 类型的数字占 8 位,因此能表示 256 个不同的数字(即 128 个正数和 128 个负数)。0b0000_0000 表示的是零(记得吗,在 Java 中可以使用 0b 这样的句法表示二进制数),因此很容易看出表示正数的位组合:
byte b = 0b0000_0001;System.out.println(b); // 1b = 0b0000_0010;System.out.println(b); // 2b = 0b0000_0011;System.out.println(b); // 3// ...b = 0b0111_1111;System.out.println(b); // 127
如果设定了 byte 类型数字的第一位,符号应该改变(因为表示正数已经用完了其他位)。所以,0b1000_0000 组合应该表示某个负数,不过是哪个负数呢?
按照我们定义事物的方式,在这种表示方式中,很容易找到一种识别位组合是否表示负数的方式:如果位组合的高位是
1,表示的就是负数。
我们来分析一下所有位都为 1 的位组合:0b1111_1111。如果在这个数字上加 1,那么得到的结果就会超出存储 byte 类型所需的 8 位,变成 0b1_0000_0000。如果我们强制把这个数变成 byte 类型,就要忽略超出的那一位,变成 0b0000_0000,也就是零。因此,顺其自然,我们把所有位都为 1 的位组合认定为 -1。这样也就得到了符合常理的算术规则,如下所示:
b = (byte) 0b1111_1111; // -1System.out.println(b);b++;System.out.println(b);b = (byte) 0b1111_1110; // -2System.out.println(b);b++;System.out.println(b);
最后,我们来看一下 0b1000_0000 表示的是什么数字。这个位组合表示的是这个类型能表示的最小负数,所以对 byte 类型来说:
b = (byte) 0b1000_0000;System.out.println(b); // -128
这种表示方式叫二进制补码,带符号的整数最常使用这种表示方式。为了有效使用,只需记住两点:
所有位都为 1 的位组合表示 -1;
如果设定了高位,表示的是负数。
Java 的其他整数类型(short、int 和 long)和 byte 的行为十分相似,只不过位数更多。但是 char 类型有所不同,因为它表示的是 Unicode 字符,不过可以使用某种方式表示成无符号的 16 位数字类型。Java 程序员一般不会把 char 当成整数类型。
9.2.2 Java中的浮点数
计算机使用二进制表示数字。我们已经说明了 Java 如何使用二进制补码表示整数,那么分数或小数怎么办?和几乎所有现代编程语言一样,Java 使用浮点运算表示分数和小数。下面说明具体的实现方式,先说十进制(常规小数),再说二进制。Java 在 java.lang.Math 类中定义了两个最重要的数学常数,e 和 π,如下所示:
public static final double E = 2.7182818284590452354;public static final double PI = 3.14159265358979323846;
当然,这两个常数其实是无理数,无法通过分数或任何有限的小数精确表示。2 也就是说,在计算机中表示时,无论如何想尽办法,都有舍入误差。假如我们只想使用 π 的前八位数,而且想把这些数字表示成一个整数,那么可以像这样表示:
2其实,这是两个已知的超越数。
314159265 × 10-8
这种表示方式显露了实现浮点数的基本方式。我们使用其中几位表示数字的有效位(在这个例子中是 314159265),另外几位则表示底数的指数(在这个例子中是 -8)。有效位表示的是有效数字,而指数说明为了得到所需的数字,要对有效数字做上移操作还是下移操作。
当然,目前举的例子都使用十进制。可是计算机使用二进制,所以我们要以二进制为例说明浮点数的实现方式,由此也增加了一些复杂度。
![]()
0.1这个数不能使用有限的二进制数位表示。也就是说,人类关心的所有计算,使用浮点数表示时几乎都会失去精度,而且根本无法避免舍入误差。
下面举个例子,说明舍入问题:
double d = 0.3;System.out.println(d); // 精心挑选的,避免显示难看的表示double d2 = 0.2;// 应该是-0.1,但打印出来的是-0.09999999999999998System.out.println(d2 - d);
规范浮点计算的标准是 IEEE-754,Java 就是基于这个标准实现的浮点数。按照这个标准,表示标准精度的浮点数要使用 24 个二进制数,表示双精度浮点数要使用 53 个二进制数。
第 2 章简略提到过,如果有硬件特性支持,Java 能表示比标准要求的更精确的浮点数。如果需要完全兼容其他平台(可能是较旧的平台),可以使用 strictfp 关键字禁用这种行为,强制遵守 IEEE-754 标准。但这种情况极其少见,几乎不用这么做,绝大多数程序员都不会用到(甚至见不到)这个关键字。
BigDecimal 类
程序员使用浮点数时,舍入误差始终是个让人头疼的问题。为了解决这个问题,Java 提供了 java.math.BigDecimal 类,这个类以小数形式实现任意精度的计算。使用这个类可以解决有限个二进制位无法表示 0.1 的问题,不过在 BigDecimal 对象和 Java 基本类型之间相互转换时还是会遇到一些边缘情况,如下所示:
double d = 0.3;System.out.println(d);BigDecimal bd = new BigDecimal(d);System.out.println(bd);bd = new BigDecimal("0.3");System.out.println(bd);
可是,就算所有计算都按照十进制进行,仍然有些数,例如 1/3,无法使用有尽小数表示。我们来看一下尝试使用 BigDecimal 表示这种数时会出现什么状况:
bd = new BigDecimal(BigInteger.ONE);bd.divide(new BigDecimal(3.0));System.out.println(bd); // 应该是1/3
因为 BigDecimal 无法精确表示 1/3,所以调用 divide() 方法时会抛出 ArithmeticException 异常。因此,使用 BigDecimal 时一定要清醒地意识到哪些运算的结果可能是无尽小数。更糟的是,ArithmeticException 是运行时的未检异常,所以出现这种异常时,Java 编译器根本不会发出警告。
最后,关于浮点数,所有高级程序员都应该阅读 David Goldberg 写的“What Every Computer Scientist Should Know About Floating-Point Arithmetic”。网上可以轻易找到这篇文章,而且可以免费阅读。3
3这篇论文发表在 Computing Surveys 杂志 1991 年 3 月号上。Sun 公司的《数值计算指南》手册里有中文版,地址:http://docs.oracle.com/cd/E19059-01/stud.9/817-7888/817-7888.pdf。——译者注
9.2.3 Java的数学函数标准库
在结束 Java 对数字数据和数学运算的介绍之前,我们还要简单说明 Java 标准库中的一些函数。这些函数基本上都是静态辅助方法,在 java.lang.Math 类中定义,包括如下。
abs()
返回指定数的绝对值。不同的基本类型都有对应的重载方法。
- 三角函数
计算正弦、余弦和正切等的基本函数。Java 还提供了双曲线版本和反函数(例如反正弦)。
max()和min()
这两个是重载的函数,分别返回两个参数(属于同一种数字类型)中较大的数和较小的数。
floor()
返回比指定参数(double 类型)小的最大整数。ceil() 方法返回比指定参数大的最小整数。
pow()、exp()和log()
pow() 方法以第一个参数为底数,第二个参数为指数,计算次方;exp() 方法做指数运算;log() 方法做对数运算。log10() 方法计算对数时以 10 为底数,而不是自然常数。
下面举一些简单的示例,说明如何使用这些函数:
System.out.println(Math.abs(2));System.out.println(Math.abs(-2));double cosp3 = Math.cos(0.3);double sinp3 = Math.sin(0.3);System.out.println((cosp3 * cosp3 + sinp3 * sinp3)); // 始终为1.0System.out.println(Math.max(0.3, 0.7));System.out.println(Math.max(0.3, -0.3));System.out.println(Math.max(-0.3, -0.7));System.out.println(Math.min(0.3, 0.7));System.out.println(Math.min(0.3, -0.3));System.out.println(Math.min(-0.3, -0.7));System.out.println(Math.floor(1.3));System.out.println(Math.ceil(1.3));System.out.println(Math.floor(7.5));System.out.println(Math.ceil(7.5));System.out.println(Math.round(1.3)); // 返回值为long类型System.out.println(Math.round(7.5)); // 返回值为long类型System.out.println(Math.pow(2.0, 10.0));System.out.println(Math.exp(1));System.out.println(Math.exp(2));System.out.println(Math.log(2.718281828459045));System.out.println(Math.log10(100_000));System.out.println(Math.log10(Integer.MAX_VALUE));System.out.println(Math.random());System.out.println("Let's toss a coin: ");if (Math.random() > 0.5) {System.out.println("It's heads");} else {System.out.println("It's tails");}
最后,我们简要讨论一下 Java 的 random() 函数。首次调用这个方法时,会创建一个 java.util.Random 类新实例。这是个伪随机数生成器(Pseudorandom Number Generator,PRNG):生成的数看起来是随机的,其实是由一个数学公式生成的。4Java 的 PRNG 使用的公式十分简单,例如:
4在计算机中很难生成真正的随机数,也很少有这方面的需求。如果真想生成真正的随机数,一般都需要特殊的硬件支持。
// 摘自java.util.Random类public double nextDouble() {return (((long)(next(26)) << 27) + next(27)) * DOUBLE_UNIT;}
如果伪随机数序列总是从同一个地方开始,那么就会生成完全相同的数字流。为了解决这个问题,可以向 PRNG 提供一个尽可能提升随机性的种子值。为了保证种子值的随机性,Java 会使用 CPU 计数器中的值,这个值一般用于高精度计时。
Java 原生的随机数生成机制能满足多数常规应用,但某些专业应用(特别是密码相关的应用和某些模拟应用)的要求严格得多。如果要开发这类应用,请咨询已经在这些领域中的程序员,寻求专业建议。
对文本和数字数据的介绍结束了,下面介绍另一种最常遇到的数据种类:日期和时间。
按照我们定义事物的方式,在这种表示方式中,很容易找到一种识别位组合是否表示负数的方式:如果位组合的高位是 