Unity3D高级编程:主程手记
上QQ阅读APP看书,第一时间看更新

2.4 浮点数的精度问题

很多人在项目中会用double类型替代float类型来提高精度,他们错误地认为double类型可以解决精度问题。我正好相反,在编程中极少使用double类型,由于浮点数在大多数项目中并没有使用到特别高的精度,所以float类型基本都够用。其实,即使使用double类型也同样有精度问题,因为浮点数本身就很容易导致不一致的问题。

我们不妨比较float与double,来看看它们有什么不同。float和double所占用的位数不同会导致精度不同,float是32位占用4字节,而double是64位占用8字节,因此,它们在计算时也会引起计算效率的不同。

在实际工作中,我们很多时候想通过使用double替换float来解决精度问题,最后基本都会以失败告终。因此,我们要认清精度这个问题的根源,才能真正解决问题。我们先来看浮点数在内存中到底是如何存储的。

计算机只能识别0和1,不管是整数还是小数,在计算机中都以二进制方式存储在内存中。那么浮点数是以怎样的方式来存储的呢?根据IEEE 754标准,任意一个二进制浮点数F均可表示为

F=(-1^s)×(1.M)×(2^e)

从上式可以看出,它被分为3个部分:符号部分即s部分、尾数部分即M部分、阶码部分即e部分。s为符号位0或1;M为尾数,是指具体的小数,用二进制表示,它完整地表示为1.xxx中1后面的尾数部分,也是因此才称它为尾数;e是比例因子的指数,是浮点数的指数。

图2-2所示的是32位和64位的浮点数存储结构,即float和double的存储结构。不论是32位浮点数还是64位浮点数,它们都由s、M、e三部分组成,并使用相同的公式来计算得到最终值。

图2-2 32位和64位的浮点数存储结构

其中e的阶码采用隐含方式,即采用移码方法来表示正负指数。移码方法对两个指数大小的比较和对阶操作比较方便,因为阶码的值大时,其指向的数值也是大的,这样更容易计算和辨认。移码(又叫增码)是符号位取反的补码,例如,float的8位阶码,应将指数e加上一个固定的偏移值127(01 111 111),即e加上127才是存储在二进制中的数据。

尾数M则更简单,它只表示1后面的小数部分,而且是二进制直译的那种,然后再根据阶码来平移小数点,最后根据小数点的左右部分分别得出整数部分和小数部分的数据。

以9.625为例,将9.625(10)转换为二进制数1001.101(2),也可以表达为1.001 101×(2^3),因此,M尾数部分为00 110 100 000 000 000 000 000,去掉了小数点左侧的1,并用0在右侧补齐。

其中,表示9.625的1001.101的小数部分为什么是“101”,因为整数部分采用“除2取余法”得到从十进制数转换到二进制数的数字,而小数部分则采用“乘2取整法”得到二进制数。因此这里的小数部分0.625乘以2为1.25取整得到1,继续0.25乘以2为0.5取整得到0,再继续0.5乘以2为1取整为1,后面都是0不再计算,因此得到0.101这个小数点后面的二进制数。

再以198 903.19为例,先形象地转成二进制的数值,整数部分采用“除2取余法”,小数部分采用“乘2取整法”,并把整数和小数部分用点号隔开得到:

110 000 100 011 110 111.001 100 001 010 001 1(截取16位小数)

以1为首,平移小数点后得到1.100 001 000 111 101 110 011 000 010 100 011×(2^17)(平移17位)即

198 903.19(10)=(-1^0)×1.100 001 000 111 101 110 011 000 010 100 011×(2^17)

从结果可以看出,小数部分0.19转为二进制数后,小数位数超过16位(已经手算到小数点后32位都还没算完,其实这个位数是无穷尽的),因此这里导致浮点数有诸多精度的问题,很多时候它无法准确地表示数字,甚至非常不准确。

浮点数的精度问题不只是小数点的精度问题,随着数值越来越大,即使是整数,也会出现相同的问题,因为浮点数本身是一个由1.M×(2^e)公式形式得到的数字。当数字放大时,M的尾数的存储位数没有变化,能表达的位数有限,自然越来越难以准确表达,特别是数字的末尾部分越来越难以准确表达时。

