四捨五入是一個非常常見的功能,在流行語言標準庫中往往存在 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) = 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協議 許可協議。轉載請註明出處!