【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
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) = 1600
,Round(1555.15807659924030304, 1) = 1555
,Round(1555.15807659924030304, 10000) = 1555.1581
。
這似乎就是終極答案了吧,等等……
終極方案
上面的方法夠簡單,也夠高效,但是 api 不太友好,第二個引數不夠直觀,帶了一定的心智負擔,其它語言都是傳遞保留多少位小數,例如 Round(1555.15807659924030304, 0) = 1555
,Round(1555.15807659924030304, 2) = 1555.16
,Round(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協議 許可協議。轉載請註明出處!