人们总是觉得精度问题好像很难碰到,其实并非如此,实际开发中常常碰到而且确实很头疼,如果没有这部分的知识结构,则很容易在查看问题时陷入无尽的疑问。下面看看哪些情况会碰到这类问题。

(1)数值比较不相等

我们在编写程序时经常会遇到阈值触发某逻辑的情景,比如某个变量,需要从0开始加,每次加某个小于0.01的数,加到刚好0.23时做某事,到0.34时做另外一件事,到0.56时再做另外一件事。

在这种情况下,精确定位就会遇到麻烦。因为浮点数在加减乘除时无法准确定位到某个值,所以就会出现要么比0.23小,要么比0.23大,但永远不会出现刚刚与0.23相等的情况,这时我们不得不放弃“==”(等于号)而选择“>”(大于号)或者“<”(小于号)来解决这种问题的出现。

如果一定要用等于来做比较,则需要有一个微小的浮动区间,即ABS(X-Y)<0.000 01时认为X和Y是相等的。

(2)数值计算不确定

比如,x=1f,y=2f,z=(1f /5555f)×11 110f,如果x/y<=0.5f时做某事,那么理论上说x/z也能通过这个if,因为在我们看来,z就等于2,和y是一样的,但实际上未必是这样的。浮点数在计算时由于位数的限制,无法得到精确的数值而是一个被截断的数值,因此z的计算结果可能是0.499 999 999 999 1,当x/z时,结果有可能大于0.5。

这让我们很头疼,在实际编码中,经常会遇到这样的情况,在外圈的if判断成立,理论上同样的结果只是公式不同,它们在内圈的if判断却可能不成立,使得程序出现异常行为,因为看起来应该是得到同样的数值,但结果却不一样。

(3)不同设备的计算结果不同

不同平台上的浮点数计算也有所偏差,由于设备上CPU寄存器和操作系统架构不同,因此会导致相同的公式在不同的设备上计算出来的结果略有偏差。

面对这些精度上的问题该怎么办?下面就来看看有哪些解决方案。

1)简单方法。

由一台机器决定计算结果,只计算一次,且认定这个值为准确值,把这个值传递给其他设备或模块,只用这个变量结果进行判断,也省去了多次计算浪费CPU内存空间。

不进行多次计算也就排除了因结果不同导致的问题。

看似相等的计算得到的结果却有可能不同,这使问题变得更复杂,比如上面所说的1f/2f的结果,使用1f/(1f/5555f×11 110f)来表示得到的结果不一样将导致问题变得不可控。因此不如只使用一次计算,不再进行多次计算,认定这次结果的数值为准确数值,只将这个浮点数值当作判断的标准。

我在编程时也常用这种方法,这种方法简单实用,特别是在网络同步方面,使用某客户端的计算结果或由服务器决定计算结果,能很好地解决浮点数计算不一致的问题。

2)改用int或long类型来替代浮点数。

浮点数和整数的计算方式都是一样的,只是小数点部分不同而已。我们完全可以通过把浮点数乘以10的幂次得到更准确的整数,也就是把自己需要的精度用整数表示。比如保留3位精度,所有浮点数都乘以10 000(因为第4位不是很准确),1.5变成15 000的整数,9.9变成99 000的整数存储。

这样,整数15 000乘以99 000得到的结果与整数30 000除以2再乘以99 000得到的结果是完全相等的。

再举例,原来2.5/3.1×5.1与0.8064×5.1都只是约等于4.1126,用整数替代,2500/31×51与80×51,等于4080,把4080看成4.08,虽然精度出现问题,但是前两者结果不一致,而后两者结果完全相同,使用整数来代替小数,使得一致性得到了保证。

如果你觉得用整数做计算的精度问题比较大,则可以再扩大数值到10的幂次,扩大后如果是250 000/31×51,就等于411 290,是不是发现精度提高了?但问题又来了,若通过乘以10的幂次来提高精度,当浮点数值比较大时,就会超出整数的最大上限2^32-1或者2^64-1。

如果你觉得精度可以接受,并且数值计算的范围肯定会被确定在32位或64位整数范围内,则可以用int和long类型的方式来代替浮点数。

3)用定点数保持一致性并缩小精度问题。

浮点数在计算机中是用V=(-1)^s×(1.M)×2^(e)公式表示的,也就是说,浮点数的表达其实是模糊的,它用了另一个数乘以2的幂次来表示当前的数。

