关于math:Go四舍五入在go语言中为何如此困难

8次阅读

共计 4441 个字符,预计需要花费 12 分钟才能阅读完成。

四舍五入是一个十分常见的性能,在风行语言规范库中往往存在 Round 的性能,它起码反对罕用的 Round half up 算法。

而在 Go 语言中这仿佛成为了难题,在 stackoverflow 上搜寻 Round 会存在大量相干发问,Go 1.10 开始才呈现 math.Round 的身影,本认为 Round 的疑难就此结束,然而一看函数正文 Round returns the nearest integer, rounding half away from zero,这是并不罕用的 Round half away from zero 实现呀,说白了就是咱们了解的 Round 阉割版,精度为 0 的 Round half up 实现,Round half away from zero 的存在是为了提供一种高效的通过二进制办法得后果,能够作为 Round 精度为 0 时的高效实现分支。

带着对 Round 的‘敬畏’,我在 stackoverflow 翻阅大量对于 Round 问题,开启寻求最佳的答案,本文整顿我认为有用的实现,简略剖析它们的优缺点,对于不想逐渐理解,想间接看后果的小伙伴,能够间接看文末的最佳实现,或者跳转 exmath.Round 间接看源码和应用吧!

<!–more–>

Round 第一弹

在 stackoverflow 问题中的最佳答案首先取得我的关注,它在 mathx.Round 被开源,以下是代码实现:

//source: https://github.com/icza/gox/blob/master/mathx/mathx.go
package mathx

import "math"

// Round returns x rounded to the given unit.
// Tip: x is "arbitrary", maybe greater than 1.
// For example:
//     Round(0.363636, 0.001) // 0.364
//     Round(0.363636, 0.01)  // 0.36
//     Round(0.363636, 0.1)   // 0.4
//     Round(0.363636, 0.05)  // 0.35
//     Round(3.2, 1)          // 3
//     Round(32, 5)           // 30
//     Round(33, 5)           // 35
//     Round(32, 10)          // 30
//
// For details, see https://stackoverflow.com/a/39544897/1705598
func Round(x, unit float64) float64 {return math.Round(x/unit) * unit
}

这个实现十分的简洁,借用了 math.Round,由此看来 math.Round 还是很有价值的,大抵测试了它的性能一次运算大略 0.4ns,这十分的快。

然而我也很快发现了它的问题,就是精度问题,这个是问题中一个答复的解释让我有了警惕,并开始了试验。他认为应用浮点数确定精度(mathx.Round 的第二个参数)是不失当的,因为浮点数自身并不准确,例如 0.05 在 64 位 IEEE 浮点数中,可能会将其存储为 0.05000000000000000277555756156289135105907917022705078125

//source: https://play.golang.org/p/0uN1kEG30kI
package main

import (
    "fmt"
    "math"
)

func main() {
    f := 12.15807659924030304
    fmt.Println(Round(f, 0.0001)) // 12.158100000000001

    f = 0.15807659924030304
    fmt.Println(Round(f, 0.0001)) // 0.15810000000000002
}

func Round(x, unit float64) float64 {return math.Round(x/unit) * unit
}

以上代码能够在 Go Playground 上运行,失去后果并非如冀望那般,这个问题次要呈现在 math.Round(x/unit)unit 运算时,math.Round 运算后肯定会是一个准确的整数,然而 0.0001 的精度存在误差,所以导致最终失去的后果精度呈现了偏差。

格式化与反解析

在这个问题中也有人提出了先用 fmt.Sprintf 对后果进行格式化,而后再采纳 strconv.ParseFloat 反向解析,Go Playground 代码在这个里。

source: https://play.golang.org/p/jxILFBYBEF
package main

import (
    "fmt"
    "strconv"
)

func main() {fmt.Println(Round(0.363636, 0.05)) // 0.35
    fmt.Println(Round(3.232, 0.05))    // 3.25
    fmt.Println(Round(0.4888, 0.05))   // 0.5
}

func Round(x, unit float64) float64 {
    var rounded float64
    if x > 0 {rounded = float64(int64(x/unit+0.5)) * unit
    } else {rounded = float64(int64(x/unit-0.5)) * unit
    }
    formatted, err := strconv.ParseFloat(fmt.Sprintf("%.2f", rounded), 64)
    if err != nil {return rounded}
    return formatted
}

