深入理解计算机系统(2.8)---浮点数的舍入,Java中的舍入例子以及浮点数运算(重要)
前言
上一章我们简单介绍了IEEE浮点标准,本次我们主要讲解一下浮点运算舍入的问题,以及简单的介绍浮点数的运算。
之前我们已经提到过,有很多小数是二进制浮点数无法准确表示的,因此就难免会遇到舍入的问题。这一点其实在我们平时的计算当中会经常出现,就比如之前我们提到过的0.3,它就是无法用浮点小数准确表示的。
为此LZ专门写了一个小程序,使用Java语言打印出了0.3的二进制表示,是这样的一个数字,0 01111101 00110011001100110011010。我们来简单算一下,这个数值大约是多少。它的阶码在偏置之后的值为-2,它的尾数位在加1之后为1 + 1/8 + 1/16 + 1/128 + 1/256 = 1.19921875。后面还有有效位,不过我们只大概计算一下,就不算那么精确了,最终算出来的值为0.2998046875。(LZ用计算器算的,0.0)
可以看出,这个值离0.3已经非常接近了,而且我们还省略了一小部分有效小数位,但是不管怎么说,二进制无法像十进制小数一样,准确的表示0.3这个数值。因此舍入这一部分是浮点数无法逃脱的内容。
浮点数舍入
在我们平时日常使用的十进制当中,我们一般对一个无理数或者有位数限制的有理数进行舍入时,大部分时候会采取四舍五入的方式,这算是一种比较符合我们期望的舍入方式。
不过针对浮点数来说,我们的舍入方式会更丰富一些。一共有四种方式,分别是向偶数舍入、向零舍入、向上舍入以及向下舍入。
这四种舍入方式都不难理解,其中向偶数舍入就是向最靠近的偶数舍入,比如将1.5舍入为2,将0.1舍入为0。而向零舍入则是向靠近零的值舍入,比如将1.5舍入为1,将0.1舍入为0。对于向上舍入来说,则是往大了(也就是向正无穷大)舍入的意思,比如将1.5舍入为2,将-1.5舍入为-1。而向下舍入则与向上舍入相反,是向较小的值(也就是向负无穷大)舍入的意思。
这里需要提一下的是,除了向偶数舍入以外,其它三种方式都会有明确的边界。这里的含义是指这三种方式舍入后的值x'与舍入之前的值x会有一个明确的大小关系,比如对于向上舍入来说,则一定有x <= x'。对于向零舍入来说,则一定有|x| >= |x'|。
对于向偶数舍入来讲,它最大的作用是在统计时使用。向偶数舍入可以让我们在统计时,将舍入产生的误差平均,从而尽可能的抵消。而其它三种方式在这方面都是有一定缺陷的,向上和向下舍入很明显,会造成值的偏大或偏小。而对于向零舍入来讲,如果全是正数的时候则会造成结果偏小,全是负数的时候则会造成结果偏大。
通常情况下我们采取的舍入规则是在原来的值是舍入值的中间值时,采取向偶数舍入,在二进制中,偶数我们认为是末尾为0的数。而倘若不是这种情况的话,则一般会有选择性的使用向上和向下舍入,但总是会向最接近的值舍入。其实这正是IEEE采取的默认的舍入方式,因为这种舍入方式总是企图向最近的值的舍入。
比如对于10.10011这个值来讲,当舍入到个位数时,会采取向上舍入,因此此时的值为11。当舍入到小数点后1位时,会采取向下舍入,因此此时的值为10.1。当舍入到小数点后4位时,由于此时为10.10011舍入值的中间值,因此采用向偶数舍入,此时舍入后的值为10.1010。
Java当中的浮点数舍入
之前我们讲解了一堆舍入的方式,最终我们给出一个结论,就是IEEE标准默认的舍入方式,是企图向最近的值舍入(Round to the Nearest Value)。
上面我们已经详细的解释了IEEE标准中默认的舍入方式(黑色加粗的那部分解释),但是估计还是会有不少猿友比较迷糊,书中也没有给出具体的例子,因此这里LZ以Java语言为例,我们直接写程序来看一下,看看Java当中的舍入方式是否是按照我们所说的进行的。
在各位看这个测试程序之前,LZ需要再给各位再解释一下中间值的概念。中间值就是指的,比如1.1(二进制)这个数字,假设要舍入到个位,那么它就是一个中间值,因为它处于1(二进制)和10(二进制)的中间,在这个时候将会采用向偶数舍入的方式。
下面便是LZ写的测试程序,其中那些具体的浮点数值是使用二进制小数的算法计算出来的,各位猿友不必在意,如果你不嫌麻烦,也可以自己手算一下。我们主要看的是最终的舍入情况。
public class Main{
public static void main(String[] args){
System.out.println("舍入前: 10.10011111111111111111101");
System.out.print("舍入后:");
printFloatBinaryString(2.62499964237213134765625f);
System.out.println();
System.out.println("舍入前: 10.10011111111111111111111");
System.out.print("舍入后:");
printFloatBinaryString(2.62499988079071044921875f);
System.out.println();
System.out.println("舍入前: 10.10011111111111111111101011");
System.out.print("舍入后:");
printFloatBinaryString(2.62499968707561492919921875f);
System.out.println();
System.out.println("舍入前: 10.10011111111111111111100011");
System.out.print("舍入后:");
printFloatBinaryString(2.62499956786632537841796875f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111101");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499964237213134765625f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111111");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499988079071044921875f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111101011");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499968707561492919921875f);
System.out.println();
System.out.println("舍入前: -10.10011111111111111111100011");
System.out.print("舍入后:");
printFloatBinaryString(-2.62499956786632537841796875f);
System.out.println();
}
public static void printFloatBinaryString(Float f){
char[] binaryChars = getBinaryChars(f);
for (int i = 0; i < binaryChars.length; i++) {
System.out.print(binaryChars[i]);
if (i == 0 || i == 8) {
System.out.print(" ");
}
}
System.out.println();
}
public static char[] getBinaryChars(Float f){
char[] result = new char[32];
char[] binaryChars = Integer.toBinaryString(Float.floatToIntBits(f)).toCharArray();
if (binaryChars.length < result.length) {
System.arraycopy(binaryChars, 0, result, result.length - binaryChars.length, binaryChars.length);
for (int i = 0; i < result.length - binaryChars.length; i++) {
result[i] = '0';
}
}else {
result = binaryChars;
}
return result;
}
}
上面是测试程序,其实程序中看不出什么,就是一堆输出语句。如果各位猿友有兴趣,也可以简单看一下程序的实现。不过我们主要还是看结果,下面是程序结果。
上面一共有8次舍入,前4次是正数,后4次是负数。可以看出对于正负数来讲,舍入后的位表示是一样的,只是最高位的符号位不同而已,因此这里LZ就不再分析下面4个负数的舍入方式了,我们主要来看前4次舍入。
第1次和第2次对于末尾01和11的舍入,由于是中间值,因此全部采取的向偶数舍入的方式,保证最低位为0。第3次由于比中间值大,而数值又是正数,因此采用向上舍入的方式。第4次则比中间值小,数值也同样是正数,因此采用向下舍入的方式。
由此可以看出,Java正是采用的我们所描述的方式进行舍入操作的,也就是总是企图朝最近的数值舍入。相对于其它语言,由于LZ主修Java,例子篇幅也比较长,因此这里就不写其他语言的例子了,有兴趣的猿友可以尝试写一下C/C++或者C#的例子来看一下,看是否是采用的同样的舍入方式。
浮点数运算
在IEEE标准中,制定了关于浮点数的运算规则,就是我们将把两个浮点数运算后的精确结果的舍入值,作为我们最终的运算结果。正是因为有了这一个特殊点,就会造成浮点数当中,很多运算不满足我们平时熟知的一些运算特性。
比如加法的结合律,也就是a + b + c = a + (b + c),这是很普通的加法运算的特性,但是浮点数是不满这一特性的,比如说下面这一段小程序。
public static void main(String[] args){
System.out.println(1f + 10000000000f - 10000000000f);
System.out.println(1f + (10000000000f - 10000000000f));
}
这一段程序会依次输出0.0和1.0,正是因为舍入而造成的这一误差。在第一个输出语句中,计算1f+10000000000f时,会将1这个有效数值舍入掉,而导致最终结果为0.0。而在第二个输出语句中10000000000f-10000000000f将先得到结果0.0,因此最终的结果为1.0。
相应的,浮点数运算对乘法也不满足结合律,也就是 a * b * c != a * (b * c),同时也不满足分配律,即 a * (b + c) != a * b + a * c。
浮点数失去了很多运算方面的特性,因此也导致很多优化手段无法进行,比如我们试图优化下面这样一段程序。
/* 优化前 */
float x = a + b + c;
float y = b + c + d;
/* 优化后 */
float t = b + c;
float x = a + t;
float y = t + d;
对于优化前的代码来讲,进行了4次浮点运算,而优化后则是3次。然而这种优化是编译器无法进行的,因为可能会引入误差,比如就像前面的小例子中的结果0和1一样。编译器在此时一般是不敢进行优化的,试想一下,如果是银行系统的汇款或者收款等功能,如果编译器进行优化的话,很可能一不小心就把别人的钱给优化掉了。
文章小结
2.X系列主要讲解了二进制的位表示方式、无符号以及补码编码以及二进制整数和浮点数的表示方式和运算。这一章是2.X的最后一章,下一章我们将进入汇编语言3.X的世界,那里我们可以看到程序是如何使用寄存器和存储器的、如何表示C语言中的指针、汇编语言如何实现程序的流程控制等等一系列内容。相对来讲,3.X的内容会比2.X的内容有意思很多,因此希望各位猿友不要错过。