定点数则不同,它把整数部分和小数部分拆分开来,都用整数的形式表示,这样计算和表达都使用整数的方式。由于整数的计算是确定的,因此就不会存在误差,缺点是由于拆分了整数和小数,两个部分都要占用空间,所以受到存储位数的限制,占用字节多了就会使用64位的long类型整数结构来存储定点数,这会导致计算的范围相对缩小。

与浮点数不同,使用定点数做计算能保证在各设备上计算结果的一致性。C#有一种叫decimal的整数类型,它并非基础类型,是基础类型的补充类型,是C#额外构造出来的一种类型,可以认为是构造了一个类作为数字实例并重载了操作符,它拥有更高的精度,却比float范围小。它的内部实现就是定点数的实现方式,我们完全可以把它看成定点数。

C#的decimal类型数值有几个特点需要我们重点关注,它占用128位的存储空间,即一个decimal变量占用16字节,相当于4个int型整数大小,或2个long型长整数大小,比double型还要大1倍。它的数值范围在±1.0×10^28到±7.9×10^28之间,这么大的占用空间却比float的取值范围还小。decimal精度比较大,精度范围为28个有效位,另外任何与它一起计算的数值都必须先转化为decimal类型,否则就会编译报错,数值不会隐式地自动转换成decimal。

看起来好用的decimal却不是大部分游戏开发者的首选。使用C#自带的decimal定点数存在诸多问题。最大问题在于无法与浮点数随意互相转换,因此在计算上需要进行一定的封装,要么提前对float处理,要么在decimal的基础上封装一层外壳(类)以适应所有数值的计算。精度过大导致CPU计算消耗量大,128位的变量、28位的精度范围,计算起来有比较大的负荷,如果大量用于程序内的逻辑计算,则CPU就会不堪重负。内存也是如此,大量使用会使得堆栈内存直线飙升,这也间接增大了CPU的消耗。因此它只适用于财务和金融领域的软件,对于游戏和其他普通应用来说不太合适,其根源是不需要这么高的精度,浪费了诸多设备资源。

实际上,大部分项目都会自己实现定点数,具体实现如前面所说的那样:把整数和小数拆开来存储,用两个int整数分别表示整数部分和小数部分,或者用long长整型存储(前32位存储整数,后32位存储浮点数),long型存储会更好,它便于存储和计算。这样,无论是整数部分还是小数部分,都用整数表示,并封装在类中。因此我们需要重载(override)所有的基本计算和比较符号,包括+、-、*、/、==、!=、>、<、>=、<=,这些符号都需要重载,重载范围包括float(浮点数)、double(双精度)、int(整数)、long(长整数)等。除了以上这些,为了能更好地融合定点数与外部数据的逻辑计算,还需要为此编写额外的定点库,包括定点数坐标类、定点数Quaternion类等来扩展定点数。

这看起来比较困难,其实并不复杂,只要静下心来编写,就会发现不是难事。将定点数与其他类型数字的加减乘除做运算符重载,如果涉及更多的数学运算,则再建立一个定点数数学库,存放一些数学运算的函数,再用编写好的定点数类去写些用于扩展的逻辑类,仅此而已,都是只要花点时间就能搞定的事,Github上也有很多定点数开源的代码,可以下载下来参考,或者把它从头到尾看一遍,将它改成适合自己项目的工具库。

4)用字符串代替浮点数。

如果想要精确度非常高,定点数和浮点数无法满足要求,那么就可以用字符串代替浮点数来计算。但它的缺点是CPU和内存的消耗特别大,只能做少量高精度的计算。

我在大学里做算法竞赛题目时,就遇到过这种检验程序员的逻辑能力和考虑问题全面性的题目,题目很简单,A×B或A-B或A+B或A/B输出结果,精度要求在小数点后100位。我们把中小学算术的笔算方式写入程序里,把字符串转化为整数,并用整数计算当前位置,接着用字符串形式存储数字,这样的计算方式完全不需要担心越界问题,还能自由控制精度。

缺点是很消耗CPU和内存,比如对于123 456.789 123 45×456 789.234 567 8这种类型的计算,使用字符串代替浮点数,一次的计算量相当于计算好几万次的普通浮点数。所以,如果程序中对精度要求很高,且计算的次数不多,这种方式可以放在考虑范围内。