当前位置: 首页 > 科技观察

妥协与取舍,解构C#中的小数计算

时间:2023-03-21 01:04:42 科技观察

0x00前言慕容在生活和工作中经常会遇到一些迷信机器的人。真正无私,所以机器的计算给出的答案永远是正确的。如果回答错误,那一定是操作机器的人的问题。但是机器的计算就一定正确吗?事实上,机器出现计算错误的情况并不少见。一个典型的例子是十进制计算。现在我们来谈谈一个相关的话题,小数在机器中或者具体在C#语言中是如何处理的?0x01让我们从一个“错误”的答案开始。既然要讲机器是如何把一道算术题做错的,自然要看一个机器计算出错的小例子。#includevoidmain(){floatnum;inti;num=0;for(i=0;i<100;i++){num+=0.1;}printf("%f\n",num);}这是一个用C语言编写的小程序。逻辑非常简单易懂。要达到的结果无非就是把0.1加100次然后输出。我想我们不需要电脑来计算,我们自己马上就能得出答案——10。那么计算机会给我们什么样的答案呢?让我们编译并运行这个C代码。答案一输出,让人大吃一惊。为什么电脑不如人的心算呢?按照慕容前言提到的朋友们的说法,他们可能会开始担心是不是密码有误,或者是慕容的电脑出了问题。但实际上代码是正确的,机器照常工作。那么为什么计算机的计算输给了人脑的计算呢?这就引出了下一个问题,计算机如何处理小数?如果我们了解计算机如何处理小数的机制,那么这一切谜团就可以解开。(当然,如果有朋友用C#给0.1加100次,结果就是10。但那是C#在背后为我们做的一些隐藏的工作,本质和计算机处理小数是一样的).0x02数字格式程序可以看作是现实世界的数字模型。现实世界中的一切都可以转化为数字,在计算机世界中复活。因此,一个必须解决的问题是数字在计算机中是如何表示的。这也是数字格式的含义。众所周知,机器语言全是数字,但本文自然不会关心所有的二进制格式。这里我们只关心现实中有意义的数字在计算机中是如何表示的。简单来说,有意义的数字大致可以分为以下三种格式。整数格式我们在开发过程中遇到的大多数数字其实都是整数。整数也是最容易在计算机中表示的。我们遇到的所有整数都可以使用32位有符号整数(Int32)表示。当然,如果需要的话,还有一个带符号的64位整数数据类型(Int64)可供选择。至于整数对应的就是小数,小数主要有两种表示形式。定点格式所谓定点格式,就是所有数据的小数点在机器中的位置都是固定的。定点小数的最常见示例是SQLServer中的货币类型。其实定点小数已经很不错了,显然适用于很多需要处理小数的情况。但它有一个先天的缺点,就是因为小数点的位置是固定的,所以它能表示的范围是有限的。因此,下面就会出现我们文章的主角。浮点格式解决定点格式固有问题的方法是浮点格式的出现。浮点格式的组成包括符号、尾数、基数和指数,小数由这四部分表示。由于计算机内部是二进制的,所以基数自然是2(就像十进制的底数是10一样)。因此,计算机往往不需要记录数据中的基数(因为它永远是2),而只用三部分来表示:符号、尾数和指数。许多编程语言至少提供了两种使用浮点格式表示小数的数据类型,即我们经常见到的双精度浮点数double和单精度浮点数float。同样,在我们的C#语言中,也有这两种使用浮点格式表示小数的数据类型——按照C#语言标准双精度浮点数和单精度浮点数在C#中对应System.Double和System.Single。但实际上,C#语言中还有第三种数据类型使用浮点数格式来表示小数,那就是小数类型——System.Decimal。需要注意的是,浮点数的格式有多种表示形式,C#遵循IEEE754标准:float单精度浮点数为32位。32位的结构是:符号部分1bit,指数部分8bit,尾数部分23bit。双精度浮点数是64位。64位的结构是:符号部分1bit,指数部分11bit,尾数部分52bit。0x03表示范围、精度和准确度说完了数字在计算机中的几种表示形式,那么在选择数字格式时就不得不提到一些指标了。最常见的无外乎这几点:指示范围、精度、准确度。数字格式的表示范围顾名思义,数字格式的表示范围是指该数字格式所能表示的从最小值到最大值的范围。例如,一个16位有符号整数的表示范围是从-32768到32767,如果要表示的数的值超出这个范围,那么这个数就不能用这种数字格式来正确表示。当然,此范围内的数字可能无法正确表示。例如,16位有符号整数不能准确表示小数,但总有一个接近的值可以用16位有符号整数格式表示。数字格式的精度说实话,很多人对精度和准确度的感觉是很模糊的,两者似是而非。不过慕容容需要提醒大家的是,精准度和准确度是两个有着巨大差距的概念。通俗地说,数字格式的精度可以认为是该格式必须代表一个数字的信息量。更高的精度通常意味着可以表示更多的数字。一个最明显的例子就是精度越高,这种格式所能表示的数字就越接近于实数。比如我们知道,如果1/3换算成小数0.3333....是无限的,那么在五位精度的情况下可以写成0.3333,在七位(的)情况下就可以写成0.333333当然,如果五位数用七位数表示,那么就是0.333300)。数字格式的精度也会影响计算过程。举个简单的例子,如果我们在计算中使用一位精度。那么整个计算可能会变成下面的情况:0.5*0.5+0.5*0.5=0.25+0.25=0.2+0.2=0.4如果我们使用两位数精度,那么计算过程就会变成下面的Condition。0.5*0.5+0.5*0.5=0.25+0.25          =0.5比较两种精度条件下的计算结果,一位精度条件下的计算结果与正确结果相差0.1。在使用两位精度的情况下,结果是正常计算的。由此可见,在计算过程中保证准确性是多么有意义。数字格式的精度前面已经介绍了数字格式的表示范围和精度,下面介绍一下数字格式的精度。正如我刚才所说,准确性和精确度是一对经常混淆的概念。那我们就用通俗的方式来评论一下准确性吧。简单的说就是数字格式(特定环境)所代表的数字与真实数字之间的误差。更高的准确性意味着数字格式表示的数字与实数的值之间的误差更小。精度越低,表示数字格式所表示的数字与实数的数值之间的误差就越大。需要注意的一件事是数字格式的精度与数字格式的准确性没有直接关系。这也是很多朋友经常混淆概念的地方。使用低精度数字格式表示的数字不一定不如使用高精度数字格式表示的数字准确。举个简单的例子:Bytenum=0x05;Int16num1=0x0005;Int32num2=0x00000005;Singlenum3=5.000000f;Doublenum4=5.00000000000000000;这时候我们用5种不同的数字格式来表示同一个数5,虽然数字格式的精度(从8位到64位)不同,但是数字格式表示的数和实数是一样的.也就是说,对于数字5,这5种数字格式的准确度是一样的。0x04舍入错误了解了计算机中几种常见的数字格式之后,我们再来说说计算机是如何通过数字格式来表示现实世界中的数字的。众所周知,计算机使用0和1,也就是二进制。用二进制表示整数很容易,但是我们在用二进制表示小数的时候,往往会有一些疑惑。比如二进制十进制1110.1101转换成十进制是什么?***乍一看好像多了一个小数点,好像很费解。其实它的处理和整数是一样的,就是把每一位的值和位权重相乘的结果相加。小数点前的位置大家都很熟悉了。从右到左分别是0的次方、1的次方、2的次方。因此,小数点前的二进制转换为十进制:1*8+1*4+1*2+0=14和小数点后的位权,对应的,从左到右为-1次方,-2次方依次递减。因此,小数点后的二进制转为十进制为:1*0.5+1*0.25+0*0.125+1*0.0625=0.8125,所以1110.1101的二进制小数转为十进制为14.8125。通过观察小数点后二进制转十进制的过程,你有没有发现一个有趣的事实?即小数点后的二进制不能代表所有的十进制数。也就是说,有些十进制数是不能转换成二进制的。这个很好理解,因为在小数点之后,二进制的位重按照除以2的节奏递减,而小数则按照除以10的节奏递减。因此,如果小数点后4位用二进制表示,即.0000~.1111范围内连续的二进制值实际上对应的是非连续的十进制数,所有可能的结果都是空的以上每一位权重(0.5、0.25、0.125和0.0625)的加法组合。因此,如果用二进制表示一个十进制非常简单的数,所用的位数可能会很长,甚至是无穷大。一个很好的例子是使用二进制浮点数来表示十进制的0.1:doublex=0.1d;实际上变量x中存储的值并不是真正的十进制0.1,而是最接近十进制0.1的二进制浮点数。这是因为无论小数点后有多少位二进制数,都不能将2的负次方相加得到0.1的结果,所以十进制数0.1在二进制中会变成十进制数。当然,二进制不一定能准确表示十进制小数,这个很好理解,因为这有点类似于我们不能用十进制准确表示1/3等循环小数。在这一点上,我们不得不向电脑妥协。因为我们现在知道,在计算机中使用的值可能并不等于现实世界中的值,而是一个非常接近计算机以某种数字格式表达的原始数字的值。在整个程序运行的过程中,我们的计算机会一直使用这个唯一的近似值来参与计算。我们假设真实值是n,但是计算机实际上会用另外一个值n+e(当然e是一个很小的数,可以是正数也可以是负数)参与计算机中的计算。此时的值e就是舍入误差。而这只是一个在计算机中以近似值表示的数字。如果涉及到数值计算,显然会带来更多的误差。这也是本文开头c程序计算错误的原因,因为计算中涉及的数值无法正确表达,到最后都变成了近似值。当然,C#语言相对“高级”很多。虽然在电脑中是一个大概的数值,但是展现在大家面前的这个数值,至少更符合人们的“预期”数值。但是在C#中,十进制计算真的没有错误吗?毕竟,这一切似乎只是一个掩饰。0x05取舍,C#中的小数比是否相等。不知道大家在使用一些关系运算符的时候,有没有注意到用等号来比较两个小数是否相等的时候出现了一些意想不到的问题。我身边的朋友都是用关系运算符直接比较两个小数的大小,但是直接比较两个小数是否相等的情况并不多。同时也提醒各位***不要轻易比较两个小数是否相等。即使在C#这样的高级语言中,仍然有可能得到一个感觉“错误”的答案,因为我们实际上是在比较两位小数。两位小数是否“接近”相等,而不是两个数字是否真正相等。下面的例子可以更好地说明这一点:staticdoubleSum(doublef1,doublef2){returnf1+f2;}}当我们编译并运行这段代码时,我们可以看到输出如下:比较这两个小数的结果是不正确的,这与我们预期的不一样.我们知道浮点数的真实面目。上面的二进制十进制1110.1101其实是按照人的习惯来表示的,但是计算机无法识别这种带小数点的东西。那么计算机会使用之前介绍过的数字格式来表示这样的数,那么一个二进制浮点数在计算机中是如何表现的呢?其实介绍数字格式的部分上面已经介绍过了,但是不实际看一下是不可能有直观的认识的。那么本文的最后,我们来看一下计算机中真正的二进制浮点数。看。0100000001000111101101101101001001001000010101110011000100100011这是一个64位二进制数。Ifitisadouble-precisionfloating-pointnumber,whatdoitspartsrepresent?Accordingtotheabovepartoftheintroductionoffloatingpointnumbers,wecandivideitintothefollowingparts:sign:0exponentpart:10000000100(binary,canbeconvertedtodecimal1028)mantissapart:0111101101101101001001001000010101110011000100100011Therefore,convertittoabinaryrepresentation小数,则是:(-1)^0*1.0111101101101101001001001000010101110011000100100011x2^(1028-1023)=1.0111101101101101001001001000010101110011000100100011x2^5=101111.01101101101001001001000010101110011000100100011如果各位读者观察足够仔细的话,是否发现了有趣的一点呢?Thatis,inthe64-digitnumberusedtorepresentdouble-precisionfloating-pointnumbersincomputers,thedigitsofthemantissaare:0111101101101101001001001000010101110011000100100011.Itbecomes1.0111101101101101001001001000010101110011000100100011x2^5,whyisthereanextra1beforethedecimalpoint?Thisisbecauseinthemantissapart,inordertounifyfloating-pointnumberswithvariousexpressionsintothesamerepresentation,itisstipulatedthatthevaluebeforethedecimalpointshouldbefixedto1.Sincethenumberbeforethedecimalpointisalways1,inordertosaveadatabit,this1doesnotneedtobesavedinthecomputer.Sohowtoensurethatthevaluebeforethedecimalpointofabinarydecimalis1?Thisrequireslogicalshiftingofthebinaryfraction,andaftershiftingleftorrightseveraltimes,theintegerpartbecomes1.Forexample,thebinarydecimalabove:1110.1101,let'stryhowtoturnitintothemantissaofafloating-pointnumberthatthecomputercanrecognize.1110.1101(原始数据)-->0001.1101101(整数部分右移为1)-->0001.11011010000000000000....(扩大位数使其符合数字格式要求)-->11011010000000000000....(去掉整数部分,只保留小数部分)嗯,这和C#或计算机中的小数计算差不多。欢迎交流。