【慕容小匹夫】匹夫细说C#:没有神话,聊聊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 = 0.3333333....(3循环)。同理,十进制小数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,但由于其表示的十进制数字有效位数在28或29(取决于最高位的值是否在7以内)的范围内,所以表示小数时对小数位数有限制。
decimal内部的4个整数
再看decimal的结构,128位中只有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#中的小数运算》,可以总结计算机中减小小数误差的策略主要有以下两个方面:
- 回避策略:根据程序目的,有时一些误差是可以接受的。误差在可允许范围内在日常生活中也很常见。
- 把小数转换成整数来计算:由于计算机用二进制进行小数计算可能有误差,但计算整数一般没问题。所以进行小数计算时可暂时借助整数,最后将结果用小数表示。