4

深入理解计算机系统(2.8)---浮点数的舍入,Java中的舍入例子以及浮点数运算(重要)

 2 years ago
source link: https://www.cnblogs.com/zuoxiaolong/p/computer12.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

深入理解计算机系统(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的内容有意思很多,因此希望各位猿友不要错过。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK