26

Go 之父说:不懂浮点数不配当码农

 4 years ago
source link: https://www.tuicool.com/articles/rEJf2iz
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

7na2imy.gif

JR7jMnN.png!web

所以要赶紧补充一些高大上的浮点数知识吧

浮点数很重要

Go语言之父,Rob Pike大神曾经在微博吐槽过:不能掌握正则表达式或浮点数就不配当码农!

虽然原文已经被删除了(大神也有害怕的时候),还好我已经存档了:

You should not be permitted to write production code if you do not have an journeyman license in regular expressions or floating point math.

关于正则表达式已经有很多权威著作或入门教程(但是很多主流的编程语言的实现居然也藏了不少烂算法,性能被几十年前ed指数级吊打)。

但是讨论浮点数的资料则比较少。一般的编程图书也不会深入讨论浮点数。但是这并不代表浮点数不重要。实际上IEEE754之父,William Kahan正是因为浮点数标准化的工作获得了图灵奖。

为什么叫浮点数

人的生命是有限的,计算机的内存也是有限的。在主流的编程语言中,浮点数一般有32bit和64bit两种,分别对应单精度和双精度浮点数(有些地方还有半精度的浮点数),也就是说一个32bit的内存只能表达 2^32 种状态(大概40亿)!

但是实际上数从无穷小到无穷大范围异常的广泛。一个阿伏伽德罗常数要写成60221407600000000000000,十进制都要24位,32bit的二进制位根本不够用。此外像原子半径之类的数又非常之小,前面要用一垃圾0来填充。

为了少写很多0(因为纸张有限,内存也只有有限的32比特),懒惰的先贤们发明了科学记数法来表示很大或很小的数。科学记数法的思路就是用较少的指数来表示很大或很小数的系数。但是光有科学记数法还不行,比如 60221407600000000000000*10^0 依然没有节省纸张。只有规范化的科学记数法才能少写垃圾0,比如 6.02*10^23 看起来就很清爽了。而规范化的科学记数法中,有效位部分的小数点是随着指数发生变化的。

浮点数也是采用类似规范化的科学记数法的思路。而IEEE754是浮点数格式的国际标准,目前主流的编程语言都是采用这个标准。

浮点数的布局

这是C语言表示的float浮点数内存布局:

// Little endian
union ieee754_float {
    float f;

    /* This is the IEEE 754 single-precision format.  */
    struct {
        unsigned int mantissa:23;
        unsigned int exponent:8;
        unsigned int negative:1;
    } ieee;

    /* This format makes it easier to see if a NaN is a signalling NaN.  */
    struct {
        unsigned int mantissa:22;
        unsigned int quiet_nan:1;
        unsigned int exponent:8;
        unsigned int negative:1;
    } ieee_nan;
};

其中最低的23it是mantissa对应有效数字,然后是8bit的exponent表示指数,最高位的1bit表示符号位。需要注意的是指数部分采用移码表示,也就是exponent作为无符号数减去127得到的最终的指数。

为何float32只有6个精度

因为有效数字mantissa部分是23bit,而 1<<23 对应十进制的8388608,不能完整表示全部的7个十进制位,因此就只剩下6个有效数字了。因此 fmt.Printf 中的 %f 对应float32默认就只打印6个十进制数(因为多打印就超出mantissa的表示范围,不能准确表达)。

浮点数的诡异:浮点数有2个0

知道了浮点数的布局,就自然可以理解为什么浮点数会有2个0。因为浮点数有一个独立的符号位,只要吧0.0的符号位设置为负数就可以得到一个负数0:

fmt.Println(math.Float32frombits(0))     // 0
fmt.Println(math.Float32frombits(1<<31)) // -0

上面的代码通过在0的基础之上加 1<<31 将符号位设置为1,这样就得到了负0。

因此用浮点数作为map的Key时,可能会遇到一些诡异的事件。比如-0能取到0对应的值吗?

浮点数的诡异:没有0.3

第一次看到这个标题,有人可能有异议。证据在这里:

fmt.Println(0.3) // 打印出了0.3

但是作为一个杠精,我要说 fmt.Println 代码是有问题的,它可能作弊了,比如:

func fmt.Println(x) {
    if x == 0.3 {
        println("0.3")
    }
}

fmt.Println 内部打印的是字符串“0.3”,不是浮点数的0.3!

实际上fmt包真的是作弊了(而且作弊的算法也成了一门学问),不信看下面这个代码:

func main() {
    fmt.Printf("%f\n", 0.3)
    fmt.Printf("%.10f\n", 0.3)
    fmt.Printf("%.20f\n", 0.3)
}

// Output:
// 0.300000
// 0.3000000000
// 0.29999999999999998890

每次输出的结果都不一样,这不是作弊是什么?fmt包作弊打印0.3的原因是因为IEEE754中不存在0.3这个数!

不相信的话可以换成0.5试试:

func main() {
    fmt.Printf("%f\n", 0.5)
    fmt.Printf("%.10f\n", 0.5)
    fmt.Printf("%.20f\n", 0.5)
}

// 0.500000
// 0.5000000000
// 0.50000000000000000000

输出0.5时,就不会因为精度的变化导致输出结果发生抖动。

