電卓アプリの合計金額が1円ズレた。テストは通っているのに本番の集計だけ合わない。原因が分からず2時間溶かした末に、0.1 + 0.2 が 0.30000000000000004 を返していると気づいた。バグじゃない、仕様だった。自分はここで一度ちゃんとハマったので、同じ落とし穴の地図を置いておく。
-
現象:
0.1 + 0.2 === 0.3がfalse。JavaScriptでもPythonでもJavaでも同じ。 - 原因: 小数は内部で2進数の分数に変換される。0.1 は2進数だと循環小数になり、途中で打ち切られて誤差が残る。
- 直し方: 等値比較をやめて誤差許容にするか、整数(最小単位)で持つか、十進数型(Decimal)を使う。用途で選ぶ。
| 読む前 | 読んだ後 |
|---|---|
| 「計算ミスするCPUなんてある?」と疑う | 2進数で0.1を表せない理由を1行で説明できる |
| 金額計算を float でやっている | お金は整数(最小単位)で持つ判断ができる |
| `==` で小数を比較して落ちる | イプシロン比較を書ける |
想定読者: 小数計算で1回でも「あれ?」となった全ての人。言語不問。特に金額・座標・集計を扱う人。
目次
- 何が起きたか(3言語で再現)
- なぜ2進数だと0.1が表せないのか
- IEEE 754 という「箱の形」
- 誤差が牙をむく2つの瞬間
- 直し方その1: 誤差を許容して比較する
- 直し方その2: 整数に寄せる(お金編)
- 直し方その3: 十進数型を使う
- 言語別の落とし穴早見
- まとめとアクション
何が起きたか(3言語で再現)
まず手元で再現する。ブラウザのコンソールでもNode.jsでもいい。
// JavaScript (ブラウザのコンソールでそのまま実行できる)
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(0.3 - 0.1); // 0.19999999999999998
Pythonでも一致する。言語の実装が悪いわけじゃない。
# Python 3
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> from decimal import Decimal
>>> Decimal(0.1) # floatが内部で何を持っているか覗く
Decimal('0.1000000000000000055511151231257827021181583404541015625')
最後の行が核心。0.1 を代入した瞬間、メモリには 0.1 ではなく 0.1000...0555... が入っている。この余りの ...0555 が積み重なって 0.30000000000000004 になる。JavaScriptもJavaもRubyもGoも、同じ double(64ビット浮動小数点)を使うかぎり同じ数字が出る。
なぜ2進数だと0.1が表せないのか
10進数で 1/3 を書こうとすると 0.3333... と無限に続く。有限桁では正確に書けない。これと同じことが、2進数の 0.1 で起きる。
2進数の小数は「1/2、1/4、1/8、1/16 …」の足し算でしか作れない。0.1(10分の1)をこの足し算で表そうとすると、
0.1(10進) = 0.0001100110011001100110011...(2進)
└─ 1001 が無限に循環する
0011 が延々と繰り返される循環小数になる。分母が2のべき乗じゃない小数(0.1、0.2、0.3、0.7 …)は、ほぼ全部この運命をたどる。逆に 0.5(=1/2)や 0.25(=1/4)や 0.75 はぴったり表せる。
>>> 0.5 + 0.25 == 0.75 # 分母が2のべき乗ならピタリ合う
True
>>> 0.1 + 0.1 + 0.1 == 0.3
False
0.1 を3回足すと誤差が3回分たまって、0.3 を1回変換した誤差と一致しない。「同じ0.3のはずなのに」ズレるのはこれが理由。
IEEE 754 という「箱の形」
無限に循環する2進数を、有限のメモリに詰めるルールが IEEE 754 という規格。ほぼ全ての言語の double がこれに従う。64ビットの内訳はこうなっている。
[符号 1bit][指数 11bit][仮数 52bit]
└ プラス/マイナス └ 桁の位置 └ 有効数字の本体
仮数部が52ビットしかないので、循環小数はこの52桁で強制的に打ち切られる。打ち切った時点で「本来より少しだけ大きい/小さい」値に丸められる。この丸め誤差が全ての元凶。
10進数に直すと、double が保証する有効数字はおよそ 15〜17桁。だから 0.30000000000000004 の ...04 みたいな16桁目あたりでボロが出る。ここは「精度が足りない」というより「有限の箱に無限を入れた代償」と捉えるのが正しい。詳しい丸めの挙動はPython公式チュートリアルのFloating Point Arithmeticが2進変換つきで一番わかりやすい。
誤差が牙をむく2つの瞬間
普段は表示のときに勝手に丸めてくれるので気づかない。問題が表面化するのは主に2パターン。
1つ目は等値比較。 if (total === 0.3) のような書き方は、誤差1個で崩れる。
let total = 0;
for (let i = 0; i < 10; i++) total += 0.1;
console.log(total); // 0.9999999999999999
console.log(total === 1.0); // false — ループ集計で誤差が蓄積
実際にテストでこう書くと落ちる。CIログにこのエラーが出て、最初は自分のロジックを疑った。
# test_total.py 集計ロジックのテストが謎に失敗した
>>> assert sum([0.1] * 10) == 1.0
Traceback (most recent call last):
File "test_total.py", line 3, in <module>
assert sum([0.1] * 10) == 1.0
AssertionError # 実際の値は 0.9999999999999999
2つ目は金額計算。 消費税や割引で小数が出ると、四捨五入の直前で1円ズレる。集計件数が100件→99件分の金額になる、みたいな事故につながる。10件足しただけでも 10→9 に化けることがある(上のassertが1.0でなく0.9999...で落ちたのがそれ)。
>>> price = 0.1 + 0.2 # 何かの計算結果が 0.30000000000000004
>>> round(price, 2) # 見た目は 0.3 に見えるが…
0.3
>>> int(price * 100) # 整数化すると 30 のはずが
30
>>> int((0.1 + 0.2 + 0.1) * 100) # 積もると 40 が 39 に落ちる
40
集計件数が増えるほど誤差は積もる。「テストは通ったのに本番の大量データで合計が合わない」の典型パターンがこれ。
直し方その1: 誤差を許容して比較する
小数同士を比べるときは、完全一致ではなく「十分近いか」で判定する。差が極小の閾値(イプシロン)より小さければ等しいとみなす。
// 許容誤差つきの比較関数
function nearlyEqual(a, b, epsilon = Number.EPSILON) {
return Math.abs(a - b) < epsilon;
}
console.log(nearlyEqual(0.1 + 0.2, 0.3)); // true
Number.EPSILON は「1.0の次に表現できる数との差」で、約 2.22e-16。JavaScriptの定数の意味はMDNのNumber.EPSILONに定義がある。Pythonなら標準ライブラリの math.isclose が同じ役割を果たす。
>>> import math
>>> math.isclose(0.1 + 0.2, 0.3) # 相対誤差も考慮してくれる
True
閾値を絶対値で固定すると桁の大きい数で破綻するので、math.isclose のように相対誤差を見る実装が安全。
直し方その2: 整数に寄せる(お金編)
金額を扱うなら、そもそも小数を使わないのが一番堅い。円ではなく「銭」や最小単位の整数で持つ。 100円なら 100、消費税10%なら 110、と整数のまま計算して、表示のときだけ割る。
# NG: 円をfloatで持つ
total = 0.0
for _ in range(3):
total += 108.9 # 税込みの端数
print(total) # 326.70000000000005 ← 誤差混入
# OK: 最小単位(銭=1/100円)を整数で持つ
total_sen = 0
for _ in range(3):
total_sen += 10890 # 108.90円を「10890銭」として整数化
print(total_sen / 100) # 326.7 ← 誤差ゼロ
ぶっちゃけこれが一番事故らない。上の例で 326.70000000000005 → 326.7 と誤差が消えたのが分かる。整数の足し算・掛け算には丸め誤差が原理的に発生しないからだ。会計・決済・ポイント計算はこの方式が定石。割り算だけは端数処理のルール(切り捨て/四捨五入)を明示的に決めておく。自分が金額処理を書くときは、まず「この値は整数で持てないか?」から入るようにしている。
直し方その3: 十進数型を使う
「整数化は面倒、でも誤差は許せない」なら、10進数を10進数のまま扱う専用の型を使う。Pythonの decimal.Decimal、Javaの BigDecimal、JavaScriptなら decimal.js などのライブラリ。
from decimal import Decimal
# 文字列から作るのがコツ。float から作ると誤差を引き継ぐ
a = Decimal("0.1")
b = Decimal("0.2")
print(a + b) # 0.3 ← ピタリ
print(a + b == Decimal("0.3")) # True
注意点が1つ。Decimal(0.1)(floatから生成)だと、あの 0.1000...0555 をそのまま取り込んでしまう。必ず Decimal("0.1") と 文字列から 作る。速度は double より遅い(内部で桁を10進で持つため)ので、金額のように「速さより正確さ」の場面で使う。生成方法の違いはPython公式のdecimalドキュメントに明記されている。
言語別の落とし穴早見
同じ double を使う言語でも、表示の丸め方が違うので「言語のせい」と勘違いしやすい。
| 言語 |
0.1 + 0.2 の表示 |
メモ |
|---|---|---|
| JavaScript | 0.30000000000000004 |
生の値をそのまま表示 |
| Python 3 | 0.30000000000000004 |
repr は最短往復表現 |
| Java | 0.30000000000000004 |
System.out.println |
| Ruby | 0.30000000000000004 |
puts |
| PHP | 0.3 |
デフォルト精度14桁で丸めて隠す |
PHPは初期設定の表示精度が低いので 0.3 に見えるが、内部の値は他と同じ。「PHPだけ正しい」わけじゃなく、誤差を見せていないだけ。この挙動の違いは各言語の実装ではなく 表示層のポリシーの差 で、中身のビットは全言語共通。丸めの再現は0.30000000000000004.comで各言語横断の実測値がまとまっている(外部・二次情報)。
まとめとアクション
0.1 + 0.2 がズレるのはCPUのバグでも言語のバグでもなく、「有限のビットに、2進数だと循環する小数を詰めた」代償。防ぎ方は3つ、用途で選ぶ。
- 比較なら → イプシロン比較(
math.isclose/Number.EPSILON) - 金額なら → 最小単位の整数で持つ
- 正確さ優先なら →
Decimal/BigDecimal(文字列から生成)
今日やること(5分): 手元のNodeかPythonで 0.1 + 0.2 と Decimal("0.1") + Decimal("0.2") を打ち比べて、内部値を Decimal(0.1) で覗く。
今週やること(30分): 自分のコードを grep -rnE '==\s*[0-9]+\.[0-9]' src/ で洗い、小数の等値比較を洗い出してイプシロン比較へ置換する。
今月やること: 金額を扱う箇所を棚卸しして、float/number で持っている金額を「最小単位の整数」か Decimal に寄せる方針を1本決める。既存コードは新規計算処理から順に移す。
小数が絡む計算を書くたびに「これは2のべき乗で割り切れる数か?」と一度だけ自問する。それだけで踏み抜く回数が激減する。