这段代码中有点问题,第一是后果不对,和咱们了解的存在差别,起初一看第二个参数传错了,应该是 0.01,我想试着调整调整精度吧,我改成了 0.0001 之后发现始终都是放弃小数点后两位,我细细钻研了下这段代码的逻辑,发现 fmt.Sprintf("%.2f", rounded) 中写死了保留的位数,所以它并不通用,我尝试如下简略调整一下使其失效。

package main

import (
    "fmt"
    "strconv"
)

func main() {
    f := 12.15807659924030304
    fmt.Println(Round(f, 0.0001)) // 12.1581

    f = 0.15807659924030304
    fmt.Println(Round(f, 0.0001)) // 0.1581

    fmt.Println(Round(0.363636, 0.0001)) // 0.3636
    fmt.Println(Round(3.232, 0.0001))    // 3.232
    fmt.Println(Round(0.4888, 0.0001))   // 0.4888
}

func Round(x, unit float64) float64 {
    var rounded float64
    if x > 0 {rounded = float64(int64(x/unit+0.5)) * unit
    } else {rounded = float64(int64(x/unit-0.5)) * unit
    }

    var precision int
    for unit < 1 {
        precision++
        unit *= 10
    }

    formatted, err := strconv.ParseFloat(fmt.Sprintf("%."+strconv.Itoa(precision)+"f", rounded), 64)
    if err != nil {return rounded}
    return formatted
}

的确取得了称心的精准度,然而其性能也十分主观,达到了 215ns/op,临时看来如果谋求精度,这个算法目前是比拟完满的。

大道至简

很快我发现了另一个极简的算法,它的精度和速度都十分的高,实现还特地精简:

package main

import (
    "fmt"

    "github.com/thinkeridea/go-extend/exmath"
)

func main() {
    f := 0.15807659924030304
    fmt.Println(float64(int64(f*10000+0.5)) / 10000) // 0.1581
}

这并不通用,除非像以下这么包装:

func Round(x, unit float64) float64 {return float64(int64(x*unit+0.5)) / unit
}

unit 参数和之前的概念不同了,保留一位小数 uint =10,只是整数 uint=1, 想对整数局部进行精度管制 uint=0.01 例如:Round(1555.15807659924030304, 0.01) = 1600Round(1555.15807659924030304, 1) = 1555Round(1555.15807659924030304, 10000) = 1555.1581

这仿佛就是终极答案了吧,等等……

终极计划

下面的办法够简略,也够高效,然而 api 不太敌对,第二个参数不够直观,带了肯定的心智累赘,其它语言都是传递保留多少位小数,例如 Round(1555.15807659924030304, 0) = 1555Round(1555.15807659924030304, 2) = 1555.16Round(1555.15807659924030304, -2) = 1600,这样的交互才合乎兽性啊。

别急我在 go-extend 开源了 exmath.Round,其算法合乎通用语言 Round 实现,且遵循 Round half up 算法要求,其性能方面在 3.50ns/op, 具体能够参看调优 exmath.Round 算法,具体代码如下:

//source: https://github.com/thinkeridea/go-extend/blob/main/exmath/round.go

package exmath

import ("math")

// Round 四舍五入,ROUND_HALF_UP 模式实现
// 返回将 val 依据指定精度 precision(十进制小数点后数字的数目)进行四舍五入的后果。precision 也能够是正数或零。func Round(val float64, precision int) float64 {p := math.Pow10(precision)
    return math.Floor(val*p+0.5) / p
}

总结

Round 性能虽简略,然而受到 float 精度影响,依然有很多人在四处寻找稳固高效的算法,参阅了大多数材料后精简出 exmath.Round 办法,冀望对其余开发者有所帮忙,至于其精度应用了大量的测试用例,没有超过 float 精度范畴时并没有呈现精度问题,未知问题期待社区测验,具体测试用例参见 round_test。

转载:

本文作者:戚银(thinkeridea)

本文链接:https://blog.thinkeridea.com/202101/go/round.html

版权申明:本博客所有文章除特地申明外,均采纳 CC BY 4.0 CN 协定 许可协定。转载请注明出处!

正文完
 0