对于Go语言中,常量和变量的运算规则是不同的,因此 0.1+0.2 换成变量就会发生变化:

func main() {
    var a = 0.1
    var b = 0.2
    fmt.Println(0.1+0.2, a+b)
}
// 0.3 0.30000000000000004

第一个输出是0.3,第二个居然不是0.3。还好我们已经知道没有0.3这个浮点数,因此第一个 0.1+0.2 的结果也不是0.3。 不能表示0.3的原因是因为计算机采用的是二进制的科学记数法,而0.3无法通过用2的不同指数的状态组成得到。

不仅没有0.3,浮点数中缺少的数字多了去了。根据抽屉原理,float32只能表示2的32次方个状态,也就是40多亿个数。但是float32的值域可是已经大大超出了40亿的范围,因此里面必然有很多数在吃空饷。

无穷有正负

无穷在浮点数中对应一个特殊的数(或者说指数值最大的那种0)。参考前面的浮点数内存布局,指数有8个bit表示,最大的指数表示是255。

下面我们构造一个指数部分是255的0:

fmt.Println(math.Float32frombits(255<<23)) // +Inf    

这样得到的就是一个正的无穷(255最大的指数表示穷,0有效位表示无)。

有了正无穷,得到负无穷就比较容易了。只要加一个符号标志位即可:

fmt.Println(math.Float32frombits(255<<23 + 1<<31)) // -Inf

用科学记数法表示是这样: 0.0*2^(255-127)

Nan不是一个数,它表示的是一种bit模式

在IEEE754浮点数的指数值域中255是最重要的一个,因为无穷对应的指数就是255。在无穷中除了指数是255之外,有效位部分是0。那么问题来了,指数为255,有效位不是0的是啥玩意?

可以跑代码看看:

fmt.Println(math.Float32frombits(255<<23 + 1)) // NaN
fmt.Println(math.Float32frombits(255<<23 + 2)) // NaN

它们都是NaN,也就是Not-a-Number,非数不是数。NaN不是一个非数,而是一类非数。

Nan有个重要的特性,就是自己和自己都不相等(想想如果用它作为map的key会有什么效果)。Nan是非法的运算得来的,比如 sqrt(-1)0/0 都是非数。

浮点数不满足结合率

浮点数不满足结合率,比如 a+(b+c)(a+b)+c 不等价。具体原因和开头的 x=x+1 方程类似。

如果 x=x+1 成立,但是 x=x+2 并不一定成立。如果 x!=x+2 ,那么显然它和 x=(x+1)+1 结果是不等价的。

四舍五入是错误的

四舍五入分为2个部分,四舍还是四舍,但是五入就不一定是五入了。还存在五舍的情形。五作为一个绝对中间的位置,凭什么总是要五入(借钱的人和还钱的人肯定也有不同的看法)?

那什么时候需要五舍?根据计算的结果长得是否漂亮,选择五舍或五入。

指数为什么要采用移码

在早年间,浮点数运算芯片是一个奢侈的玩意。码农们都不做浮点数运算。但是运气也有背的时候,比如需要给一个浮点数表示的数组排序。

正如凌凌漆所言,IEEE754并非浪得虚名。即使没有浮点数芯片,码农们依然可以快速给浮点数数组排序:将浮点数数组当中整数数字进行排序就可以了。

为何?因为浮点数数组有序的话,那么同样bit模式的整数数组依然是有序的。其中第一个原因是符号位保证有序,第二个原因是指数采用移码保证大指数较大,最后几十有效数字部分比较小数点部分大数字。

解浮点数方程: x+1=x

不是纯数学意义上的方程, 对应计算机的一个浮点数问题:

if((float)(x+1.0) == (float)(x)) { x = ? }

简单分析, ieee754中float采用23bit表示有效位, 再加省略的1, 共有24bit.

当结果超出24bit时, 小数部分被被丢失:

var a float32 = 1 << 24
var b float32 = a+1

fmt.Println(math.Float32bits(a)) // 1266679808
fmt.Println(math.Float32bits(b)) // 1266679808

因此浮点数运算是不满足结合率的!

浮点数分类

首先是符号位,根据符号位可以分为正数和负数两类(包括两种0)。不过符号位分类比较简单,这里忽略。

此外,根据指数位和有效数字部分的不同组合,可以分为以下几种:

switch {
case 指数 在 1-254 之间:
    // 规范的浮点数(不是0,也不是很小的浮点数)
    // 也就是小数点前是1,但是这位被省略了
    // 我们平时见到的非0浮点数大多数都是这类
case 指数 == 0:
    if 有效数 == 0 {
        // 这是 0
    } else {
        // 非规范的浮点数,对应很小的浮点数
        // 也就是小数点前是0,但是这位被省略了
    }
case 指数 == 255:
    if 有效数 == 0 {
        // 无穷
    } else {
        // NaN
    }
}

所以说指数部分是IEEE754的精髓。

浮点数在数轴的分布

nIVNVfQ.png!web

浮点数分布不均匀:越靠近原点越密;同一指数阶码分布均匀。

课后习题

  1. JSON为何没有支持int64类型?

  2. 可参与算术运算的float32数有多少个?,bit模式有多少种?

mMRj2ma.jpg!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK