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

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

而在 Go 语言中这仿佛成为了难题,在 stackoverflow 上搜寻 [go] 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协定 许可协定。转载请注明出处!

评论

发表回复

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

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