深入浅出iOS浮点数精度问题 (上)

目录

一,浮点数精度丢失?

二,整数的二进制表示

三,浮点数的二进制表示

四,iEEE 754浮点数的手动转换

五,四舍六入五去偶


一,浮点数精度丢失?

在iOS开发中,我们时常会使用 NSString 的 +方法,用格式化字符串将一个浮点数包裹为字符串,如下面代码所示:

将浮点数包装为字符串

接着在需要使用基本数据类型的地方,再将字符串转为基本数据类型,使用 NSString 的 - 方法 doubleValue 或 floatValue 可以轻松帮我们做到这一点,如下面代码所示:

深入浅出iOS浮点数精度问题 (上)_第1张图片
取出字符串的浮点值

一切都看起来很美好,不是吗?16542.7 变为了字符串 @"16542.70",当我们需要使用基本数据类型来参与运算时,比如要计算总和,计算利率,再将 @"16542.70" 转换为 浮点数的 16542.70,完美!perfect!轻松加愉快!好,先别高兴太早,让我们看看将字符串转为基本数据类型后的结果:

深入浅出iOS浮点数精度问题 (上)_第2张图片
字符串转为浮点值

的确如我们所期,打印出了我们一开始定义的两个浮点数值,但这个直白且 “毋庸置疑” 的打印结果,其实仅仅是个烟雾弹,我们换一种方式,对格式化限定符稍加改动,再来看打印结果:

深入浅出iOS浮点数精度问题 (上)_第3张图片
保留到小数点后10位

结果似乎仍然正确显示。
别慌,我们再稍加改动,再看打印结果:

深入浅出iOS浮点数精度问题 (上)_第4张图片
保留到小数点后12位

怎么会这样??
浮点数似乎闹了点小脾气,他在我们预料的 “正确结果” 上发生了细小的偏差,至此你可能会恍然大悟,因为 C 语言中,格式化字符串默认 "%f" 默认保留到小数点后第6位,也就是说,即使浮点数的值不是你所期望的 16542.7 而是 16542.70000000001,我们在打印时默认让他保留到了小数点后6位,那么这个 0.0000000001,也就理所当然被省略掉了。同样道理,28732.599999999999 也因为这样的舍入,变为我们所看到的正确结果 28732.600000,而通过限制保留到小数点后到具体位数,我的得以看到这个浮点数真实的面目。

问题到底出在哪里?
我的浮点数精度定义时分明是 16542.7 和 28732.6 被你搞这么一同方法调用,精度却似乎是丢失了,具体是哪个步骤让他发生了这种预期外的变化???

二,整数的二进制表示

在计算机内部,所有数据类型均是以二进制的方式存储,比如 char 型变量 c = 'a',字符'a'对应的ASCALL编码是97,则它可以用二进制表示为 1100 0001,比如 int 型变量 s = 255,则它可以用二进制表示为1111 1111,我们用以下打印佐证这一事实:
以下是该打印函数的实现体和测试用例,随机数种子取固定值100,以
便你使用时能和我产生相同的结果。


深入浅出iOS浮点数精度问题 (上)_第5张图片
整数二进制格式打印的函数体

深入浅出iOS浮点数精度问题 (上)_第6张图片
展示整数二进制格式的测试用例

深入浅出iOS浮点数精度问题 (上)_第7张图片
整数类型的二进制位表示

我们知道,将整数映射到二进制的方式为补码,简单说来,对于一台64位的机器

(可以简单理解为内存地址最大表示的上限是64个bit位,也就是8个字节,你可以使用 sizeof( int * ) 观察输出来佐证这个理解,你将观察到,64位机器指针是8个字节,而在32位机器上,指针是4个字节)

char 类型是 1 个字节,8 个 bit 位,则 0001 0100 表示为 1 * 2^2 + 1 * 2^4 = 20。

