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

目录

一,浮点数精度丢失?

二,整数的二进制表示

三,浮点数的二进制表示

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

五,四舍六入五去偶


一,浮点数精度丢失?

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

将浮点数包装为字符串

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

取出字符串的浮点值

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

字符串转为浮点值

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

保留到小数点后10位

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

保留到小数点后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,以
便你使用时能和我产生相同的结果。


整数二进制格式打印的函数体

展示整数二进制格式的测试用例

整数类型的二进制位表示

我们知道,将整数映射到二进制的方式为补码,简单说来,对于一台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)这个无限不循环小数。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容