没有神话,剖析decimal的“障眼法”

0x00 前言

在上一篇文章《妥协与取舍,解构C#中的小数运算》的留言区域有不少朋友都不约而同的说道了C#中的decimal类型。事实上以前的那篇文章的立意主要在于聊聊使用二进制的计算机是如何处理小数的,无非我接触最多的是在托管环境下运行的高级语言C#,所以顺带使用了C#做为例子。一方面说明了计算机处理小数的本质,也起到了提醒各位更加关注本质而非高级语言表象的做用。固然,那篇文章中主要提到的是二进制浮点数double和float(即System.Double和System.Single,下文中使用double和float来分别指代这两个类型)。不过既然说到障眼法,我以为仍是有必要写一篇文章专门来聊聊decimal类型,也算是对留言提到decimal的朋友的统一回复。html

0x01 先从0.1和二进制浮点数提及

私底下有一些朋友告诉我说在上一篇文章中若是只是单纯的说十进制中的0.1没法使用二进制准确的表示,虽然理论上的确是这样,但毕竟没有经过直接观察得到一个直观的印象,因此在正式引出decimal以前,咱们先来看一看一个十进制的小数0.1为什么不能被二进制浮点数准确的表示出来吧。segmentfault

如同在十进制中,1/3是没法被准确表示的,若是咱们要将1/3转换成十进制小数的形式则是:数组

1/3 = 0.3333333....(3循环)spa

同理,十进制小数0.1也是没法被二进制小数准确表示,若是咱们要将十进制的0.1转换为二进制小数则是:code

0.1 = 0.00011001100....(1100循环)htm

咱们能够看到,若是要将十进制的0.1转换为二进制小数,则会出现1100循环的情况。所以根据我在上一篇文章中提到过的IEEE 754标准以及在上一篇文章中最后所举的一个例子,咱们首先将0.00011001100....进行逻辑移位,使之小数点左边第一位是1。那么结果是1.10011001100...,共移动了4位,所以指数相应的应该是-4。因此,表示十进制0.1的float二进制浮点数的结果以下:blog

符号位:0(表示正数)图片

指数部分:01111011(01111011换算成十进制是123,由于要减去-127故结果为-4)ci

尾数部分:10011001100110011001101(即经过移位以后,舍掉小数点左侧的1,留下的小数部分,保留23位)get

那么这个用来“表示”十进制小数0.1的float二进制浮点数若是换算成十进制数究竟是多少呢?它和0.1到底有多大的偏差呢?下面咱们就来换算一下:

指数部分:2^(-4) = 1/16

尾数部分:1 + 1/2 + 1/16 + 1/32 + 1/256 + 1/512 + 1/4096 + 1/8192 + 1/65536 + 1/131072 + 1/1048576 + 1/2097152 + 1/8388608 = 1.60000002384185791015625 (在换算成float时会把小数点左侧的1省略,这里须要再次加回来)

那么,换算以后实际的十进制数即是:1.60000002384185791015625 * 1/16 = 0.100000001490116119384765625

因此咱们能够看到,二进制浮点数并不能准确的表示0.1这个十进制小数,它使用了0.100000001490116119384765625来代替0.1。

这即是直接使用二进制来表示小数的方式,颇有可能会产生偏差。

0x02 decimal的障眼法

可是不少朋友都提到了使用decimal来避免上文中出现的偏差。的确,使用decimal是一个十分保险的措施。可是,为何使用decimal类型,计算机忽然就可以很完美的计算十进制数了呢?难道是计算机在涉及到decimal类型的运算时,改变了本身内部最根本的二进制运算吗?

固然不是。

我在上一篇文章中提到过

“众所周知,计算机中使用的是0和1,即二进制,使用二进制表示整数是十分容易的一件事情”。

那么是否有可能间接借助整数来表示小数呢?由于二进制表示十进制整数是十分完美的。

答案的确如此。可是在咱们讨论decimal的细节以前,我以为有必要先简单介绍一下decimal。

在这里的decimal指的C#语言中的System.Decimal,虽然在C#语言规范中只提到了两种浮点数float和double(二进制浮点数),可是若是咱们了解浮点数的定义,decimal显然也是浮点数——只不过它的底数是10,所以它是十进制浮点数。

decimal的结构

一样,decimal和float以及double的组成也十分相似:符号位、指数部分以及尾数部分。

固然,decimal有更多的位,总共达到了128位,换句话说它又16个字节。若是咱们把这16个字节划分红4个部分,就能够一窥它的组成结构了。

下面使用m表示尾数部分、e表示指数部分、s表示符号位:

1~4号字节: mmmm mmmm mmmm mmmm mmmm mmmm mmmm mmmm

5~8号字节: mmmm mmmm mmmm mmmm mmmm mmmm mmmm mmmm

9~12号字节: mmmm mmmm mmmm mmmm mmmm mmmm mmmm mmmm

13~16号字节: 0000 0000 0000 0000 000e eeee 0000 000s

从它的组成结构,咱们能够看到decimal的尾数部分有96位(12字节),而指数部分有效的只有5位,符号位天然只有1位。

decimal的尾数

如今让咱们把思路拉回本小节一开始的部分,若是经过借助整数来表示小数的方式,decimal即可以更准确的来表示一个十进制小数了。这里咱们就能够看到,decimal的尾数部分事实上是一个整数,而尾数所表示的范围也很明确了:0~2^96 - 1。换算为十进制即是0~79228162514264337593543950335,一个29位的数字(固然,最高位的值最多到7)。

