※ こちらは、公立千歳科学技術大学の大学祭にて開催されたゆるちとせで発表したLTの記事版です。
speakerdeck.com
導入
以下の計算を試してください:
let a: Double = 0.1
let b: Double = 0.2
let result = a + b
print(result)
期待値は0.3です。
しかし、上記のコードの結果は「0.30000000000000004」になります。
これが丸め誤差です。
今日は、丸め誤差が発生する仕組みについてお話しします。
先ほどのコードに出てきたDoubleは浮動小数点数型の一種です。
浮動小数点数型とは、10進数の実数を、有限桁の2進数の近似値で表現する方法です。
…近似値?ちょっと引っかかる言い方ですね。
近似値の具体を確認するには、「基数変換」を知る必要があります。
おさらいしましょう。
基数変換の方法をおさらい
今後の話の前提として、基数変換の方法をおさらいしましょう。
整数部と小数部で若干手順が異なります。
整数部を変換するときは、整数を2で割り続けて、余りを下から上へ並べる方法を使います。
小数部を変換する場合は、小数部分に2を掛け続け、積の整数部分を順に取り出します。
0.1を二進数にする
「近似値」の具体に迫りましょう。
先ほどの計算に出てきた0.1は、浮動小数点数において近似値として扱われる数です。
0.1を二進数に基数変換して、「近似値」の具体に迫りましょう。
循環している…?
つまり、0.1は循環小数になってしまうため、
2進数では有限桁数で表現できないのです。
先ほど、浮動小数点数は10進数の実数を、有限桁の2進数の近似値で表現する方法だと説明しました。
ここまでの話を踏まえると、丸め誤差は小数を有限桁の二進数で表現できない時に発生するものであることがわかります。
誤差が発生するにも関わらず浮動小数点数が活用される理由ってなんでしょう。
なにかしらかの、10進数に変わる優位性があるはずです。
そういえば、CPUは2進数で動きますよね。
メモリ的に嬉しい話が背景にありそうな予感がします。
結論から言うと、浮動小数点数の優位性は、
誤差が発生するリスクがあっても、効率的なメモリ利用と高速な演算が可能
な点にあります。
- 浮動小数点数は、指定されたビット数(例えば、Floatは32ビット、Doubleは64ビット)で数値を表現します。これにより、メモリ使用量が予測可能になります。
浮動小数点数の内部表現は、ビット数が符号部・指数部・仮数部(係数部)に分かれています。このため、各ビットが効率的に利用される構造になっています。
また、指数部を使うことで数値のスケールを柔軟に調整できるため、非常に大きな数や非常に小さな数を、相対的に少ないビット数で表現できます。
- 内部的な話をすると、FPUという浮動小数点演算ユニットで演算が実行されます。これにより、10進数演算よりも効率的な計算が可能になります
SwiftのFloatやDouble(ダブル)は、Decimalに比べてパフォーマンスが高く、扱える数値範囲が広いのが特徴です。
Double型とDecimal型のパフォーマンスの比較がしたいですね。
ベンチマークを取ってみました。
確かにDoubleのほうが早いですね。
一般的に、Decimalは高精度を提供する一方で、パフォーマンスが低いというトレードオフがあります。
したがって、パフォーマンスが重要な場合はDoubleやFloatが推奨され、正確さが求められる計算ではDecimalを選ぶべきです。
2つの方向性があります。
1. 整数で計算した後、10で割る
2. Decimalを使用する。
整数で計算した後、10で割る
10進数の整数を2進数に変換する際は常に正確に基数変換できます。
10進数の整数は、単にその値を2の累乗の組み合わせとして表現するだけで済むからです。
10進数の小数を2進数に変換する際のみ、丸め誤差は問題になります。
(2の累乗とは、2を何回か掛けた数のことです。次のような数のことを指します:20 = 1、21 = 2、22 = 4、23 = 8、24 = 16)
この方法は、Intで表現可能な範囲内でのみ使用できると言う制限があります。
Decimalを使用する
10進数を使えば問題ないですよね。
許容可能な範囲であれば、DoubleやFloatで十分です。
数値の正確さが必須ではない場面では、むしろDouble・Floatを使うべきでしょう。
一方で、ユーザーに計算結果を見せる必要があるとき。電卓や株価の計算などにおいては、Decimal型を使用する方が適切です。
まとめ
丸め誤差は、10進数を2進数で表現する際に発生する計算誤差です。浮動小数点数は、誤差を伴うものの、高速でメモリ効率が良いため、広く使用されています。
丸め誤差の回避方法として、整数で計算後に10で割る方法や、Decimal型を使用する手段があります。ただし、どちらも制限があります。
許容できる場面では、FloatやDoubleを選択し、正確さが求められる場面では、Decimalや他の方法を使用します。
微妙な誤差と向き合う時に便利な概念
近似等価性: 比較する際、誤差を許容したい場合に使う。
区間演算: 計算全体の誤差範囲を管理し、結果がある範囲内に収まることを保証したい場合に使う。
近似等価性を扱う関数が将来的にSwiftに追加されるかも…
近似等価性は、浮動小数点数の計算で発生する微小な誤差を考慮しながら値を比較する時に役立ちます。
0.1と0.3を足した結果を0.4と比較する際、通常の比較では丸め誤差の影響で正確に一致しませんが、近似等価性を利用すれば、微小な差を許容して「ほぼ等しい」と判断できます。
forums.swift.org
区間演算は計算全体における誤差を管理する手法。
全ての計算を範囲として扱うことで、複数回の演算で発生する累積誤差を考慮しながら計算を進めることができる。
浮動小数点演算の誤差発生メカニズムについて~Swiftを例に~
この記事が詳しいです。
Doubleで意図的に丸め誤差を発生させて、内部でどのような丸め処理が行われているのか解明しようとしています。
結論から言うと、浮動小数点数の丸めは「真の結果に最も近い値」で行われます。
当日話さなかったけど、調べた諸々を供養
活用編
ここからは私が実際にコードを書いていて、今日お話しした知識を活用した例を共有させてください。
【Kotlin】String -> Intの型変換ができない!
以下のコードは失敗します。
val str = "111111111111"
val num = str.toIntOrNull() // 変換失敗、nullになる
今日話した内容を踏まえるとなんとなく原因に予測が経ちますね。
Kotlinのドキュメントを見てみましょう:
KotlinのIntは32ビットです。
"111111111111"はIntで表現可能な範囲を超えてしまっています。
Dobleで表現可能な範囲も確認してみます。
KotlinのDoubleは64ビットまで表現可能のようです。
String -> Doubleなら成功しそうですね。
試してみましょう。
val str = "111111111111"
val num = str.toDoubleOrNull() // 変換成功、111111111111.0が返る
変換成功しました!!
今回は変換対象が整数で表現できる値だったのでこれでも問題ありません。
しかし、この方法には精度が犠牲になっているという問題があります。
Kotlinには、この問題を解決する型があります。
それがJavaから引き継いだBigIntegerです。
これは非常に大きな整数を精度を損なうことなく扱うために設計された型です。DoubleやFloatのような浮動小数点数と異なり、BigIntegerは整数を正確に表現でき、桁数が多くても丸め誤差や近似の影響を受けないという特徴があります。
SwiftにもBigIntというよく似た型が実装されています。
状況に応じて適宜使い分けていきましょう(?: 実行環境、CPUとかとこの話は関連しているんだろうか)
あれ、コード書いてるとき「0.1」って書いてるよね。結局内部的に進数変換するのだとしたら、どのタイミングでやっているんだろう。
->
10進数リテラルは、コンパイル時にLLVMコンパイラによって2進数に変換され、FPUによって処理されます。
(参考:
Expression
Integer Operators
)
詳細はよくわからないので調べます。
CPUは2進数で動くはず。10進数の計算はどこでやるの?
-> 調べます。
多分10をかけて、整数として扱って計算してるんじゃないかなって予感はする。。。。。。
今後の意気込み
第二回ができるように頑張ります。
おまけ
ゆるちとせでご一緒した熊谷さんが実施された、浮動小数点数の発表スライド。私の発表を聞いた次に触れるとちょうど良さそうなレベル感でした。
yumemi.notion.site