浮点数底层原理

标签:Go语言首次发布:2024-01-08最近修改:2024-04-02

浮点数陷阱

猜猜下面程序的输出是什么?

go
func main() {    var a, b float64    a = 0.3    b = 0.6    fmt.Println(a + b)}

运行后结果如下:

text
[deep@binary float]$ go run main.go0.8999999999999999

刚开始我也天真地认为其输出结果是 0.9,但实际的输出结果是 0.8999999999999999。所以,必须深入理解浮点数在计算机中的存储方式及性质,才能正确处理数字的计算问题。golang 与很多其他语言(C、C++、Python…)一样,使用了 IEEE-754 标准存储浮点数。

定点数与浮点数

定点数

定点表示法是一种用整数来模拟小数的表示方式。在这种表示法中,数的小数点位置是固定的。通常将一个数分为整数部分和小数部分,并预先约定小数点的位置。使用定点表示法的表示的数字就是定点数。概念可能有点抽象,举个例子:

在十进制中 12.75 可以写为 1*101 + 2*100 + 7*10-1 + 5*10-2 。也就是小数点前面的是 10 的正数次幂,小数点后面的是 10 的负数次幂。在二进制中也是同样的道理,假如在一个 8 位二进制中,预先规定前四位为整数位,后四位为小数位。那么 01001101 可以表示为 0100.1101,转换为十进制就是 4.8125(4 是整数部分,0.8125 是小数部分,0100 二进制对应的十进制是 4,1101 二进制对应的十进制是 1*2-1 + 1*2-2 + 0*2-3 + 2-4 = 0.8125)。

定点数的优点是计算速度快,尤其是在没有浮点硬件支持的系统中。其缺点是表示范围和精度有限:一旦小数点位置固定,就只能表示一定范围内的数,而且小数部分的精度也受到限制。

浮点数

浮点数的表示方式更为灵活。它不是将小数点固定在某个位置,而是将数表示为尾数和指数的形式。这相当于科学记数法的二进制版本。在科学记数法中,一个数被表示为一个尾数(这个数的有效数字)乘以 10 的某个指数。例如:

  • 123.45 可以表示为 1.2345 x 102
  • 0.006 可以表示为 6 x 10-3

同样地,浮点数在计算机中的表示也采用了分离的尾数和指数,但基数是 2 而不是 10。一个浮点数的例子:

  • 6.5 在二进制中可以表示为 110.1,这可以被写为 1.101 x 22。在浮点表示中,1.101 是尾数,2 是指数。

32 位浮点数

image-20231228193115048

以 32 位浮点数为例,分析如下:

  • 符号位(sign):最高的 1 位是符号位,0 代表正数,1 代表负数。
  • 指数位(exponent):用于存储指数的值,但是这个值是有偏移量的。
  • 尾数位(fraction):用于存储小数部分。

