四舍五入在 Go 語(yǔ)言中為何如此困難?
四舍五入是一個(gè)非常常見(jiàn)的功能,在流行語(yǔ)言標(biāo)準(zhǔn)庫(kù)中往往存在 Round 的功能,它最少支持常用的 Round half up 算法。
而在 Go 語(yǔ)言中這似乎成為了難題,在 stackoverflow 上搜索 [go] Round 會(huì)存在大量相關(guān)提問(wèn),Go 1.10 開(kāi)始才出現(xiàn) math.Round 的身影,本以為 Round 的疑問(wèn)就此結(jié)束,但是一看函數(shù)注釋 Round returns the nearest integer, rounding half away from zero ,這是并不常用的 Round half away from zero 實(shí)現(xiàn)呀,說(shuō)白了就是我們理解的 Round 閹割版,精度為 0 的 Round half up 實(shí)現(xiàn),Round half away from zero 的存在是為了提供一種高效的通過(guò)二進(jìn)制方法得結(jié)果,可以作為 Round 精度為 0 時(shí)的高效實(shí)現(xiàn)分支。
帶著對(duì) Round 的‘敬畏’,我在 stackoverflow 翻閱大量關(guān)于 Round 問(wèn)題,開(kāi)啟尋求最佳的答案,本文整理我認(rèn)為有用的實(shí)現(xiàn),簡(jiǎn)單分析它們的優(yōu)缺點(diǎn),對(duì)于不想逐步了解,想直接看結(jié)果的小伙伴,可以直接看文末的最佳實(shí)現(xiàn),或者跳轉(zhuǎn) exmath.Round[1] 直接看源碼和使用吧!
Round 第一彈
// source: https://github.com/icza/gox/blob/master/mathx/mathx.go
package mathx
import "math"
// AbsInt returns the absolute value of i.
func AbsInt(i int) int {
if i < 0 {
return -i
}
return i
}
// 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
}
// Near reports if 2 float64 numbers are "near" to each other.
// The caller is responsible to provide a sensible epsilon.
//
// "near" is defined as the following:
// near := math.Abs(a - b) < eps
//
// Corner cases:
// 1. if a==b, result is true (eps will not be checked, may be NaN)
// 2. Inf is near to Inf (even if eps=NaN; consequence of 1.)
// 3. -Inf is near to -Inf (even if eps=NaN; consequence of 1.)
// 4. NaN is not near to anything (not even to NaN)
// 5. eps=Inf results in true (unless any of a or b is NaN)
func Near(a, b, eps float64) bool {
// Quick check, also handles infinities:
if a == b {
return true
}
return math.Abs(a-b) < eps
}
這個(gè)實(shí)現(xiàn)非常的簡(jiǎn)潔,借用了 math.Round,由此看來(lái) math.Round 還是很有價(jià)值的,大致測(cè)試了它的性能一次運(yùn)算大概 0.4ns,這非常的快。
但是我也很快發(fā)現(xiàn)了它的問(wèn)題,就是精度問(wèn)題,這個(gè)是問(wèn)題中一個(gè)回答的解釋讓我有了警覺(jué),并開(kāi)始了實(shí)驗(yàn)。他認(rèn)為使用浮點(diǎn)數(shù)確定精度(mathx.Round 的第二個(gè)參數(shù))是不恰當(dāng)?shù)?,因?yàn)楦↑c(diǎn)數(shù)本身并不精確,例如 0.05 在 64 位 IEEE 浮點(diǎn)數(shù)中,可能會(huì)將其存儲(chǔ)為 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[4] 上運(yùn)行,得到結(jié)果并非如期望那般,這個(gè)問(wèn)題主要出現(xiàn)在 math.Round(x/unit) 與 unit 運(yùn)算時(shí),math.Round 運(yùn)算后一定會(huì)是一個(gè)精確的整數(shù),但是 0.0001 的精度存在誤差,所以導(dǎo)致最終得到的結(jié)果精度出現(xiàn)了偏差。
格式化與反解析
在這個(gè)問(wèn)題中也有人提出了先用 fmt.Sprintf 對(duì)結(jié)果進(jìn)行格式化,然后再采用 strconv.ParseFloat 反向解析,Go Playground[5] 代碼在這個(gè)里。
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
}
這段代碼中有點(diǎn)問(wèn)題,第一是結(jié)果不對(duì),和我們理解的存在差異,后來(lái)一看第二個(gè)參數(shù)傳錯(cuò)了,應(yīng)該是 0.01,我想試著調(diào)整調(diào)整精度吧,我改成了 0.0001 之后發(fā)現(xiàn)一直都是保持小數(shù)點(diǎn)后兩位,我細(xì)細(xì)研究了下這段代碼的邏輯,發(fā)現(xiàn) fmt.Sprintf("%.2f", rounded) 中寫(xiě)死了保留的位數(shù),所以它并不通用,我嘗試如下簡(jiǎn)單調(diào)整一下使其生效。
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
}
確實(shí)獲得了滿意的精準(zhǔn)度,但是其性能也非??陀^,達(dá)到了 215ns/op,暫時(shí)看來(lái)如果追求精度,這個(gè)算法目前是比較完美的。
大道至簡(jiǎn)
很快我發(fā)現(xiàn)了另一個(gè)極簡(jiǎn)的算法,它的精度和速度都非常的高,實(shí)現(xiàn)還特別精簡(jiǎn):
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 參數(shù)和之前的概念不同了,保留一位小數(shù) uint =10,只是整數(shù) uint=1, 想對(duì)整數(shù)部分進(jìn)行精度控制 uint=0.01 例如:Round(1555.15807659924030304, 0.01) = 1600,Round(1555.15807659924030304, 1) = 1555,Round(1555.15807659924030304, 10000) = 1555.1581。
這似乎就是終極答案了吧,等等……
終極方案
上面的方法夠簡(jiǎn)單,也夠高效,但是 api 不太友好,第二個(gè)參數(shù)不夠直觀,帶了一定的心智負(fù)擔(dān),其它語(yǔ)言都是傳遞保留多少位小數(shù),例如 Round(1555.15807659924030304, 0) = 1555,Round(1555.15807659924030304, 2) = 1555.16,Round(1555.15807659924030304, -2) = 1600,這樣的交互才符合人性啊。
別急我在 go-extend[6] 開(kāi)源了 exmath.Round[7],其算法符合通用語(yǔ)言 Round 實(shí)現(xiàn),且遵循 Round half up 算法要求,其性能方面在 3.50ns/op, 具體可以參看調(diào)優(yōu) exmath.Round 算法[8], 具體代碼如下:
//source: https://github.com/thinkeridea/go-extend/blob/main/exmath/round.go
// MIT License
// Copyright (c) 2020 Qi Yin <[email protected]>
package exmath
import (
"math"
)
// Round 四舍五入,ROUND_HALF_UP 模式實(shí)現(xiàn)
// 返回將 val 根據(jù)指定精度 precision(十進(jìn)制小數(shù)點(diǎn)后數(shù)字的數(shù)目)進(jìn)行四舍五入的結(jié)果。precision 也可以是負(fù)數(shù)或零。
func Round(val float64, precision int) float64 {
if precision == 0 {
return math.Round(val)
}
p := math.Pow10(precision)
if precision < 0 {
return math.Floor(val*p+0.5) * math.Pow10(-precision)
}
return math.Floor(val*p+0.5) / p
}
總結(jié)
Round 功能雖簡(jiǎn)單,但是受到 float 精度影響,仍然有很多人在四處尋找穩(wěn)定高效的算法,參閱了大多數(shù)資料后精簡(jiǎn)出 exmath.Round[9] 方法,期望對(duì)其他開(kāi)發(fā)者有所幫助,至于其精度使用了大量的測(cè)試用例,沒(méi)有超過(guò) float 精度范圍時(shí)并沒(méi)有出現(xiàn)精度問(wèn)題,未知問(wèn)題等待社區(qū)檢驗(yàn),具體測(cè)試用例參見(jiàn) round_test[10]。
原文鏈接:https://blog.thinkeridea.com/202101/go/round.html
作者:戚銀
參考資料
exmath.Round: https://github.com/thinkeridea/go-extend/blob/main/exmath/round.go#L12
[2]stackoverflow: https://stackoverflow.com/questions/39544571/golang-round-to-nearest-0-05
[3]mathx.Round: https://github.com/icza/gox/blob/master/mathx/mathx.go#L26
[4]Go Playground: https://play.golang.org/p/0uN1kEG30kI
[5]Go Playground: https://play.golang.org/p/jxILFBYBEF
[6]go-extend: https://github.com/thinkeridea/go-extend
[7]exmath.Round: https://github.com/thinkeridea/go-extend/blob/main/exmath/round.go
[8]調(diào)優(yōu) exmath.Round 算法: https://github.com/thinkeridea/go-extend/pull/13
[9]exmath.Round: https://github.com/thinkeridea/go-extend/blob/main/exmath/round.go
[10]round_test: https://github.com/thinkeridea/go-extend/blob/main/exmath/round_test.go
一個(gè) Go 語(yǔ)言實(shí)現(xiàn)的數(shù)據(jù)庫(kù)
Go 語(yǔ)言將成為惡意軟件開(kāi)發(fā)者的首選
Go語(yǔ)言基礎(chǔ)編程學(xué)習(xí)資料一套

