浮点数陷阱
猜猜下面程序的输出是什么?
func main() { var a, b float64 a = 0.3 b = 0.6 fmt.Println(a + b)}运行后结果如下:
[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 位浮点数

以 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 时,有两种特殊情况,这具体取决于尾数位的值:
- 无穷大:
- 如果尾数位全为 0,那么这个浮点数表示无穷大。
- 正无穷大表示为符号位为 0,指数位全为 1,尾数位全为 0。
- 负无穷大表示为符号位为 1,指数位全为 1,尾数位全为 0。
- 非数(NaN,Not a Number):
- 如果尾数位不全为 0,那么这个浮点数表示一个非数(NaN)。
- NaN 用于表示某些未定义的或不可表示的计算结果,如 0 除以 0,负数的平方根,或者超出浮点数表示范围的数值运算结果。
- 无穷大:
- 当指数位全为 0 时,有两种特殊情况,这具体取决于尾数位的值:
- 零:
- 如果尾数位全为 0,那么这个浮点数表示零(0)。
- 正零表示为符号位为 0,指数位全为 0,尾数位全为 0。
- 负零表示为符号位为 1,指数位全为 0,尾数位全为 0。
- 非规格化数:
- 如果尾数位不全为 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 位浮点数:
- 指数位最小有效值是 00000001,因为 00000000 是保留给非规格化数和 0 的特殊情况。实际的指数值是 1 减去偏移量 127,得到-126。
- 尾数位最小值是当所有位都是 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 位浮点数:
- 指数位为 00000000,表示一个非规格化数。但是非规格化的指数并不是 0-127=-127,而是固定的 1-偏移量,对于 32 位浮点数也就是 1-127=-126,所以非规格化的指数是固定的-126。
- 尾数位可以从 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 转换为二进制大致如下:
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)...这个过程一直重复下去,得到的二进制小数是:
0.01001100110011001...(后面就是1001的无限循环)2. 规格化二进制数
为了将这个数放到 IEEE 754 格式中,我们需要将其规格化。规格化就是将小数点移动到第一个 1 的右边,然后记录移动了多少位。对于 0.3,规格化后的数是:
1.0011001100110011001100110011001100110011001100110011… × 2^-2^
3. 编码指数
由于 IEEE 754 的指数字段使用偏移量(或称为指数偏置),对于 32 位浮点数,这个偏移量是 127。所以实际指数-2 需要加上 127,得到 125,然后将 125 转换为二进制表示:
125(十进制) = 01111101(二进制)4. 编码尾数
只取规格化二进制小数中小数点后的部分,并且在 IEEE 754 标准中,由于前面总是 1,所以不需要存储这个 1。我们只需要存储后面的部分,并且如果不够 23 位,就用 0 来填充。对于 0.3,我们取它的二进制小数部分的前 23 位:
0011 0011 0011 0011 0011 0015. 组合成最终的 IEEE 754 表示
最后,我们把所有部分组合起来,得到 0.3 的 IEEE 754 32 位表示:
- 符号位:0(因为 0.3 是正数)
- 指数位:01111101
- 尾数位:00110011001100110011001
所以,0.3 的 32 位 IEEE 754 浮点数表示为:
0 01111101 00110011001100110011001这个二进制序列就是十进制数 0.3 以 32 位单精度浮点格式存储时的大致样子。实际的值会按照这个二进制表示进行四舍五入处理,因此这不是一个精确的等价,而是一个非常接近的近似值。
显示浮点数格式
Go 语言标准库的 math 包提供了许多有用的计算函数,其中,Float32 可以以字符串的形式打印出单精度浮点数的二进制值。
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])}运行结果如下:
[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。
func main() { var z float64 fmt.Println(z, -z, 1/z, -1/z, z/z, math.Sqrt(-1))}运行结果如下:
[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。
func main() { nan := math.NaN() fmt.Printf("%T\n", nan) fmt.Println(nan*10, nan == nan, nan > nan, nan < nan)}运行结果如下:
[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 中定义。
// 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}用法如下:
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))}运行结果如下:
[deep@binary float]$ go run main.goz/z是否是NaN: true1/z是否是正无穷: true-1/z是否是负无穷: true浮点数精度
精度是一个复杂的概念,大部分人对精度有一些误解。当在互联网上搜索单精度浮点数的精度到底为多少时,会看到许多不同的答案:6 位、7 位、8 位。然而实际情况是浮点数的精度是不固定的,并且,一般在谈论浮点数的精度时都有一个默认的前提,即讨论的是二进制浮点数的十进制精度。理论表明,单精度浮点数 float32 的精度为 6~8 位,双精度浮点数 float64 的精度为 15~17 位。
精度丢失问题
浮点数不管是存储还是计算都会有精度丢失的问题存在。
func main() { var a float32 for i := 0; i < 10; i++ { a += 0.01 fmt.Println(a) }}运行结果如下:
[deep@binary float]$ go run main.go0.010.020.030.040.0499999970.0599999950.069999990.079999990.089999990.09999999由于 0.01 无法用二进制浮点数精确表示,因为它在二进制中是一个无限重复的小数。因此,它被存储为一个接近但不完全相同的数。因为这个原始的舍入误差,每次循环时 a 的值都会有一点点的误差。这些误差在第 5 次累加之后变得可见,导致最终结果偏离了我们期望的精确结果 0.1。
这个问题说明了:
- 二进制浮点数的局限性:并非所有的十进制数都能够在二进制浮点数系统中精确表示。这导致即使是非常简单的十进制数,如 0.1,也可能会在存储和计算时产生舍入误差。
- 误差累积:在重复的运算中,这些微小的误差会累积,最终可能导致一个明显的偏差。
- 浮点数比较的问题:由于这个原因,直接比较两个浮点数是否相等通常是不安全的。如果需要这样的比较,你应该定义一个足够小的差值(称为 epsilon),然后比较两个数的差值是否小于这个 epsilon。
math/big 库
在 Go 语言中,math/big 库提供了实现大数计算的工具。这意味着这个包可以处理超过 Go 标准数字类型所能表示的范围的整数、有理数和浮点数。这对于加密、科学计算、高精度计算等场合特别有用,因为在这些领域经常会遇到非常大或非常精确的数字计算。
math/big 库中的数据类型主要有三种:
big.Int:表示大整数。这个类型没有固定大小的限制,可以存储任意大小的整数值。big.Float:表示高精度的浮点数。用户可以指定所需的精度,以便在不丢失精度的情况下执行浮点运算。big.Rat:表示有理数,也就是分数,它由两个 big.Int 类型的值组成,一个作为分子,另一个作为分母。
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))}运行结果如下:
[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- 比较大小