尽管 32 位的浮点数的存储分布看上去很简单,但是这里还有很多需要注意的地方。

  • 使用科学计数法后,尾数的取值范围一定是[1,2),可以取 1 但不能取到 2。因此规定尾数在存储时舍弃第一个 1,只存储小数点之后的数字,这样可以节省存储空间。
  • 8 位的指数位是一个无符号位,所以其取值范围位 0~255。但是全 0 和全 1 的指数位有特殊含义(下面会分析),所以,正常情况下,指数位的取值范围是 1~254,一共 254 个数值。
  • 由于指数位只能表示正数。但是当一个数字小于 1 的时候,比如 0.5 表示为 1.0 x 2-1,指数位是-1,是一个负数。为了解决指数位不能存储负数的问题,引入了偏移量,也就是对于 32 位的浮点数,存储的指数值要加上 127。为什么是 127?而不是 128 呢?其实偏移量的选择是为了最大化浮点数的动态范围,这意味着应该能够表示尽可能大和尽可能小的浮点数。最小的非零指数(不考虑非规格化数)是 1,最大的非特殊(不是无穷大或 NaN)指数是 254,所以选择折中的 127 作为偏移量有助于避免上溢和下溢。
  • 当指数位全为 1 时,有两种特殊情况,这具体取决于尾数位的值:
    1. 无穷大:
      • 如果尾数位全为 0,那么这个浮点数表示无穷大。
      • 正无穷大表示为符号位为 0,指数位全为 1,尾数位全为 0。
      • 负无穷大表示为符号位为 1,指数位全为 1,尾数位全为 0。
    2. 非数(NaN,Not a Number):
      • 如果尾数位不全为 0,那么这个浮点数表示一个非数(NaN)。
      • NaN 用于表示某些未定义的或不可表示的计算结果,如 0 除以 0,负数的平方根,或者超出浮点数表示范围的数值运算结果。
  • 当指数位全为 0 时,有两种特殊情况,这具体取决于尾数位的值:
    1. 零:
      • 如果尾数位全为 0,那么这个浮点数表示零(0)。
      • 正零表示为符号位为 0,指数位全为 0,尾数位全为 0。
      • 负零表示为符号位为 1,指数位全为 0,尾数位全为 0。
    2. 非规格化数:
      • 如果尾数位不全为 0,那么这个浮点数表示一个非规格化数。
      • 非规格化数允许表示非常接近于零的数,但是它们没有一个隐含的前导 1(与规格化数相反)。这意味着非规格化数不具备完全的精度。
      • 非规格化数的实际指数比规格化数的最小指数还要小,对于 32 位单精度浮点数来说,这个实际指数是 2−126
最大正规数

最大数产生于当指数最大且尾数也最大时。对于 32 位浮点数:

  • 指数的最大值为 254(255 有特殊用处),但是要从中减去偏移量(127),所以实际的最大指数为 254-127 = 127。
  • 尾数的最大值是当所有 23 位都是 1 时。由于存在隐含的 1,所以尾数的最大值是 1+(2-1+2-2+······+2-23) = 1+(1−2−23) = 2−2−23

因此,最大的正规数近似为:2127×(2−2−23)。换算成十进制,大约是 3.4028235×1038

最小正规数

最小的正规数发生在指数最小且尾数为最小正数的情况下。对于 32 位浮点数:

  1. 指数位最小有效值是 00000001,因为 00000000 是保留给非规格化数和 0 的特殊情况。实际的指数值是 1 减去偏移量 127,得到-126。
  2. 尾数位最小值是当所有位都是 0 时,即实际的尾数就是 1(对于规格化数,尾数的隐含前导位是 1)。

因此,最小的正规数是:2−126。换算成十进制,大约是 1.17549435×10−38

所以,规格化的 32 位浮点数可以表示为 [−3.4×1038,−1.2×10−38] [1.2×10−38,3.4×1038] 之间的数值。

非规格化数

非规格化数提供了一个非零值小于最小正规数的范围。对于 32 位浮点数:

  1. 指数位为 00000000,表示一个非规格化数。但是非规格化的指数并不是 0-127=-127,而是固定的 1-偏移量,对于 32 位浮点数也就是 1-127=-126,所以非规格化的指数是固定的-126。
  2. 尾数位可以从 000······001 到 111······111,由于非规格化数没有隐含的 1,所以尾数的范围就是从 2-23 到 1-223 )。

最小的非规格化数是当尾数位为最小正数,即:2−126 × 2−23 = 2−149。换算成十进制,大约是 1.40129846×10−45

最大的非规格化数是当尾数位为最大正数,即:2−126 × (1-2-23)。换算成十进制,大约是 1.1754942106924411×10−38

32 位与 64 位浮点数区别

32 位的单精度浮点数与 64 位的双精度浮点数的差异:

精度 符号位 指数位 尾数位 偏移量
32 位 1 8 23 127
64 位 1 11 52 1023

最后,浮点表示法的优点是可以表示非常大或非常小的数,范围和精度远远超过定点数。浮点运算在科学计算和需要很大数值范围的应用中非常有用。其缺点是计算比定点慢,并且由于尾数和指数的有限位数,可能会有舍入错误。

