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