Go言語で桁数指定可能な四捨五入関数を作る

こちらは、HRBrain Advent Calendar 2022 6日目の記事です。  

https://qiita.com/advent-calendar/2022/hrbrain

どうもこんにちは、HRBrainでバックエンドエンジニアとして働いてる藤原です。１年に１度のペースで記事を書いております。

今回は、小数を任意の桁数で四捨五入する関数をGo言語で作りたいと思います。  その実現方法と注意点について書いていきます。

# Go言語の標準の四捨五入関数
Go言語標準の`math.Round`関数が用意されています。  
しかし、こちらは桁数指定ができず、小数第一位を四捨五入して整数にするというものです。
```golang
func Round(x float64) float64
```
https://pkg.go.dev/math#Round

今回は桁数指定までできる、より汎用的なRound関数を作っていきたいと思います。

# まずは素直に実装してみます

```golang
func Round(num float64, pos int) float64 {
	shift := math.Pow10(pos)             // 小数の位置をずらすためのシフト値を算出
	shiftedNum := num * shift            // 四捨五入したい桁を小数第一位にずらす
	roundedNum := math.Round(shiftedNum) // 小数第一位を四捨五入する
	result := roundedNum / shift         // 小数の位置を元に戻す
	return result
}
```
実際に動かしてみます。  
https://goplay.tools/snippet/cZ7Yy-48whP

```golang
func main() {
	fmt.Println(Round(1.5, 0))
	fmt.Println(Round(1.14, 1))
	fmt.Println(Round(1.114, 2))
	fmt.Println(Round(1.1115, 3))
}
```
結果
```
2
1.1
1.11
1.112
```
良さそうです。  

# 網羅的にテスト
本当に問題ないか確認するため、もう少し網羅的にテストしてみようと思います。  
0.0000〜9.9999の範囲を0〜3桁で四捨五入してみます。

テスト用のコードを用意して実行します。  
```golang
func main() {
	roundTest(
		1,    // 整数桁数
		4,    // 小数桁数
		0, 3, // 四捨五入位置の範囲
		Round, // 四捨五入関数
		// エラー発生時のログ出力
		func(got, want, base float64) {
			fmt.Printf("ret %v, want %v, base %v\n", got, want, base)
		},
	)
}
```
https://goplay.tools/snippet/3lYhEVrmkJg  
＊テスト用関数`roundTest`は指定に従い四捨五入の関数を試験する実装になっています。  詳細の説明は割愛します。

実行してみると、いくつか期待値にならないものが出てきました。
```
ret:計算結果、want:期待値、base:元の値

ret 4, want 4.001, base 4.0005
ret 4.005, want 4.006, base 4.0055
ret 4.008, want 4.009, base 4.0085
ret 4.013, want 4.014, base 4.0135
ret 4.01, want 4.02, base 4.015
ret 0.14, want 0.15, base 0.145
ret 5.01, want 5.02, base 5.015
ret 0.28, want 0.29, base 0.285
・・・
```

期待値にならないものをピックアップして動かしてみます。  
更にログを追加して詳細を確認します。

```golang
func main() {
	fmt.Println(Round(4.0005, 3))
	fmt.Println()
	fmt.Println(Round(4.0135, 3))
	fmt.Println()
	fmt.Println(Round(0.145, 2))
	fmt.Println()
	fmt.Println(Round(0.285, 2))
	fmt.Println()
}

func Round(num float64, pos int) float64 {
	fmt.Printf("num=%v\n", num)
	// 小数の位置をずらすためのシフト値を算出
	shift := math.Pow10(pos)
	fmt.Printf("shift=%v\n", shift)
	// 四捨五入した桁を小数第一位にする
	shiftedNum := num * shift
	fmt.Printf("shiftedNum=%v\n", shiftedNum)
	// 小数第一位を四捨五入する
	roundedNum := math.Round(shiftedNum)
	fmt.Printf("roundedNum=%v\n", roundedNum)
	// 小数の位置を元に戻す
	result := roundedNum / shift
	return result
}
```
　
https://goplay.tools/snippet/iC9hddwyMp6

結果
```
num=4.0005
shift=1000
shiftedNum=4000.4999999999995
roundedNum=4000
4

num=4.0135
shift=1000
shiftedNum=4013.4999999999995
roundedNum=4013
4.013

num=0.145
shift=100
shiftedNum=14.499999999999998
roundedNum=14
0.14

num=0.285
shift=100
shiftedNum=28.499999999999996
roundedNum=28
0.28
```

小数の桁数をシフトしたところで不思議な値（0.4999999...）になり、   
本来は0.5が切り上げになる想定のものが、0.5未満なので切り捨てになっています。

# 小数の誤差
この問題は、浮動小数点型（float）を扱った計算により発生する「丸め誤差」というものです。  

https://e-words.jp/w/丸め誤差.html

# 小数の誤差を解決する
`decimal`という固定小数点数を使って計算を実施するライブラリを使用することで、誤差を回避できます。  
https://github.com/shopspring/decimal

こちらのライブラリを使ってRound関数を実装し直します。  

```golang
func Round(num float64, pos int) float64 {
	fmt.Printf("num=%v\n", num)
	result, _ := decimal.NewFromFloat(num).Round(int32(pos)).Float64()
	return result
}
```
桁数指定の四捨五入が用意されており、非常に簡単に実装できました。

実行してみます。  
https://goplay.tools/snippet/bvVgrI1dYTr

結果

```
num=4.0005
4.001

num=4.0135
4.014

num=0.145
0.15

num=0.285
0.29
```
誤差が発生していたケースが期待通りの結果になりました。


網羅的な試験も実施してみます。  
https://goplay.tools/snippet/oOHd74YbYcD  
エラーが発生せず、全てOKでした。

# まとめ
丸め誤差を回避して桁数指定のRound関数を実装する方法について説明しました。  
言語問わず小数を扱う計算では誤差が発生することがありますので、注意して扱いたいですね。

# 最後に

HRBrainでは一緒に働いてくれる仲間を募集しています。  
「誤差なんて朝飯前だよ」と思ったそこのあなた、ぜひご応募ください。

https://www.hrbrain.co.jp/