没有神话,聊聊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[], 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的“障眼法”的更多相关文章
- 聊聊数据库~3.SQL基础篇
上篇回顾:聊聊数据库~SQL环境篇 扩展:为用户添加新数据库的权限 PS:先使用root创建数据库,然后再授权grant all privileges on 数据库.* to 用户名@"%& ...
- 聊聊 C# 和 C++ 中的 泛型模板 底层玩法
最近在看 C++ 的方法和类模板,我就在想 C# 中也是有这个概念的,不过叫法不一样,人家叫模板,我们叫泛型,哈哈,有点意思,这一篇我们来聊聊它们底层是怎么玩的? 一:C++ 中的模板玩法 毕竟 C+ ...
- 聊聊Unity项目管理的那些事:Git-flow和Unity
0x00 前言 目前所在的团队实行敏捷开发已经有了一段时间了.敏捷开发中重要的一个话题便是如何对项目进行恰当的版本管理.项目从最初使用svn到之后的Git One Track策略再到现在的GitFlo ...
- Mono为何能跨平台?聊聊CIL(MSIL)
前言: 其实小匹夫在U3D的开发中一直对U3D的跨平台能力很好奇.到底是什么原理使得U3D可以跨平台呢?后来发现了Mono的作用,并进一步了解到了CIL的存在.所以,作为一个对Unity3D跨平台能力 ...
- fir.im Weekly - 聊聊 Google 开发者大会
中国互联网的三大错觉:索尼倒闭,诺基亚崛起,谷歌重返中国.12月8日,2016 Google 开发者大会正式发布了Google Developers 中国网站 ,包含了Android Develope ...
- 【.net 深呼吸】聊聊WCF服务返回XML或JSON格式数据
有时候,为了让数据可以“跨国经营”,尤其是HTTP Web有关的东东,会将数据内容以 XML 或 JSON 的格式返回,这样一来,不管客户端平台是四大文明古国,还是处于蒙昧时代的原始部落,都可以使用这 ...
- 聊聊asp.net中Web Api的使用
扯淡 随着app应用的崛起,后端服务开发的也越来越多,除了很多优秀的nodejs框架之外,微软当然也会在这个方面提供更便捷的开发方式.这是微软一贯的作风,如果从开发的便捷性来说的话微软是当之无愧的老大 ...
- 聊聊 C 语言中的 sizeof 运算
聊聊 sizeof 运算 在这两次的课上,同学们已经学到了数组了.下面几节课,应该就会学习到指针.这个速度的确是很快的. 对于同学们来说,暂时应该也有些概念理解起来可能会比较的吃力. 先说一个概念叫内 ...
- 聊聊 Apache 开源协议
摘要 用一句话概括 Apache License 就是,你可以用这代码,但是如果开源你必须保留我写的声明:你可以改我的代码,但是如果开源你必须写清楚你改了哪些:你可以加新的协议要求,但不能与我所 公布 ...
随机推荐
- 前端学Markdown
前面的话 我个人理解,Markdown就是一个富文本编辑器语言,类似于sass对于css的功能,Markdown也可以叫做HTML预处理器,只不过它是一门轻量级的标记语言,可以更简单的实现HTML ...
- Shell碎碎念
1. 字符串如何大小写转换 str="This is a Bash Shell script." 1> tr方式 newstr=`tr '[A-Z]' '[a-z]' < ...
- 如果你也会C#,那不妨了解下F#(7):面向对象编程之继承、接口和泛型
前言 面向对象三大基本特性:封装.继承.多态.上一篇中介绍了类的定义,下面就了解下F#中继承和多态的使用吧.
- 调用微信退款接口或发红包接口时出现System.Security.Cryptography.CryptographicException: 出现了内部错误 解决办法
我总结了一下出现证书无法加载的原因有以下三个 1.证书密码不正确,微信证书密码就是商户号 解决办法:请检查证书密码是不是和商户号一致 2.IIS设置错误,未加载用户配置文件 解决办法:找到网站使用的应 ...
- 【WPF】日常笔记
本文专用于记录WPF开发中的小细节,作为备忘录使用. 1. 关于绑定: Text ="{Binding AnchorageValue,Mode=TwoWay,UpdateSourceTrig ...
- 从国内流程管理软件市场份额看中国BPM行业发展
随着互联网+.中国制造2025.工业4.0等国家战略的支持与引导,企业在数字经济时代的信息化表现惊人,越来越多企业认识到,对于企业的发展来说,信息自动化远远还不够,企业的战略.业务和IT之间需保持高度 ...
- django 第三天 有关库使用
项目中经常会用到第三方的lib和app,有些lib和app会进行不断更新,更新后可能会存在冲突,因此可以创建externals目录,下面欧app和libs.app存放django-cms,haysta ...
- Linux命令【第二篇】
1.如何过滤出已知当前目录下oldboy中的所有一级目录(提示:不包含oldboy目录下面目录的子目录及隐藏目录,即只能是一级目录). ^:以什么开头,例如^olboy表示以oldboy开头. ls: ...
- Linux网络驱动--snull
snull是<Linux Device Drivers>中的一个网络驱动的例子.这里引用这个例子学习Linux网络驱动. 因为snull的源码,网上已经更新到适合最新内核,而我自己用的还是 ...
- TCP三次握手图解