钱,乃亘古之玄物,有则气粗神壮,缺则心卑力浅

在一个系统中,特别是一个和钱相关的系统,钱乃重中之重,计算时的精度将是本篇讨论的主题。

精度为何如此重要

“积羽沉舟”用在此处最为合适。假如某电商平台每年订单成交数量为10亿,每笔订单少结算1分钱,则累计损失1000万!有一说一,这损失的钱就是王某人的十分之一个小目标。如果因为精度问题在给客户结算时,少算会损失客户,多算会损失钱。由此可见,精确的计算钱十分重要!

为什么会有精度的问题

经典案例,我们来看一下0.1 + 0.2在计算机中是否等于0.3

上述case学过计算机的应该都知道,计算机是二进制的,用二进制表示浮点数时(IEEE754标准),只有少量的数可以用这种方法精确的表示出来。下面以0.3为例看一下十进制转二进制小数的过程。

计算机的位数有限制,因此计算机用浮点数计算时肯定无法得到精确的结果。这种硬限制无法突破,所以需要引入精度以保证对钱的计算在允许的误差范围内尽可能准确。

关于浮点数在计算机中的实际表示本文不做进一步讨论,可以参考下述连接学习:

单精度浮点数表示:

https://en.wikipedia.org/wiki/Single-precision_floating-point_format

双精度浮点数表示:

https://en.wikipedia.org/wiki/Double-precision_floating-point_format

浮点数转换器:

https://www.h-schmidt.net/FloatConverter/IEEE754.html

用浮点数计算

还是以上述0.1 + 0.2为例,0.00000000000000004的误差完全可以忽略,我们尝试小数部分保留5位精度,看下面结果。

此时的结果符合预期。这也是为什么很多时候判断两个浮点数是否相等往往采用a - b <= 0.00001的形式,说白了这就是小数部分保留5位精度的另一种表现形式。

用整型计算

前面提到只有少量的浮点数可以用IEEE754标准表示,而整型可精确表示所有有效范围内的数。因此很容易想到,使用整型表示浮点数。

例如,事先定好小数保留8位精度,则0.10.2分别表示成整数为1000000020000000, 浮点数的运算也就转换为整型的运算。还是以0.1 + 0.2为例。

// 表示小数位保留8位精度
const prec = 100000000

func float2Int(f float64) int64 {
	return int64(f * prec)
}

func int2float(i int64) float64 {
	return float64(i) / prec
}
func main() {
	var a, b float64 = 0.1, 0.2
	f := float2Int(a) + float2Int(b)
	fmt.Println(a+b, f, int2float(f))
	return
}

上述代码输出结果如下:

上述输出结果完全符合预期,所以用整型来表示浮点数看起来是一个可行的方案。但,我们不能局限于个例,还需要更多的测试。

fmt.Println(float2Int(2.3))

上述代码输出结果如下:

这个结果是如此的出乎意料,却又是情理之中。

上图表示2.3在计算机中实际的存储值,因此使用float2Int函数进行转换时的结果是229999999而不是230000000

这个结果很明显不符合预期,在确定的精度范围内仍有精度损失,如果把这个代码发到线上,很大概率第二天就会光速离职。要解决这个问题也很简单,只需引入github.com/shopspring/decimal即可,看下面修正后的代码。

// 表示小数位保留8位精度
const prec = 100000000

var decimalPrec = decimal.NewFromFloat(prec)

func float2Int(f float64) int64 {
	return decimal.NewFromFloat(f).Mul(decimalPrec).IntPart()
}

func main() {
	fmt.Println(float2Int(2.3)) // 输出:230000000
}

此时结果符合预期,系统内部的浮点运算(加法、减法、乘法)均可转换为整型运算,而运算结果只需要一次浮点转换即可。

到这里,用整型计算基本能满足大部分场景,但仍有两个问题尚需注意。

1、整型表示浮点数的范围是否满足系统需求。

2、整型表示浮点数时除法依旧需要转换为浮点数运算。

整型表示浮点数的范围

int64为例,数值范围为-9223372036854775808~9223372036854775807,如果我们对小数部分精度保留8位,则剩余表示整数部分依旧有11位,即只表示钱的话仍旧可以存储上百亿的金额,这个数值对很多系统和中小型公司而言已经绰绰有余,但是使用此方式存储金额时范围依旧是需要慎重考虑的问题。

整型表示浮点数的除法

在Go中没有隐式的整型转浮点的说法,即整型和整型相除得到的结果依旧是整型。我们以整型表示浮点数时,就尤其需要注意整型的除法运算会丢失所有的小数部分,所以一定要先转换为浮点数再进行相除。