最大的 char 值是 0111 1111, 即 ( 2 << 7 ) - 1 也就是 127, 你可能会有所疑惑,如果最高位占 1 , 这样不就比 127 还要大了吗?记住,最高位是符号位,在 C 家族 的世界中,数据类型分为有符号和无符号,而这个最左边也就是最高位的 bit 位,代表一个数据类型的符号,0 代表正数,1代表负数。

最小的 char 值是 1000 000, 即 -( 2 << 8 ) 也就是 -128, 在补码表示中,最高位符号位为 1 代表负权重,所以 1001 0101 的有符号值就是 -(128) + 16 + 4 + 1 = -107,我们用以下代码示例佐证该结论:

深入浅出iOS浮点数精度问题 (上)_第8张图片
展示有符号 char 的 负值 的二进制表示
深入浅出iOS浮点数精度问题 (上)_第9张图片
展示有符号 char 的 正值 的二进制表示

你可以通过右侧二进制表示反推 char 值,加深对补码表示的理解

三,浮点数的二进制表示

终于到了本篇文章的主题——浮点数,在计算机内,浮点数的存储也不例外,仍然使用二进制位来存储,但将浮点数映射为二进制的方式却与整数表达大相径庭,下面的打印使用了有意为之的空格作为隔断,请观察以下打印结果


深入浅出iOS浮点数精度问题 (上)_第10张图片

深入浅出iOS浮点数精度问题 (上)_第11张图片
单精度浮点数的二进制表示

乍看似乎毫无规律可循,其实你只用记住,当今世界绝大多数计算机采用的浮点数编码方式都遵守 IEEE 754 标准,这个标准描述了这样一种浮点数的定义方式:

浮点数值 = (-1) ^ S * ( 2 ^ E) * M

S 是符号位,E为移码 (阶码 + 偏置量),M是尾数

单精度浮点数 符号位占 1 bit, 移码占 8 bit,尾数占23 bit。上述打印采用了相同的格式的空格隔断。

可以用下图来形象的记忆单精度浮点数 ( float ) 在内存中的结构

深入浅出iOS浮点数精度问题 (上)_第12张图片
iEEE 754编码的单精度浮点数的内存示意图

因此,我们采用定义一个用位域分割的结构体,来表示单精度浮点数的内存结构,如下代码所示


深入浅出iOS浮点数精度问题 (上)_第13张图片
单精度浮点数位域结构体

接着定义一个联合,让这个结构体和一个单精度浮点数共享一块内存空间,我们会发现,这样做是直观且便于理解的。

深入浅出iOS浮点数精度问题 (上)_第14张图片
单精度浮点数联合

这里用了 yh 的前缀只是为了解决系统已经有了 float_t 定义产生的名字冲突。

接下来就完成浮点数二进制格式打印函数的定义

深入浅出iOS浮点数精度问题 (上)_第15张图片
浮点数二进制表示的函数体

四,iEEE 754浮点数的手动转换

下面我们执行一些手动的转换,并利用工具函数验证结果,加深对浮点数的理解。

例1 :float a = -128.625

首先将十进制128.625转换成二进制小数

128 -> 2^7 -> 10000000
0.625 -> 2^-1 + 2^-3 -> 0.101
128.625 -> 10000000.101

然后将二进制小数表示为 IEEE 754标准的格式

10000000.101 -> 1.0000000101 * 2^7
-> (-1) ^ 0 * (2 ^ 7) *(0.0000000101 + 1)

阶码的转换公式为 : E = e - 2 ^ (k - 1) (k 为阶码位数)

对于单精度浮点数而言,阶码是 8 个 bit 位
e = E + 127 = 7 + 127 = 134
将其表示为二进制即 1000 0110

故 -128.625 的 IEEE 754标准 浮点数格式为

符号位 --------- 阶码 ------------------------------ 尾数
1 ------------- 1000 0110 -------------- 00000001010000000000000

用我们自己写的工具函数来佐证这一结果:

深入浅出iOS浮点数精度问题 (上)_第16张图片
屏幕快照 2017-09-06 12.34.37.png

例2 :float c = 1.1