十进制浮点数转换为二进制

十进制浮点数转换为二进制浮点数的过程可以分为几个步骤。下面详细说明如何将十进制数 0.3 转换为符合 IEEE 754 标准的 32 位(单精度)二进制浮点数。

1. 将十进制小数转换为二进制小数

首先,将十进制的 0.3 转换为二进制小数。0.3 乘以 2 的结果是 0.6,所以小数点左边是 0;再将 0.6 乘以 2,得到 1.2,小数点左边是 1,以此类推。这个过程产生了一个重复的二进制序列,因为 0.3 不能用有限的二进制小数精确表示。

十进制的 0.3 转换为二进制大致如下:

text
0.3 × 2 = 0.6 → 00.6 × 2 = 1.2 → 1 (保留0.2)0.2 × 2 = 0.4 → 00.4 × 2 = 0.8 → 00.8 × 2 = 1.6 → 1 (保留0.6)0.6 × 2 = 1.2 → 1 (保留0.2)0.2 × 2 = 0.4 → 00.4 × 2 = 0.8 → 00.8 × 2 = 1.6 → 1 (保留0.6)...

这个过程一直重复下去,得到的二进制小数是:

text
0.01001100110011001...(后面就是1001的无限循环)

2. 规格化二进制数

为了将这个数放到 IEEE 754 格式中,我们需要将其规格化。规格化就是将小数点移动到第一个 1 的右边,然后记录移动了多少位。对于 0.3,规格化后的数是:

1.0011001100110011001100110011001100110011001100110011… × 2^-2^

3. 编码指数

由于 IEEE 754 的指数字段使用偏移量(或称为指数偏置),对于 32 位浮点数,这个偏移量是 127。所以实际指数-2 需要加上 127,得到 125,然后将 125 转换为二进制表示:

text
125(十进制) = 01111101(二进制)

4. 编码尾数

只取规格化二进制小数中小数点后的部分,并且在 IEEE 754 标准中,由于前面总是 1,所以不需要存储这个 1。我们只需要存储后面的部分,并且如果不够 23 位,就用 0 来填充。对于 0.3,我们取它的二进制小数部分的前 23 位:

text
0011 0011 0011 0011 0011 001

5. 组合成最终的 IEEE 754 表示

最后,我们把所有部分组合起来,得到 0.3 的 IEEE 754 32 位表示:

  • 符号位:0(因为 0.3 是正数)
  • 指数位:01111101
  • 尾数位:00110011001100110011001

所以,0.3 的 32 位 IEEE 754 浮点数表示为:

text
0 01111101 00110011001100110011001

这个二进制序列就是十进制数 0.3 以 32 位单精度浮点格式存储时的大致样子。实际的值会按照这个二进制表示进行四舍五入处理,因此这不是一个精确的等价,而是一个非常接近的近似值。

显示浮点数格式

Go 语言标准库的 math 包提供了许多有用的计算函数,其中,Float32 可以以字符串的形式打印出单精度浮点数的二进制值。

go
func main() {    var number float32    number = 0.3    fmt.Printf("十进制格式:%f\n", number)    bits := math.Float32bits(number)    binary := fmt.Sprintf("%.32b\n", bits)    fmt.Printf("二进制格式:%s | %s | %s\n", binary[0:1], binary[1:9], binary[9:32])}

运行结果如下:

text
[deep@binary float]$ go run main.go十进制格式:0.300000二进制格式:0 | 01111101 | 00110011001100110011010

注意尾数部分,最后两位与前面得出的尾数不同,这是因为我没有对无限的二进制小数进行舍入。在 Go 代码中,舍入操作是由硬件浮点单元和/或编译器的浮点运算库自动完成的。这些实现通常遵循 IEEE 754 标准,并使用精确的算法来确定如何舍入到最接近的可表示浮点数。

Nan 和无穷

在 Go 语言中有正无穷(+Inf)与负无穷(-Inf)两类异常的值,例如正无穷 1/0。NaN 代表异常或无效的数字,例如 0/0 或者 Sqrt(-1)。下面代码中分别构造出+Inf、-Inf 与 NaN。