此时若是咱们对尾数部分进一步划分结构的话,能够将尾数当作是由三个部分的整数组成的:

1~4号字节(32位)表明了一个整数,表示的尾数的低位部分。

5~8号字节(32位)表明了一个整数,表示的尾数的中间部分。

9~12号字节(32位)表明了一个整数,表示尾数的高位部分。

这样,咱们就将表示一个整数的decimal尾数又划分红了三个整数。

decimal的指数和符号

值得一提的还有指数部分,首先它也是一个整数,可是若是咱们进一步观察decimal的结构的话,还能够发现指数部分的形式(000e eeee)很奇怪只有5位是有效的,这是由于它的最大值只能到28。至于为什么要这样处理,缘由其实很简单,decimal指数部分的底数是10,而尾数部分表示的是一个29位或者28位的整数(之因此这样说是因为最高位29的值其实只能到7,因此总共只有28位的值是能够任意设置的)。那么就假设咱们有一个28位的十进制整数,这28个位置上的值能够是0~9之中任何一个数,此时decimal的指数部分控制的即是咱们要在这个28位整数的哪一位点上小数点。

固然,还须要提醒各位读者注意的一点即是decimal的指数部分表示的负指数幂,也就是说decimal所表示的值实际上是以下的样子:

符号 * 尾数 / 10 ^指数

所以,decimal能正确表示的数字范围位是-/+79228162514264337593543950335,可是也正是因为decimal能够表示的十进制数字的有效位数也在28或29(取决于最高位的值是否在7之内)的范围内,所以在表示小数的时候,对小数的位数也是有限制的。

decimal内部的4个整数

咱们再回去看一眼decimal的结构,能够发现实际上128位中只有102位是必须的,除了这有意义的102位以外,其他的位的值是0。而这102位咱们能够进一步把它分红4个整数,这即是咱们在调用decimal.GetBits(value)方法时,返回的包含了4个元素的int型数组:

其中前3个int型整数在上文我已经说过,它们用来表示尾数的低位部分中间部分以及高位部分。

最后的1个int型整数用来表示指数和符号部分。该int型整数中的0~15位并无使用,而是所有设为0;16~23位用来表示指数,固然因为指数最大值是28所以只有其中的5位有效;24~30位一样没有使用,而是所有设为0;最后一位存放的即是符号位,0表明正数,1表明负数。

下面我就来给各位举一个例子:

//获取decimal的组成结构
using System;
using System.Collections.Generic;

class Test
{    
    static void Main()
    {
        decimal[] vals = {1.111111m, -1.111111m};

        Console.WriteLine("{0,31}  {1,10:X8}{2,10:X8}{3,10:X8}{4,10:X8}", 
                        "Argument", "Bits[3]", "Bits[2]", "Bits[1]", 
                        "Bits[0]" );
          Console.WriteLine( "{0,31}  {1,10:X8}{2,10:X8}{3,10:X8}{4,10:X8}", 
                         "--------", "-------", "-------", "-------", 
                         "-------" );
          foreach(decimal val in vals)
          {
              int[] bits = decimal.GetBits(val);
            Console.WriteLine("{0,31}  {1,10:X8}{2,10:X8}{3,10:X8}{4,10:X8}",  val, bits[3], bits[2], bits[1], bits[0]);
        }
    }
}

我对这段代码进行编译并运行的结果以下图:
此处输入图片的描述

0x03 如何才能避免“出错”

经过上一段文字,我相信各位读者应该已经发现了decimal其实并不神秘。也所以更加坚决了采用decimal来进行小数计算时必定会获得正确答案的信心。可是正如我在上文中所说的,decimal虽然提升了计算的准确度,可是它的有效位数也是有限的。尤为是在表示小数时,若是位数超过了它的有效位数,那么可能会获得“错误”的答案。

好比下面的这个小例子:

//没有注意有效位数而产生的错误

using System;

class Test
{
    static void Main()
    {
        var input = 1.1111111111111111111111111111m;
        for (int i = 1; i < 10; i++)
        {
            decimal output = input * (decimal) i;
            Console.WriteLine(output);
        }
    }
}

咱们来编译运行它:
此处输入图片的描述
能够发现7之内的结果都是正确的,而最后乘以8和乘以9的部分却出现了错误。而产生这个结果的缘由,其实我在上文中已经不止一次的提到过,那即是在29位有效数字状况下,最高位的值不能超过7才能得到准确的值。而乘以8和乘以9显然不符合这种要求。

所以,结合个人上一篇文章《妥协与取舍,解构C#中的小数运算》,咱们能够总结一下计算机中用来减少小数偏差的策略无非如下两个方面:

  1. 回避策略:即无视这些错误,根据程序目的的不一样,有的时候一些偏差是能够接受的。这也是很好理解的,偏差在一个能够容许的范围内也是广泛存在于平常生活的中的。

  2. 把小数转换成整数来计算:既然计算机使用二进制进行小数计算时可能会有偏差,可是计算整数时通常是没有问题的。所以,进行小数计算时能够暂时借助整数,只不过把最后的结果使用小数来表示即可以了。

相关文章
相关标签/搜索