妥协与取舍,解构C#中的小数运算

题外话

正文开始之前,我首先要感谢博客园提供的这个优秀的平台。通过在这个优秀的平台上和很多志同道合的朋友交流,互相帮助,我也很荣幸的获得了15年的微软MVP的奖项。也使我更加坚信了代码改变世界。感激!感恩!努力!加油!

0x00 前言

慕容在生活和工作中常常会遇到一些十分迷信机器的人,他们之中很多人都相信机器是最理智的,没有任何感情,是真正的铁面无私,因此机器的运算所给出的答案总是正确的,如果答案错误,那么一定是操作机器的人的问题。但机器的运算就一定是正确的吗?事实上,机器出现运算错误并不是一个罕见的情况,一个典型的例子便是小数运算。下面就让我们来聊一个相关的话题,在机器或者具体的说在C#语言中小数是如何被处理的?

0x01 先从一个“错误”的答案说起

 既然要聊一聊机器是怎么把算术题做错的,那么我们自然要先来看一个机器运算错误的小例子。

#include <stdio.h>

void main(){
    float num;
    int i ;
    num = 0;
    for(i = 0; i < 100; i++)
    {
        num += 0.1;
    }
    printf("%f\n", num);
}

这是一份C语言写成的小程序,逻辑十分简单易懂,所要实现的结果无非是将0.1相加100次之后再输出。我想不需要计算机来计算,我们自己心算就能立刻得出答案——10。那么计算机会交给我们一份什么样的答案呢?下面我们将这份C代码编译并且运行一下。