浮点和整型的最大精度

int64的范围为-9223372036854775808~9223372036854775807,则用整型表示浮点型时,整数部分和小数部分的有效十进制位最多为19位。

uint64的范围为0~18446744073709551615,则用整型表示浮点型时,整数部分和小数部分的有效十进制位最多为20位,因为系统中表示金额时一般不会存储负数,所以和int64相比,更加推荐使用uint64

float64根据IEEE754标准,并参考维基百科知其整数部分和小数部分的有效十进制位为15-17位。

我们看下面的例子。

var (
	a float64 = 123456789012345.678
	b float64 = 1.23456789012345678
)

fmt.Println(a, b, decimal.NewFromFloat(a), a == 123456789012345.67)
return

上述代码输出结果如下:

根据输出结果知,float64无法表示有效位数超过17位的十进制数。从有效十进制位来讲,老许更加推荐使用整型表示浮点数。

计算中尽量保留更多的精度

前面提到了精度的重要性,以及整型和浮点型可表示的最大精度,下面我们以一个实际例子来探讨计算过程中是否要保留指定的精度。

var (
	// 广告平台总共收入7.11美元
	fee float64 = 7.1100
	// 以下是不同渠道带来的点击数
	clkDetails = []int64{220, 127, 172, 1, 17, 1039, 1596, 200, 236, 151, 91, 87, 378, 289, 2, 14, 4, 439, 1, 2373, 90}
	totalClk   int64
)
// 计算所有渠道带来的总点击数
for _, c := range clkDetails {
	totalClk += c
}
var (
	floatTotal float64
	// 以浮点数计算每次点击的收益
	floatCPC float64 = fee / float64(totalClk)
	intTotal int64
	// 以8位精度的整形计算每次点击的收益(每次点击收益转为整形)
	intCPC        int64 = float2Int(fee / float64(totalClk))
	intFloatTotal float64
	// 以8位进度的整形计算每次点击的收益(每次点击收益保留为浮点型)
	intFloatCPC  float64 = float64(float2Int(fee)) / float64(totalClk)
	decimalTotal         = decimal.Zero
	// 以decimal计算每次点击收益
	decimalCPC = decimal.NewFromFloat(fee).Div(decimal.NewFromInt(totalClk))
)
// 计算各渠道点击收益,并累加
for _, c := range clkDetails {
	floatTotal += floatCPC * float64(c)
	intTotal += intCPC * c
	intFloatTotal += intFloatCPC * float64(c)
	decimalTotal = decimalTotal.Add(decimalCPC.Mul(decimal.NewFromInt(c)))
}
// 累加结果对比
fmt.Println(floatTotal) // 7.11
fmt.Println(intTotal) // 710992893
fmt.Println(decimal.NewFromFloat(intFloatTotal).IntPart()) // 711000000
fmt.Println(decimalTotal.InexactFloat64()) // 7.1100000000002375

对比上面的计算结果,只有第二种精度最低,而造成该精度丢失的主要原因是float2Int(fee / float64(totalClk))将中间计算结果的精度也只保留了8位,因此在结果上面产生了误差。其他计算方式在中间计算过程中尽可能的保留了精度因此结果符合预期。

除法和减法的结合

根据前面的描述,在计算除法的过程中要使用浮点数且尽可能保留更多的精度。这依旧不能解决所有问题,我们看下面的例子。

// 1元钱分给3个人,每个人分多少?
var m float64 = float64(1) / 3
fmt.Println(m, m+m+m)

上述代码输出结果如下:

由计算结果知,每人分得0.3333333333333333元,而将每人分得的钱再次汇总时又变成了1元,那么 这0.0000000000000001元是从石头里面蹦出来的嘛!有些时候我真的搞不懂这些计算机。

这个结果很明显不符合人类的直觉,为了更加符合直觉我们结合减法来完成本次计算。

// 1元钱分给3个人,每个人分多少?
var m float64 = float64(1) / 3
fmt.Println(m, m+m+m)
// 最后一人分得的钱使用减法
m3 := 1 - m - m
fmt.Println(m3, m+m+m3)

上述代码输出结果如下:

通过减法我们终于找回了那丢失的0.0000000000000001元。当然上面仅是老许举的一个例子,在实际的计算过程中可能需要通过decimal库进行减法以保证钱不凭空消失也不凭空增加。

以上均为老许的浅薄之见,有任何疑虑和错误请及时指出,衷心希望本文能够对各位读者有一定的帮助。

注:

写本文时, 笔者所用go版本为: go1.16.6

文章中所用部分例子:https://github.com/Isites/go-coder/blob/master/money/main.go

【关注公众号】