go
func main() {    var z float64    fmt.Println(z, -z, 1/z, -1/z, z/z, math.Sqrt(-1))}

运行结果如下:

text
[deep@binary float]$ go run main.go0 -0 +Inf -Inf NaN NaN

在 IEEE-754 标准中,NaN 分为 sNAN 与 qNAN。qNAN 代表出现了无效或异常的结果,sNAN 代表发生了无效的操作,例如将字符串转化为浮点数。qNAN 的指数位全为 1,且小数位的第一位为 1;sNAN 的指数位全为 1,但是小数位的第一位为 0。用 math.NaN 函数可以生成一个 NaN,对 NAN 的任何操作都会返回 NAN。另外,对 NAN 的任何比较都会返回 false。

go
func main() {    nan := math.NaN()    fmt.Printf("%T\n", nan)    fmt.Println(nan*10, nan == nan, nan > nan, nan < nan)}

运行结果如下:

text
[deep@binary float]$ go run main.gofloat64NaN false false false

有些时候需要判断浮点数是否为 NaN 或者 Inf,这需要借助 Math.IsNaN 和 Math.IsInf 函数。其判断条件很简单,在 IEEE-754 标准中,NaN!=NaN 会返回 true,Go 语言编译器在判断浮点数时,浮点数的比较会被编译成 UCOMISD 或 COMISD 的 CPU 指令,该指令会判断和处理 NaN 等异常情况从而实现当 NaN!=NaN 时返回 true。可以通过判断浮点数是否在有效的范围内来检查其是否为 Inf。浮点数的最大和最小值的常量在 math/const.go 中定义。

go
// 0x表示接下来是一个十六进制数。// 1是十六进制数的有效位部分,它代表了这个浮点数的十六进制尾数。// p表示接下来的数字是以2为底的指数部分。const (    MaxFloat32             = 0x1p127 * (1 + (1 - 0x1p-23))    SmallestNonzeroFloat32 = 0x1p-126 * 0x1p-23    MaxFloat64             = 0x1p1023 * (1 + (1 - 0x1p-52))    SmallestNonzeroFloat64 = 0x1p-1022 * 0x1p-52)func IsNaN(f float64) (is bool) {    // IEEE 754 says that only NaNs satisfy f != f.    // To avoid the floating-point hardware, could use:    //	x := Float64bits(f);    //	return uint32(x>>shift)&mask == mask && x != uvinf && x != uvneginf    return f != f}// 如果sign > 0,IsInf检查f是否为正无穷大。// 如果sign < 0,IsInf检查f是否为负无穷大。// 如果sign == 0,IsInf检查f是否为无穷大,不论正负。func IsInf(f float64, sign int) bool {    // Test for infinity by comparing against maximum float.    // To avoid the floating-point hardware, could use:    //	x := Float64bits(f);    //	return sign >= 0 && x == uvinf || sign <= 0 && x == uvneginf;    return sign >= 0 && f > MaxFloat64 || sign <= 0 && f < -MaxFloat64}

用法如下:

go
func main() {    var z float64    fmt.Printf("z/z是否是NaN: %#v\n", math.IsNaN(z/z))    fmt.Printf("1/z是否是正无穷: %#v\n", math.IsInf(1/z, 1))    fmt.Printf("-1/z是否是负无穷: %#v\n", math.IsInf(-1/z, -1))}

运行结果如下:

text
[deep@binary float]$ go run main.goz/z是否是NaN: true1/z是否是正无穷: true-1/z是否是负无穷: true

浮点数精度

精度是一个复杂的概念,大部分人对精度有一些误解。当在互联网上搜索单精度浮点数的精度到底为多少时,会看到许多不同的答案:6 位、7 位、8 位。然而实际情况是浮点数的精度是不固定的,并且,一般在谈论浮点数的精度时都有一个默认的前提,即讨论的是二进制浮点数的十进制精度。理论表明,单精度浮点数 float32 的精度为 6~8 位,双精度浮点数 float64 的精度为 15~17 位。