在对 1.1 进行 IEEE 754 标准转换前,我们先打印出 2^-1 ~ 2^-23 的精确值

 - 1   0.5
 - 2   0.25
 - 3   0.125
 - 4   0.0625
 - 5   0.03125
 - 6   0.015625
 - 7   0.0078125
 - 8   0.00390625
 - 9   0.001953125
 -10   0.0009765625
 -11   0.00048828125
 -12   0.000244140625
 -13   0.0001220703125
 -14   0.00006103515625
 -15   0.000030517578125
 -16   0.0000152587890625
 -17   0.00000762939453125
 -18   0.000003814697265625
 -19   0.0000019073486328125
 -20   0.00000095367431640625
 -21   0.000000476837158203125
 -22   0.0000002384185791015625
 -23   0.00000011920928955078125

对照上面的数值,接下来开始转换 0.1

如果尾数有5位

0.0625 + 0.03125 = 0.9375 -> 0.00011

如果尾数有6位

0.0625 + 0.03125 = 0.9375 -> 0.00011 因为如果加上第6位的1,就是 0.109375 超出了0.1

如果尾数有7位

0.625 + 0.03125 = 0.9375 -> 0.00011

如果尾数有8位

0.625 + 0.03125 + 0.00390625 = 0.09765625 -> 0.00011001

如果尾数有9位

0.625 + 0.03125 + 0.00390625 + 0.001953125 = 0.099609375 -> 0.000110011

如果尾数有10位和11位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 = 0.099609375 -> 0.000110011

如果尾数是12位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 = 0.099853515625 -> 0.000110011001

如果尾数是13位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 = 0.0999755859375 -> 0.0001100110011

如果尾数是14位和15位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 = 0.0999755859375 -> 0.0001100110011

如果尾数是16位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 + 0.0000152587890625 = 0.0999908447265625 -> 0.0001100110011001

如果尾数是17位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 + 0.0000152587890625 + 0.00000762939453125 = 0.09999847412109375 -> 0.00011001100110011

如果尾数是18位和19位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 + 0.0000152587890625 + 0.00000762939453125 = 0.09999847412109375 -> 0.00011001100110011

如果尾数是20位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 + 0.0000152587890625 + 0.00000762939453125 + 0.00000095367431640625 = 0.09999942779541015625 -> 0.00011001100110011001

如果尾数是21位

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 + 0.0000152587890625 + 0.00000762939453125 + 0.00000095367431640625 + 0.000000476837158203125 = 0.099999904632568359375 -> 0.000110011001100110011

如果尾数是22位和23位,结果都将是

0.6252 + 0.03125 + 0.00390625 + 0.001953125 + 0.000244140625 + 0.0001220703125 + 0.0000152587890625 + 0.00000762939453125 + 0.00000095367431640625 + 0.000000476837158203125 = 0.099999904632568359375 -> 0.000110011001100110011

恭喜你!如果你仔细用纸笔执行完上述繁琐的计算,相信你对浮点数已经有了一些体会,当然,我肯定是没手动做这些计算,尽管我能够对天发四,上述计算都是绝对精确的,它们的实现方式如下代码所示

深入浅出iOS浮点数精度问题 (上)_第17张图片
iOS高精度库封装分类

这里采用链式编程为高精度算法在调用上提供了轻便的支持,使冗余的代码变得简洁,如果你对链式编程以及iOS内置的高精度算法库比较熟悉,可以自己进行封装。当然,对不熟悉的读者,封装方法也会在下一篇文章中讲到。

从上述的转换过程中可以发现,十进制 0.1 转成 二进制表示的过程中似乎显得无穷无尽,并且 0.1 的二进制表示中不断重复地出现 0011 这一形式,你可能不禁想问,这个转换过程真的是无穷无尽吗?的确是这样的,对于单精度浮点数而言,因为尾数只有23位,超出部分无法容纳,转换似乎是停止了。但你也可以看到,我们尽力而为的二进制表示结果 0.000110011001100110011 再转换成 十进制 后是 0.099999904632568359375,显然这是一个趋近值,如果尾数部分能容纳的范围再增长一些,这个转换过程还将持续几个来回,但这也仅仅只对向 0.1 的趋近中贡献了微不足道的一些力量,实际上无论尾数有多长,都无法精确表示 0.1 (double 类型浮点数 的符号位占 1 bit,移码占 11 bit,尾数占 52 bit)。

