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[], bits[], bits[], bits[]);
}
}
}

我对这段代码进行编译并运行的结果如下图:

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

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

比如下面的这个小例子:

//没有注意有效位数而产生的错误
using System; class Test
{
static void Main()
{
var input = 1.1111111111111111111111111111m;
for (int i = ; i < ; i++)
{
decimal output = input * (decimal) i;
Console.WriteLine(output);
}
}
}

我们来编译运行它:

可以发现7以内的结果都是正确的,而最后乘以8和乘以9的部分却出现了错误。而产生这个结果的原因,其实我在上文中已经不止一次的提到过,那便是在29位有效数字情况下,最高位的值不能超过7才能获得准确的值。而乘以8和乘以9显然不符合这种要求。

因此,结合我的上一篇文章《妥协与取舍,解构C#中的小数运算》,我们可以总结一下计算机中用来减小小数误差的策略无非以下两个方面:

1.回避策略:即无视这些错误,根据程序目的的不同,有的时候一些误差是可以接受的。这也是很好理解的,误差在一个可以允许的范围内也是普遍存在于日常生活的中的。

2.把小数转换成整数来计算:既然计算机使用二进制进行小数计算时可能会有误差,但是计算整数时一般是没有问题的。因此,进行小数计算时可以暂时借助整数,只不过把最后的结果使用小数来表示便可以了。

没有神话,聊聊decimal的“障眼法”的更多相关文章

  1. 聊聊数据库~3.SQL基础篇

    上篇回顾:聊聊数据库~SQL环境篇 扩展:为用户添加新数据库的权限 PS:先使用root创建数据库,然后再授权grant all privileges on 数据库.* to 用户名@"%& ...

  2. 聊聊 C# 和 C++ 中的 泛型模板 底层玩法

    最近在看 C++ 的方法和类模板,我就在想 C# 中也是有这个概念的,不过叫法不一样,人家叫模板,我们叫泛型,哈哈,有点意思,这一篇我们来聊聊它们底层是怎么玩的? 一:C++ 中的模板玩法 毕竟 C+ ...

  3. 聊聊Unity项目管理的那些事:Git-flow和Unity

    0x00 前言 目前所在的团队实行敏捷开发已经有了一段时间了.敏捷开发中重要的一个话题便是如何对项目进行恰当的版本管理.项目从最初使用svn到之后的Git One Track策略再到现在的GitFlo ...

  4. Mono为何能跨平台?聊聊CIL(MSIL)

    前言: 其实小匹夫在U3D的开发中一直对U3D的跨平台能力很好奇.到底是什么原理使得U3D可以跨平台呢?后来发现了Mono的作用,并进一步了解到了CIL的存在.所以,作为一个对Unity3D跨平台能力 ...

  5. fir.im Weekly - 聊聊 Google 开发者大会

    中国互联网的三大错觉:索尼倒闭,诺基亚崛起,谷歌重返中国.12月8日,2016 Google 开发者大会正式发布了Google Developers 中国网站 ,包含了Android Develope ...

  6. 【.net 深呼吸】聊聊WCF服务返回XML或JSON格式数据

    有时候,为了让数据可以“跨国经营”,尤其是HTTP Web有关的东东,会将数据内容以 XML 或 JSON 的格式返回,这样一来,不管客户端平台是四大文明古国,还是处于蒙昧时代的原始部落,都可以使用这 ...

  7. 聊聊asp.net中Web Api的使用

    扯淡 随着app应用的崛起,后端服务开发的也越来越多,除了很多优秀的nodejs框架之外,微软当然也会在这个方面提供更便捷的开发方式.这是微软一贯的作风,如果从开发的便捷性来说的话微软是当之无愧的老大 ...

  8. 聊聊 C 语言中的 sizeof 运算

    聊聊 sizeof 运算 在这两次的课上,同学们已经学到了数组了.下面几节课,应该就会学习到指针.这个速度的确是很快的. 对于同学们来说,暂时应该也有些概念理解起来可能会比较的吃力. 先说一个概念叫内 ...

  9. 聊聊 Apache 开源协议

    摘要 用一句话概括 Apache License 就是,你可以用这代码,但是如果开源你必须保留我写的声明:你可以改我的代码,但是如果开源你必须写清楚你改了哪些:你可以加新的协议要求,但不能与我所 公布 ...

随机推荐

  1. HttpClient的替代者 - RestTemplate

    需要的包 ,除了Spring的基础包外还用到json的包,这里的数据传输使用json格式 客户端和服务端都用到一下的包 <!-- Spring --> <dependency> ...

  2. 在一个空ASP.NET Web项目上创建一个ASP.NET Web API 2.0应用

    由于ASP.NET Web API具有与ASP.NET MVC类似的编程方式,再加上目前市面上专门介绍ASP.NET Web API 的书籍少之又少(我们看到的相关内容往往是某本介绍ASP.NET M ...

  3. web前端基础知识

    #HTML    什么是HTML,和他ML...    网页可以比作一个装修好了的,可以娶媳妇的房子.    房子分为:毛坯房,精装修    毛坯房的修建: 砖,瓦,水泥,石头,石子....    精 ...

  4. CSS HTML元素布局及Display属性

    本篇文章主要介绍HTML的内联元素.块级元素的分类与布局,以及dispaly属性对布局的影响. 目录 1. HTML 元素分类:介绍内联元素.块级元素的分类. 2. HTML 元素布局:介绍内联元素. ...

  5. nodejs创建http服务器

    之前有简单介绍nodejs的一篇文章(http://www.cnblogs.com/fangsmile/p/6226044.html) HTTP服务器 Node内建有一个模块,利用它可以很容易创建基本 ...

  6. 23种设计模式--建造者模式-Builder Pattern

    一.建造模式的介绍       建造者模式就是将零件组装成一个整体,用官方一点的话来讲就是将一个复杂的构建与其表示相分离,使得同样的构建过程可以创建不同的表示.生活中比如说组装电脑,汽车等等这些都是建 ...

  7. 你真的会玩SQL吗?之逻辑查询处理阶段

    你真的会玩SQL吗?系列目录 你真的会玩SQL吗?之逻辑查询处理阶段 你真的会玩SQL吗?和平大使 内连接.外连接 你真的会玩SQL吗?三范式.数据完整性 你真的会玩SQL吗?查询指定节点及其所有父节 ...

  8. java时间

    Calendar.getInstance().getTime() 获取当前时间(包括星期和时区 CST China Standard Time):  Fri Jan 06 21:03:36 CST 2 ...

  9. Java程序:从命令行接收多个数字,求和并输出结果

    一.设计思想:由于命令行接收的是字符串类型,因此应先将字符串类型转化为整型或其他字符型,然后利用for循环求和并输出结果 二.程序流程图: 三.源程序代码:   //王荣荣 2016/9/23     ...

  10. 看图理解JWT如何用于单点登录

    单点登录是我比较喜欢的一个技术解决方案,一方面他能够提高产品使用的便利性,另一方面他分离了各个应用都需要的登录服务,对性能以及工作量都有好处.自从上次研究过JWT如何应用于会话管理,加之以前的项目中也 ...