はじめに
実務でSwiftUIを使ったiOSアプリ開発をしている中で、ちょっとした事件が起きました。
とある計算の処理を実装していたのですが、数値の比較が期待値と微妙にズレてしまう現象が起きました。
デバッグを進めてみると、犯人は「浮動小数点数の計算誤差」でした。
基本情報や応用情報の勉強をしていると、「情報落ち」「打ち切り誤差」「桁落ち」「丸め誤差」という用語はよく出てきますよね。
私も試験対策として暗記はしていたのですが、なぜこれらの誤差が発生するのか、実際のプログラミングでどんな影響が出るのかまでは、正直あまり理解できていませんでした。
この記事では、これらの数値誤差の仕組みをわかりやすく整理しつつ、Swift・Java・Pythonで実際に同じ計算を動かして各言語の挙動を比較します。
さらに、解決策として Decimal 型の使い方もご紹介します。
試験勉強中の方にも、実務で同じ問題にぶつかった方にも、参考になると思います。
浮動小数点数でなんで誤差が出るのか
コンピュータの数値表現の限界:
コンピュータは内部的にすべての数値を2進数で扱います。
整数は問題ないのですが、小数を2進数で表現しようとすると無理が生じることがあります。例えば、私たちにとって馴染み深い 0.1 という数を2進数で表現しようとすると、以下のようになります。
$$0.1_{(10)} = 0.00011001100110011001100110011..._{(2)}$$
永遠に続く循環小数になってしまいます。
10進数では 1/3 = 0.333... が循環するように、2進数では 0.1 が循環します。
ほぼすべての言語(Swift、Java、Python、C++など)は IEEE754 という国際標準規格に基づく浮動小数点数を採用しています。
64ビットの場合、以下のような構成になっています。
符号部(1bit) | 指数部(11bit) | 仮数部(52bit)
仮数部が52ビットしかないため、表現できる有効桁数はおよそ15〜17桁が限界です。それを超える精度は切り捨てられるので、これが誤差の根本的な原因となります。
4つの数値誤差
まずは全体像を表で整理してみましょう
| 誤差の種類 | 原因 | 具体的なイメージ |
|---|---|---|
| 丸め誤差 | 表現できない数を近い値に丸める |
0.1 が 0.10000000000000001 になる |
| 情報落ち | 大きい数 + 小さい数で小さい方が消える |
10^16 + 1 = 10^16 のまま |
| 桁落ち | ほぼ等しい数同士の引き算で有効桁が減る |
1.0000001 - 1.0000000 で精度低下 |
| 打ち切り誤差 | 無限の計算を有限回で打ち切る |
e^x の無限級数を途中で止める |
丸め誤差
有限のビット数では正確に表現できない数値を、一番近い表現可能な値に丸めることで生じる誤差です。最も基本的で、日常的なコードで頻繁に発生します。
実際の計算例:
| 計算式 | 期待値 | 実際の結果 |
|---|---|---|
0.1 + 0.2 |
0.3 |
0.30000000000000004 |
0.1 + 0.2 == 0.3 |
true |
false |
1.1 + 2.2 |
3.3 |
3.3000000000000003 |
情報落ち
絶対値が大きく異なる2つの数を計算するとき、小さい方の数の情報が完全に消えてしまう現象です
イメージ図:
10000000000000000.0 (10の16乗)
+ 1.0 (小さな数)
--------------------
10000000000000000.0 (← 1e+16になり1.0が消滅...)
実際の検証結果:
| 計算 | 期待値 | 実際の結果 | 状況 |
|---|---|---|---|
1e16 + 1.0 |
10000000000000001.0 |
10000000000000000.0 |
1.0が消失 |
桁落ち
値がほぼ等しい2つの数を引き算したとき、上位の有効桁が互いに打ち消し合い、残った下位桁の誤差が相対的に大きく現れる現象です。
イメージ図:
1.2345678 (8桁の精度)
- 1.2345677 (8桁の精度)
= 0.0000001 (精度が8桁から1桁に激減)
打ち切り誤差
本来無限に続く計算を有限回で打ち切ることで生じる誤差です。例えば、e(ネイピア数)の計算:
$$e^x = 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + \frac{x^4}{4!} + \cdots$$
項数による精度の変化(x=1のとき、真の値 e ≈ 2.71828...):
| 使用項数 | 近似値 | 真の値との差 |
|---|---|---|
| 1項 | 1.00000 |
1.71828 |
| 3項 | 2.50000 |
0.21828 |
| 5項 | 2.70833 |
0.00995 |
| 10項 | 2.71828 |
0.00000003 |
Swift・Java・Pythonで実際に検証してみた
丸め誤差の検証:
// Swift
let a: Double = 0.1
let b: Double = 0.2
print(a + b) // 0.30000000000000004
print(a + b == 0.3) // false
// Java
public class Main {
public static void main(String[] args) {
double a = 0.1;
double b = 0.2;
System.out.println(a + b); // 0.30000000000000004
System.out.println(a + b == 0.3); // false
}
}
# Python
a = 0.1
b = 0.2
print(a + b) # 0.30000000000000004
print(a + b == 0.3) # False
情報落ちの検証:
// Swift
let large: Double = 10_000_000_000_000_000.0 // 10の16乗
let small: Double = 1.0
print(large + small) //1e+16
3言語の検証結果まとめ:
| 誤差の種類 | Swift | Java | Python |
|---|---|---|---|
| 丸め誤差 | 発生 | 発生 | 発生 |
| 情報落ち | 発生 | 発生 | 発生 |
| 桁落ち | 発生 | 発生 | 発生 |
| 打ち切り誤差 | 発生 | 発生 | 発生 |
結論: どの言語でも同じように誤差が発生します
これは言語固有の問題ではなく、IEEE754という共通の規格を使っているためです
SwiftUIでの解決策:Decimal型を活用しよう
なぜDecimal型が有効なのか:
Decimal 型は内部的に10進数のまま数値を扱うため、0.1 や 0.2 を2進数に変換する必要がありません。そのため、10進数の小数計算では丸め誤差が発生しません。
Double型とDecimal型の比較:
import Foundation
// Double型(誤差あり)
let doubleA: Double = 0.1
let doubleB: Double = 0.2
print("Double: \(doubleA + doubleB)") // 0.30000000000000004
print("Double ==: \(doubleA + doubleB == 0.3)") // false
// Decimal型(誤差なし)
let decimalA = Decimal(string: "0.1")!
let decimalB = Decimal(string: "0.2")!
print("Decimal: \(decimalA + decimalB)") // 0.3
print("Decimal ==: \(decimalA + decimalB == Decimal(string: "0.3")!)") // true
SwiftUIでの実践的な使用例:
//AIで作成してみた、SwiftUIでの Decimal クラスの活用例です
import Foundation
import SwiftUI
struct PriceCalculator {
let basePrice: Decimal
let taxRate: Decimal
var taxIncludedPrice: Decimal {
return basePrice * (1 + taxRate)
}
var formattedPrice: String {
let handler = NSDecimalNumberHandler(
roundingMode: .plain,
scale: 0,
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)
let rounded = NSDecimalNumber(decimal: taxIncludedPrice)
.rounding(accordingToBehavior: handler)
return "¥\(rounded)"
}
}
// 使用例
let calculator = PriceCalculator(
basePrice: Decimal(string: "1000")!,
taxRate: Decimal(string: "0.1")!
)
print(calculator.taxIncludedPrice) // 1100(誤差なし)
注意点:
Decimal(0.1) のように直接小数を渡すと、Decimalに変換される前の準備段階(Double型として読み込まれた瞬間)に、すでに2進数の丸め誤差が入り込んでしまいます。
正確な計算をするための鉄則は「小数をDouble型に一切触れさせないこと」です
// NG:浮動小数点リテラルからの初期化は誤差が入り込む
let ng = Decimal(0.1) // 誤差を含んだ値になる
// OK:文字列から初期化する
let ok = Decimal(string: "0.1")! // 正確に0.1として扱われる
他言語での対応方法:
// Java:BigDecimalを使用
import java.math.BigDecimal;
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b)); // 0.3(誤差なし)
# Python:decimalモジュールを使用
from decimal import Decimal
a = Decimal('0.1')
b = Decimal('0.2')
print(a + b) # 0.3(誤差なし)
おわりに
試験勉強で覚えた知識が実務でこんな形で活かされるとは思いませんでした。
特に数値の比較や消費税などの金額の計算では、Decimal型の使用を強くおすすめします。