精度丢失问题

浮点数不管是存储还是计算都会有精度丢失的问题存在。

go
func main() {    var a float32    for i := 0; i < 10; i++ {        a += 0.01        fmt.Println(a)    }}

运行结果如下:

text
[deep@binary float]$ go run main.go0.010.020.030.040.0499999970.0599999950.069999990.079999990.089999990.09999999

由于 0.01 无法用二进制浮点数精确表示,因为它在二进制中是一个无限重复的小数。因此,它被存储为一个接近但不完全相同的数。因为这个原始的舍入误差,每次循环时 a 的值都会有一点点的误差。这些误差在第 5 次累加之后变得可见,导致最终结果偏离了我们期望的精确结果 0.1。

这个问题说明了:

  1. 二进制浮点数的局限性:并非所有的十进制数都能够在二进制浮点数系统中精确表示。这导致即使是非常简单的十进制数,如 0.1,也可能会在存储和计算时产生舍入误差。
  2. 误差累积:在重复的运算中,这些微小的误差会累积,最终可能导致一个明显的偏差。
  3. 浮点数比较的问题:由于这个原因,直接比较两个浮点数是否相等通常是不安全的。如果需要这样的比较,你应该定义一个足够小的差值(称为 epsilon),然后比较两个数的差值是否小于这个 epsilon。

math/big 库

在 Go 语言中,math/big 库提供了实现大数计算的工具。这意味着这个包可以处理超过 Go 标准数字类型所能表示的范围的整数、有理数和浮点数。这对于加密、科学计算、高精度计算等场合特别有用,因为在这些领域经常会遇到非常大或非常精确的数字计算。

math/big 库中的数据类型主要有三种:

  1. big.Int:表示大整数。这个类型没有固定大小的限制,可以存储任意大小的整数值。
  2. big.Float:表示高精度的浮点数。用户可以指定所需的精度,以便在不丢失精度的情况下执行浮点运算。
  3. big.Rat:表示有理数,也就是分数,它由两个 big.Int 类型的值组成,一个作为分子,另一个作为分母。
go
func main() {    a := new(big.Int)    // 将字符串12345678901234567890123456789转换为big.Int类型    a, ok := a.SetString("12345678901234567890123456789", 10) // 使用 base 10    if ok != true {      fmt.Println("设置失败")    }    b := new(big.Float).SetPrec(100) // 设置足够大的精度    b.SetString("0.12345678901234567890123456789")    c := new(big.Float).SetPrec(100)    c.SetString("0.00000000000000000000000000001")    d := new(big.Rat)    d.SetString("1/12345678901234567890123456789") // 设置为分数的字符串表示    fmt.Println("big.Int:", a)    fmt.Println("big.Float:", b)    fmt.Println("big.Rat:", d)    sum := new(big.Float).SetPrec(100).Add(b, c)    // 打印时使用 Text 方法来格式化输出    fmt.Println("sum:", sum.Text('f', 29))}

运行结果如下:

text
[deep@binary float]$ go run main.gobig.Int: 12345678901234567890123456789big.Float: 0.12345678901234567890123456789big.Rat: 1/12345678901234567890123456789sum: 0.12345678901234567890123456790

其他转换方法

  • SetBytes([]byte):将一个字节切片转换为 big.Int。
  • SetInt64(int64):直接将一个 int64 类型的值设置为 big.Int。
  • Set:用另一个 big.Int 或 big.Rat 来设置当前的 big.Int 或 big.Rat。
  • SetFloat64(float64):将一个 float64 类型的值设置为 big.Float。
  • SetRat(big.Int, big.Int):通过两个 big.Int 类型的分子和分母来设置 big.Rat。

其他运算方法

下面是一些常见的运算方法:

  • Add - 加法
  • Sub - 减法
  • Mul - 乘法
  • Quo - 除法
  • Neg - 取负数
  • Abs - 取绝对值
  • Cmp - 比较大小