关于golang:探讨系统中💰钱的精度问题

来自公众号:Gopher指北

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

在一个零碎中,特地是一个和钱相干的零碎,钱乃重中之重,计算时的精度将是本篇探讨的主题。

精度为何如此重要

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

为什么会有精度的问题

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

上述case学过计算机的应该都晓得,计算机是二进制的,用二进制示意浮点数时(IEEE754规范),只有大量的数能够用这种办法准确的示意进去。上面以0.3为例看一下十进制转二进制小数的过程。

计算机的位数有限度,因而计算机用浮点数计算时必定无奈失去准确的后果。这种硬限度无奈冲破,所以须要引入精度以保障对钱的计算在容许的误差范畴内尽可能精确。

对于浮点数在计算机中的理论示意本文不做进一步探讨,能够参考下述连接学习:

单精度浮点数示意:

https://en.wikipedia.org/wiki…

双精度浮点数示意:

https://en.wikipedia.org/wiki…

浮点数转换器:

https://www.h-schmidt.net/Flo…

用浮点数计算

还是以上述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-…

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理