深入浅出iOS浮点数精度问题 (上)
目录
一,浮点数精度丢失?
二,整数的二进制表示
三,浮点数的二进制表示
四,iEEE 754浮点数的手动转换
五,四舍六入五去偶
一,浮点数精度丢失?
在iOS开发中,我们时常会使用 NSString 的 +方法,用格式化字符串将一个浮点数包裹为字符串,如下面代码所示:
将浮点数包装为字符串接着在需要使用基本数据类型的地方,再将字符串转为基本数据类型,使用 NSString 的 - 方法 doubleValue 或 floatValue 可以轻松帮我们做到这一点,如下面代码所示:
取出字符串的浮点值一切都看起来很美好,不是吗?16542.7 变为了字符串 @"16542.70",当我们需要使用基本数据类型来参与运算时,比如要计算总和,计算利率,再将 @"16542.70" 转换为 浮点数的 16542.70,完美!perfect!轻松加愉快!好,先别高兴太早,让我们看看将字符串转为基本数据类型后的结果:
字符串转为浮点值的确如我们所期,打印出了我们一开始定义的两个浮点数值,但这个直白且 “毋庸置疑” 的打印结果,其实仅仅是个烟雾弹,我们换一种方式,对格式化限定符稍加改动,再来看打印结果:
保留到小数点后10位结果似乎仍然正确显示。
别慌,我们再稍加改动,再看打印结果:
怎么会这样??
浮点数似乎闹了点小脾气,他在我们预料的 “正确结果” 上发生了细小的偏差,至此你可能会恍然大悟,因为 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,以
便你使用时能和我产生相同的结果。
整数二进制格式打印的函数体
展示整数二进制格式的测试用例
整数类型的二进制位表示
我们知道,将整数映射到二进制的方式为补码,简单说来,对于一台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,我们用以下代码示例佐证该结论:
展示有符号 char 的 负值 的二进制表示 展示有符号 char 的 正值 的二进制表示你可以通过右侧二进制表示反推 char 值,加深对补码表示的理解
三,浮点数的二进制表示
终于到了本篇文章的主题——浮点数,在计算机内,浮点数的存储也不例外,仍然使用二进制位来存储,但将浮点数映射为二进制的方式却与整数表达大相径庭,下面的打印使用了有意为之的空格作为隔断,请观察以下打印结果
单精度浮点数的二进制表示
乍看似乎毫无规律可循,其实你只用记住,当今世界绝大多数计算机采用的浮点数编码方式都遵守 IEEE 754 标准,这个标准描述了这样一种浮点数的定义方式:
浮点数值 = (-1) ^ S * ( 2 ^ E) * M
S 是符号位,E为移码 (阶码 + 偏置量),M是尾数
单精度浮点数 符号位占 1 bit, 移码占 8 bit,尾数占23 bit。上述打印采用了相同的格式的空格隔断。
可以用下图来形象的记忆单精度浮点数 ( float ) 在内存中的结构
iEEE 754编码的单精度浮点数的内存示意图因此,我们采用定义一个用位域分割的结构体,来表示单精度浮点数的内存结构,如下代码所示
单精度浮点数位域结构体
接着定义一个联合,让这个结构体和一个单精度浮点数共享一块内存空间,我们会发现,这样做是直观且便于理解的。
单精度浮点数联合这里用了 yh 的前缀只是为了解决系统已经有了 float_t 定义产生的名字冲突。
接下来就完成浮点数二进制格式打印函数的定义
浮点数二进制表示的函数体四,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
用我们自己写的工具函数来佐证这一结果:
屏幕快照 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高精度库封装分类这里采用链式编程为高精度算法在调用上提供了轻便的支持,使冗余的代码变得简洁,如果你对链式编程以及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
用我们自己写的工具函数来佐证这一结果:
大功告成 !!!
等等!
细心你的也许会发现,这两个结果是存在细微差别的!
用工具函数打印出来的浮点数尾数是
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)这个无限不循环小数。
既然从一开始就是不精确的,又何来精度丢失之谈呢。