答案一输出,就让人大吃一惊。怎么计算机还不如人的心算吗?如果按照慕容在前言中提到的那些朋友这时可能就开始纠结是否是代码写错了,亦或者是慕容的电脑出现了什么问题。但事实上代码是正确的,机器也是运行如常的。那么究竟是为什么计算机的运算输给了人的心算呢?这就引出了下一个问题,计算机是如何处理小数的呢?如果我们了解一下计算机处理小数的机制,那么这一切的迷就能够解开了。(当然如果有朋友用C#来对0.1加100次,之后的结果是10。但那是C#在幕后为我们做的一些隐藏工作,在计算机处理小数的问题上,本质是一样的)。

0x02 数字的格式

一个程序可以看做是现实世界的一个数字化的模型。现实世界中的一切都可以转化为数字在计算机的世界中重新复活。因此,一个不得不解决的问题就是数字是如何在计算机中表达的。这也是数字格式出现的意义。

众所周知,机器语言全部都是数字,但是本文自然不会关心全部的二进制格式。这里我们只关心现实中有意义的数字是如何在计算机中被表示的。简单而言,有意义的数字大体可以分为以下三种格式。

整数格式

我们在开发的过程中遇到的大部分的数字其实都是整数。而整数在计算机中也是最容易表示的一种。我们遇到的整数都可以使用32位有符号整数来表示(Int32)。当然,如果需要,还有有符号 64 位整数数据类型(Int64)可供选择。至于和整数相对应的便是小数,而小数主要有两种表示方式。

定点格式

所谓定点格式,即约定机器中所有数据的小数点位置是固定不变的。而定点小数的最常见的例子是SQL Server中的money类型。事实上定点小数已经很不错了,它显然能够适合很多需要处理小数的情况。但是它有一个与生俱来的缺点,那就是由于小数点的位置固定,因此它能表示范围是受限的。因此下面我们本文的主角就要出场了。

浮点格式

解决定点格式先天问题的方案便是浮点格式的出现。而浮点格式的组成则包括符号、尾数、基数和指数,通过这四部分来表示一个小数。由于计算机内部是二进制的,因此基数自然而然是2(就如十进制的基数是10一样)。因此计算机在数据中往往无需记录基数(因为总是2),而是只用符号、尾数、指数这三部分来表示。很多编程语言都至少提供了两种使用浮点格式表示小数的数据类型,即我们常常能见到的双精度浮点数double和单精度浮点数float。同样,在我们的C#语言中也存在着这两种使用了浮点格式表示小数的数据类型——按照C#语言标准双精度浮点数和单精度浮点数在C#中对应的是System.Double和System.Single。但是事实上在C#语言中还存在着第三种使用了浮点格式表示小数的数据类型,那就是decimal类型——System.Decimal。需要注意的是,浮点格式的表示形式有很多,而在C#中遵循的是IEEE 754标准:

  • float单精度浮点数为32位。32位的构造为:符号部分1bit、指数部分8bit以及尾数部分23bit。
  • double双精度浮点数为64位。64位的构造为:符号部分1bit、指数部分11bit以及尾数部分52bit。

0x03 表示范围、精度和准确度

既然聊完了数字在计算机中的几种表示形式,那么接下来我们就不得不提一下在选择数字格式时的一些指标。最常见的无非是这几点:表示范围、精度、准确度。

数字格式的表示范围

顾名思义,数字格式的表示范围指的就是这种数字格式所能表示的最小的值最大的值范围。例如一个16位有符号整数的表示范围是从-32768到32767。如果要被表示的数字的值超出了这个范围,那么使用这种数字格式就不能正确的表示这个数字了。当然在这个范围内的数字也有可能无法被正确的表示,例如16位有符号整数是无法准确表示一个小数的,但是总有一个接近的值是可以用16为有符号整数格式来表示的。

数字格式的精度

实话实说,精度和准确度让很多人都有一种十分模糊的感觉,似乎是一样的却又有区别。但慕容需要提醒各位注意的是,精度和准确度是两个有巨大差距的概念。

通俗的来讲,数字格式的精度可以认为是该格式有多少信息用来表示一个数字。更高的精度通常意味着能够表示更多的数字,一个最明显的例子便是精度越高那么这种格式所能表示的数字就越接近真实的数字。例如我们知道1/3如果换算成小数0.3333....是无穷尽的,那么它在五位精度的情况下可以写成0.3333,而在七位的情况下就又变成了0.333333(当然,如果用七位表示五位,那么就是0.333300)。

数字格式的精度还会影响到计算的过程。举一个简单的例子,如果在计算中我们使用的是一位精度。那么整个计算可能就变成了下面的这种情况:

0.5 * 0.5 + 0.5 * 0.5 = 0.25 + 0.25

                                = 0.2 + 0.2

                                =0.4

而如果我们使用的是两位精度,那么计算过程又会变成下面的情况。

0.5 * 0.5 + 0.5*0.5 = 0.25 + 0.25

           =0.5

对比两种精度情况下的计算结果,一位精度情况下的计算结果和正确的结果差了0.1。而使用了两位精度的情况则正常的计算出了结果。因此可以发现在计算的过程中保证精度是一件多么有意义的事情。

数字格式的准确度

数字格式的表示范围、精度都已经介绍完了,那么接下来我们就来介绍一下数字格式的准确度。刚刚已经说过了,准确度和精度是一对经常让人混淆的概念。

那么我们再通俗的给准确度来个注释,简单的说它表示的是该数字格式(特定环境)所表示的数字和真实数字的误差。准确度越高,则意味着数字格式所表示的数字和真实数字的值之间的误差越小。准确度越低,则意味着数字格式所表示的数字和真实数字的值之间的误差越大。

需要注意的一点是数字格式的精度和数字格式的准确度并没有直接的关系,这一点也是很多朋友在概念上常常会混淆的地方。使用低精度的数字格式表示的数字,并不一定要比使用高精度的数字格式所表示的数字的准确度低。

举一个简单的例子:

Byte   num = 0x05;
Int16  num1 = 0x0005;
Int32  num2 = 0x00000005;
Single num3 = 5.000000f;
Double num4 = 5.000000000000000;

此时,我们分别使用5种不同的数字格式表示同一个数字5,虽然数字格式的精度(从8位到64位)不同,但是通过数字格式所表示出来的数和真实的数是一样的。也就是说对于数字5,这5种数字格式的准确度相同。

0x04 取整误差

了解了计算机中常见的几种数字格式之后,现在我们再来聊一聊计算机是如何通过数字格式来表示现实世界中的数字的。众所周知,计算机中使用的是0和1,即二进制,使用二进制表示整数是十分容易的一件事情,不过在使用二进制表示小数时,我们往往会产生一些疑问。例如二进制小数1110.1101换算成十进制是多少呢?第一眼看上去多了一个小数点,似乎让人十分困惑。事实上它的处理和整数是一样的,即将各个数位的数值和位权相乘结果求和。小数点前的位权,大家都已经十分熟悉了,从右向左分别是0次幂、1次幂、2次幂以此递增,因此小数点前的二进制换算为十进制便是:

1 * 8 + 1 * 4 + 1 * 2 + 0 = 14

而在小数点之后的位权,相应的从左向右分别是-1次幂、-2次幂依次递减。因此小数点之后的二进制转换为十进制便是:

1 * 0.5 + 1 * 0.25 + 0 * 0.125 + 1 * 0.0625 = 0.8125

因此1110.1101这个二进制小数转换为十进制便是14.8125。

通过观察小数点之后的二进制转换为十进制的过程,各位看官是否发现了很有趣的一个事实呢?那就是小数点之后的二进制并不能表示所有的十进制数,换言之有一些十进制数是无法转换成二进制的。这个很好理解,因为小数点之后,二进制的位权按照除以2的节奏递减,而十进制却是按照除以10的节奏递减。因此如果小数点后4位用二进制表示,即从.0000~.1111这个范围内连续的二进制数值事实上对应的十进制数是不连续的,所有可能的结果也不过是各个位权(0.5、0.25、0.125以及0.0625)相加的组合而已。

因此,一个在十进制中十分简单的数字如果用二进制来准确无误的表示,所使用的位数可能会十分长甚至是无限的。一个很好的例子便是使用二进制浮点数来表示十进制中的0.1:

double x = 0.1d;

事实上变量x中所保存的值并不是真正的十进制中的0.1,而是一个最接近十进制0.1的二进制浮点数。这是因为无论小数点之后有多少位二进制的数,2的负数次幂都无法相加得到0.1这个结果,因此0.1这个十进制数在二进制中会变成一个无限小数。

当然二进制有可能无法准确的表示一个十进制小数很好理解,因为这有点类似于在十进制中我们同样无法准确表示1/3这样的循环小数。

此时,我们便不得不和计算机妥协了。因为我们现在知道了在计算机中使用的数值可能并不等于真实世界中的数值,而是计算机使用某种数字格式表示的一个十分接近原始数字的一个值。而在整个程序运行的过程中,我们的计算机就要一直使用这个仅仅是近似的数值来参与计算,我们假设真实的数值是n,而计算机事实上会使用另一个数值n + e(当然e是一个可正可负且十分小的数)来参与计算机中的运算。此时,这个数值e便是取整误差。

而这还仅仅是一个数字在计算机中使用近似值来表示,如果该数值参与到计算中去,那么显然会带来更多误差。这也是本文一开始那个c程序之所以计算错误的原因,因为无法正确的表示参与计算的值,到最后都变成了近似值。当然C#语言相对而言要“高级”了很多,虽然在计算机中也是近似值,但是展示在大家眼前的至少还是更加符合人们“预期”的值。不过在C#中,小数计算真的是不会出错的吗?毕竟,这一切似乎仅仅是障眼法。

0x05 取与舍,C#的小数

比比是否相等

不知道各位看官在使用一些关系运算符时,有没有留意到直接使用等号比较两个小数是否相等时是否会出现一些意想不到的问题。我身边的朋友使用关系运算符直接比较两个小数大小的情况比较多,而直接比较两个小数是否相等的情况却不太多。同时我在此也想提醒各位最好不要轻易比较两个小数是否相等,即便在C#这种高级语言中仍然可能得到让人感觉“错误”的答案,这是因为我们事实上比较的是两个小数是否“接近”于相等,而不是两个数是否是真正的相等。下面这个例子可能会更好的说明这一点:

using System;

class Test
{    
    static void Main()
    {
        double f = Sum (0.1d, 0.2d);
        double g = 0.3d;
        Console.WriteLine (f);
        Console.WriteLine (f==g);
    }
    
    static double Sum (double f1, double f2)
    {
        return f1+f2;
    }
}

我们编译并且运行这段代码,可以看到输出了如下的内容:

比较这两个小数的结果并不是true,这和我们的预期并不一样。

浮点数的真模样

我们知道,像上文中的那个二进制小数1110.1101事实上也是按照人类习惯表达出来的,但是计算机可是不能识别这种带小数点的东西的哦。所以计算机会使用之前介绍的数字格式来表示这样一个数字,那么一个二进制浮点数在计算机中到底是如何表现的呢?其实在上文介绍数字格式的部分已经介绍过了,但是没有实际看一眼终究是不能有一个直观的认识,那在本文的最后,我们就来看一个二进制浮点数的在计算机中真实的样子。

0100000001000111101101101101001001001000010101110011000100100011

这是一个64位的二进制数。如果把它作为一个双精度浮点数,那么它的各部分都分别表示了什么呢?

按照上文介绍浮点数的部分,我们可以将它分成如下几部分:

符号:0

指数部分:10000000100(二进制,可以转换为十进制的1028)

尾数部分:0111101101101101001001001000010101110011000100100011

因此,将它转换为一个用二进制表示的小数,则是:

(-1)^0 * 1.0111101101101101001001001000010101110011000100100011  x 2^(1028-1023)

= 1.0111101101101101001001001000010101110011000100100011  x 2^5

= 101111.01101101101001001001000010101110011000100100011

如果各位读者观察足够仔细的话,是否发现了有趣的一点呢?那就是在这个在计算机中用来表示双精度浮点数的64位数中,尾数部分的几位数字是:0111101101101101001001001000010101110011000100100011

但是经过从计算机中的形式转化成人类使用二进制表示小数的形式之后,数字却变成了1.0111101101101101001001001000010101110011000100100011x 2^5,小数点之前为什么会多出了一个1呢?

这是因为在尾数部分,为了将表现形式多样的浮点数统一为同一种表示方式而规定要将小数点前的值固定为1。由于小数点前的数永远是1,因此为了节省一个数据位,这个1在计算机中并不需要被保存。

那么应该如何保证一个二进制小数的小数点前的值是1呢?这就需要对二进制小数进行逻辑移位了,通过左移或右移若干次后,将整数部分变为1。例如上文中的这个二进制小数:1110.1101,我们就来试试如何把它变成计算机可以识别的浮点数的尾数吧。

1110.1101(原始数据)——>0001.1101101(通过右移将整数部分变为1)——>0001.11011010000000000000....(拓展位数,使之符合数字格式的规定)——>11011010000000000000....(去掉整数部分,仅保留小数部分)

好了,到此关于C#或者计算机中的小数计算就写得差不多了。欢迎各位交流。

posted @ 2015-10-25 09:01  慕容小匹夫  阅读(8879)  评论(19编辑  收藏  举报