整理我们刚才全部转换过程,可以得到:
1.000110011001100110011

整理成 iEEE 754 标准格式
(-1) ^ 0 * 2 ^ 0 * 0.000110011001100110011

根据 阶码 = 移码 E + 偏置量 (2 ^ (k - 1)) k 表示阶码 bit 位数,单精度是 8 bit,双精度是 12 bit

e = E + 127 = 127 -> 01111111

得到 1.1 转换为 iEEE 754 标准编码的浮点数

符号位 --------- 阶码 ------------------------------ 尾数
1 ------------- 0111 1111 -------------- 00011001100110011001100

用我们自己写的工具函数来佐证这一结果:


深入浅出iOS浮点数精度问题 (上)_第18张图片
大功告成 !!!

等等!

细心你的也许会发现,这两个结果是存在细微差别的!

用工具函数打印出来的浮点数尾数是

0 0011 0011 0011 0011 0011 0 "1"

最后一位是1

而经过刚才的手工计算,得到的尾数是

0 0011 0011 0011 0011 0011 0 "0"

最后一位是 0

好吧,如果你真能发现这一点,那我不得不对你的细心五体投地。

这里之所以产生如此细微的差别,原因在于操作系统内部实现的浮点数编码时,默认是向偶数舍入的,为了说明什么是向偶数舍入,以及还有哪些舍入方式,我们来考虑下面尾数为3位的情况

五,四舍六入五去偶

如果我们对 0.1001 只能提供 3 个 bit 位用于表示,显然,第三位是最低有效位,我们只能忍痛“截断”第3位往后的数据,此时我们发现,0.0001 是 0.001的一半,在这种情况进行截断时,操作系统默认采用舍入到偶数的方式,操作系统会认为最低有效位为0是偶数,为1就是奇数,所以 操作系统将 0.1001 舍入为 0.100 以保证最低有效位是偶数 0,而将 0.1011 舍入为 0.110 以保证最低有效位是偶数 0。

让我们看两个向偶数舍入的例子(保留到小数点后两位),10.11100 采用向偶数舍入的方式变为 11.00,10.10100 采用向偶数舍入的方式变为 10.10。

需要注意的是,如果最低有效位后的小数总和大于最低有效位的一半,将采用向上舍入,把1进位到最低有效位,如果最低有效位后的小数总和小于最低有效位的一半,将会把最低有效位后的所有小数部分舍弃掉,让我们再来看两个向上舍入的例子(保留到小数点后两位),10.01101 将会向上舍入为 10.10,0.1111 将会向上舍入为 1.00。

再看两个向下舍入的例子(保留到小数点后两位),0.1001 将会向下舍入为 0.10,0.0101 将会向下舍入为 0.01

回到我们的刚才转换的 1.1,转换后结果为

0 0111 1111 00011001100110011001100 1100...

可以看到,最低有效位往后的小数总和大于末尾的一半,所以采用向上舍入的方式,向最低有效位进 1,最终得到

符号位 --------- 阶码 ------------------------------ 尾数
1 ------------- 0111 1111 -------------- 00011001100110011001101

到此,你应该对很早不知何时何地听到的

浮点数是无法精确表示大部分实数的

这句话有更佳深刻的体会,的确,能被精确表示的只是很少的一部分,再回过头看开头的例子,你也许会豁然开朗。

并非在 [NSString stringWithFormat:...] 或者 [string doubleValue] 中发生了浮点数精度的丢失,而是 iEEE 754 标准定义的浮点数本身就无法精确表示一些实数,这就好比十进制无法精确表示 (1 / 3)这个无限不循环小数。

既然从一开始就是不精确的,又何来精度丢失之谈呢。

你可能感兴趣的:(深入浅出iOS浮点数精度问题 (上))