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

0x00 前言

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

0x01 先从0.1和二进制浮点数说起

私底下有一些朋友告诉我说在上一篇文章中如果只是单纯的说十进制中的0.1无法使用二进制准确的表示,虽然理论上的确是这样,但毕竟没有通过直接观察获得一个直观的印象,所以在正式引出decimal之前,我们先来看一看一个十进制的小数0.1为何不能被二进制浮点数准确的表示出来吧。
如同在十进制中,1/3是无法被准确表示的,如果我们要将1/3转换成十进制小数的形式则是:
1/3 = 0.3333333....(3循环)
同理,十进制小数0.1也是无法被二进制小数准确表示,如果我们要将十进制的0.1转换为二进制小数则是:
0.1 = 0.00011001100....(1100循环)
我们可以看到,如果要将十进制的0.1转换为二进制小数,则会出现1100循环的状况。因此根据我在上一篇文章中提到过的IEEE 754标准以及在上一篇文章中最后所举的一个例子,我们首先将0.00011001100....进行逻辑移位,使之小数点左边第一位是1。那么结果是1.10011001100...,共移动了4位,因此指数相应的应该是-4。所以,表示十进制0.1的float二进制浮点数的结果如下:
符号位:0(表示正数)
指数部分:01111011(01111011换算成十进制是123,因为要减去-127故结果为-4)
尾数部分:10011001100110011001101(即通过移位之后,舍掉小数点左侧的1,留下的小数部分,保留23位)
那么这个用来“表示”十进制小数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.把小数转换成整数来计算:既然计算机使用二进制进行小数计算时可能会有误差,但是计算整数时一般是没有问题的。因此,进行小数计算时可以暂时借助整数,只不过把最后的结果使用小数来表示便可以了。