【Go】四捨五入在go語言中爲什麼如此困難

四捨五入是一個很是常見的功能,在流行語言標準庫中每每存在 Round 的功能,它最少支持經常使用的 Round half up 算法。html

而在 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 時的高效實現分支。git

帶着對 Round 的‘敬畏’,我在 stackoverflow 翻閱大量關於 Round 問題,開啓尋求最佳的答案,本文整理我認爲有用的實現,簡單分析它們的優缺點,對於不想逐步瞭解,想直接看結果的小夥伴,能夠直接看文末的最佳實現,或者跳轉 exmath.Round 直接看源碼和使用吧!github

<!--more-->golang

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,這很是的快。api

可是我也很快發現了它的問題,就是精度問題,這個是問題中一個回答的解釋讓我有了警覺,並開始了實驗。他認爲使用浮點數肯定精度(mathx.Round的第二個參數)是不恰當的,由於浮點數自己並不精確,例如 0.05 在64位IEEE浮點數中,可能會將其存儲爲0.05000000000000000277555756156289135105907917022705078125ide

//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協議 許可協議。轉載請註明出處!

相關文章
相關標籤/搜索