• 【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 直接看源码和使用吧!

    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协议 许可协议。转载请注明出处!

  • 相关阅读:
    C#实现office文档转换为PDF或xps的一些方法
    Asp.Net性能优化.
    我们该如何在网上寻找资源!!!!
    用谷歌浏览器来当手机模拟器
    js 文档高度
    文件的ContentType类型网页设计,HTML/CSS
    javascript 获取滚动条高度+常用js页面宽度与高度[转]
    学习MVC
    windows自带性能监控器
    C#语言规范之小结
  • 原文地址:https://www.cnblogs.com/thinkeridea/p/14222806.html
Copyright © 2020-2023  润新知