LoginSignup
20
7

More than 1 year has passed since last update.

【Swift】FloatやDoubleの小数演算で誤差がでる現象を実際に2進数演算して調べてみた

Last updated at Posted at 2023-03-31

Swiftで小数の計算を行なったときに、思いもしない誤差が出たことはありませんか?
その真相を遥か昔に学んだ情報数学の拙い知識で実際演算して調査してみました。
対処方法は最後に記述してあるので、そこだけ知りたい方は最後だけ見てもらえたら良いかと思います。

非常に残念ですが、この記事では解明に至っていません😌
知識のある方の温かいコメントお待ちしております。

どういう現象か

let d1: Double = 0.9
let d2: Double = 0.8
print(d1 + d2)
// 1.7000000000000002

let f1: Float = 0.9
let f2: Float = 0.8
print(f1 + f2)
// 1.7

普通に計算したら1.7ですよね。なのに何故か結果に謎の小数が入ってしまっています。
まず原因を見ていきます。

なぜ?

原因のキーワード

  • 循環小数
  • 丸め誤差

2進数演算してみる

2進数変換

  • 0.8の2進数変換
0000 . 1100 1100 1100 1100 1100 1100...

1100がずっと繰り返されます。
同じ数字がずっと繰り返される2進数を循環小数と言います。

  • 0.9の2進数変換
0000 . 1110 0110 0110 0110 0110 0110...

0110がずっと繰り返されます。こちらも同じく循環小数です。

[切り捨て] 小数点演算(64ビット)

循環小数はのままでは計算できないので、64ビットのところで切り捨てした値で計算する。
特定の桁で数値を処理することで発生する誤差を丸め誤差と言います。

0000 . 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100  1100
+
0000 . 1110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110  0110
=
0001 . 1011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0010

これを10進数にすると
1.6999999999999999989591659144139157433528453

Swiftでの演算結果と違いますね。

[切り上げ] 小数点演算(64ビット)

切り捨てでは計算してないっぽいので次は切り上げで計算してみましょう。

  • 0.8の64ビット2進数変換(切り上げ)
0000 . 1100 1100 1100 1100 1100 1100..

64bitでプツッと切る
0000 . 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100
最後のbitを切り上げする
0000 . 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101
  • 0.9の64ビット2進数変換(切り上げ)
0000 . 1110 0110 0110 0110 0110 0110...

64bitでプツッと切る
0000 . 1110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110
最後のbitを切り上げする
0000 . 1110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0111
  • 演算してみる
0000 . 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101
+
0000 . 1110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0111
=
0001 . 1011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100

10進数にすると
1.7000000000000000006938893903907228377647697
有効桁で四捨五入
1.7000000000000000 = 1.7

循環小数を最上位桁切り上げで演算した結果は 1.7 になりました。
Swiftの計算結果とずれています。

なんとなく計算結果の下位8bitを切り上げしてみました。

計算結果
0000 0001 . 1011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100

下位8bitを切り上げ
0001 . 1011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100
10進数に変換
1.7000000000000001776356839400250464677810668
有効桁で四捨五入
1.7000000000000002

これはSwiftの計算結果と同じになりますね。
この結果からSwiftの2進数は符号付き浮動小数点で表現されているっぽいなと思いました。(というかそれが計算機の標準ですかね?)

ということで符号付き浮動小数点で再計算してみます。
符号付き浮動小数点についておさらいしたい方はこの記事がわかりやすかったです。

[切り上げ] 符号付き浮動小数点演算(64ビット)

  • 0.8の64ビット符号付き浮動小数点数変換(切り上げ)
0000 . 1100 1100 1100 1100 1100 1100..

符号部と指数部をつけて最下位bitを切り上げ
0 0000 0000 0000 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 111
  • 0.9の64ビット2進数変換(切り上げ)
0000 . 1110 0110 0110 0110 0110 0110...

符号部と指数部をつけて最下位bitを切り上げ
0 0000 0000 0000 1110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 100

仮数部だけを小数点に戻して演算する。

0000 . 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 111
+
0000 . 1110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 100
=
0001 . 1011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 011

10進数にすると
1.7000000000000006

なんか違ってしまった…。
浮動小数点数まで辿り着いた時は天才か!と思ったのに()

⬇️冒頭の計算

let d1: Double = 0.9
let d2: Double = 0.8
print(d1 + d2)
// 1.7000000000000002

Doubleで持ってる段階で浮動小数になっていると思っているのですが、私の計算が何か間違っているんですかね。誰か詳しい人教えてください…。

ただ、なんとなく何が起こっているのかはわかりました。これには一応対応方法があります。

対策

NSDecimalNumberを使うと丸めることができます。丸めるルールも設定できます。
Document

let n1 = NSDecimalNumber(string: "0.9")
let n2 = NSDecimalNumber(string: "0.8")
print(n1.adding(n2)) 
// 1.7
20
7
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
7