问题呈现
在Go语言中,有两种浮点数类型(虚数除外):float32和float64. 浮点数是用来解决整数不能表示小数的问题。我们需要知道浮点数算术运算是实数算术运算的近似,下面通过例子说明浮点数运算采用近似值的影响以及如何提高计算精度。
var n float32 = 1.0001
fmt.Println(n * n)
上面的程序,我们的预期结果可能是 1.0001 * 1.0001 = 1.00020001。然而,实际上在大多数的x86处理器上,运行结果为 1.0002。
原因分析
如何解释这种差异呢?我们先来理解浮点数运算规则。
以float64为例,在math.SmallestNonzeroFloat64(float64的最小值)到math.MaxFloat64(float64的最大值)区间内有无穷尽个实数值。但是float64是用64个bit位表示的,将无穷尽的实数一一映射到有限的64个bit上是无法实现的。必须采用近似值的方法,丢失一些精度信息。同理对于float32类型,也是这样。
Go语言中的浮点数遵循IEEE-754标准,用部分bit位表示尾数,另一部分bit位表示指数。尾数用来表示基本值,指数将与尾数进行相乘得到的结果为最终的数值。在单精度浮点类型(float32)中,用8个bit位表示指数,23个bit位表示尾数,还有1个bit位是符号位。在双精度浮点类型(float64)中,分别用11个和52个bit位表示指数和尾数,剩下的1个bit位表示符合。可以用下面的计算公式将浮点数转为十进制数。
sign * 2^exponent * mantissa
下图是数值1.0001(float32)在IEEE-754下的计算机表示。阶码由8个bit位构成:01111111, 而阶码=原码+偏置值, 8位的偏值为:2^(8-1)-1=127. 所以原码的值为0,即exponent为0. mantissa的值为1.000100016593933. 因此它的十进制数为: 1 × 2^0 × 1.000100016593933. 原本1.0001在计算机中的存储的实际值是1.000100016593933,所以缺少精度会影响存储值的准确性。
解决方法
通过上面的一个具体的例子了解了浮点数在计算机中存储的是近似值。那我们在开发程序的时候需要注意什么呢?第一个需要注意的是比较操作,使用 == 运算符比较两个浮点数可能会导致不准确。我们应该比较它们的差值,看差值是否在一个小的误差内。例如,用于测试的testify(https://github.com/stretchr/testify)库有一个InDelta函数来断言两个值是否在给定的delta范围内。第二个需要注意的是浮点数的结果取决于实际的处理器。大多数处理器都有一个浮点单元(FPU)来处理这种计算,不能保证在一台机器上执行的结果在另一台具有不同FPU的机器上相同。通过比较差值是否在一定的范围内可能是跨不同机器实现有效测试的解决方案。
经验一:用好三种特殊浮点数
Go语言中还有三种特殊的浮点数:正无穷大、负无穷大、NaN(Not-a-Number)。根据IEEE-754标准,NaN是唯一满足 f!=f的浮点数。下面是创建特殊浮点数的示例。
var a float64
positiveInf := 1 / a
negativeInf := -1 / a
nan := a / a
fmt.Println(positiveInf, negativeInf, nan)
+Inf -Inf NaN
我们可以使用math库中的math.IsInf检查浮点数是否为无穷大,以及使用math.IsNaN检查浮点数是否为NaN.
经验二:注意累积放大偏差
十进制数到浮点数的转换可能存在精度下降,这是由于转换导致的错误。此外,还要注意错误可以在一系列浮点运算中累积, 通过下面这个例子进行说明。f1和f2函数以不同的顺序执行相同的操作,在f1函数中,result先被初始化为float64类型的10000, 然后在循环中每次自增1.0001。相反,f2函数先进行自增操作,然后增加10000.
func f1(n int) float64 {
result := 10_000.
for i := 0; i < n; i++ {
result += 1.0001
}
return result
}
func f2(n int) float64 {
result := 0.
for i := 0; i < n; i++ {
result += 1.0001
}
return result + 10_000.
}
在x86处理器上执行上述计算,得到结果如下。可以看到,n越大,不精确性越大。f2的精度比f1要高。
n | Exact result | f1 | f2 |
---|---|---|---|
10 | 10010.0001 | 10010.000999999993 | 10010.001 |
1k | 11000.1 | 11000.099999999293 | 11000.099999999982 |
1m | 1.0101e+06 | 1.0100999999761417e+06 | 1.0100999999766762e+06 |
如果对浮点数进行乘法和除法运算,结果是什么样的呢?现在假设要执行下面的运算操作:
a * ( b + c )
作所周知,运用数学分配率,上面的结果和下面的是一样的。
a * b + a * c
现在通过程序进行验证以下,看看是否如上面的预期一样。代码如下:
a := 100000.001
b := 1.0001
c := 1.0002
fmt.Println(a * (b + c))
fmt.Println(a*b + a*c)
运行上述程序,得到的结果如:
200030.00200030004
200030.0020003
准确的结果应该是200030.002,所以第一种计算方法得到的精度最差。事实上,当执行操作涉及加法、减法、乘法和除法时,先进行乘法和除法运算,能够获得更好的精度。虽然,这可能会影响执行时间(第二种计算方法需要3步操作,第一种方法只需两步操作),但这是执行结果准确度和执行时间之间权衡的选择。
思考总结
Go语言中float32和float64在计算机中是一种近似值表示,因此,我们必须牢记下面的规则:
-
当比较两个浮点数时,检查它们的差值是否在可接受的范围内,而不是直接 == 进行比较
-
当执行加法或减法时,为了获得更好的精度,可以根据运算级进行分组
-
为了提高准确性,如果一系列运算需要加法、减法、乘法或除法,先执行乘法和除法运算