浮動小数点の精度:なぜ 0.1 + 0.2 ≠ 0.3 なのか(2026年版)
0.1 + 0.2 が 0.30000000000000004 を返すのは、0.1 も 0.2 も 2 進浮動小数点数の内部には存在しないからだ。double が保持できるのは m × 2ⁿ という形の値だけであり、10 分の 1 は 2進数では無限に続く循環小数になる。そのためハードウェアは、最も近い表現可能な値を代わりに格納する。0.1 の背後にある double は、正確には 0.1000000000000000055511151231257827021181583404541015625 である。0.2 の背後にあるのは 0.200000000000000011102230246251565404236316680908203125 だ。この 2 つの格納値を足すと、厳密な和は表現可能な 2 つの double のあいだに落ちる。IEEE 754 は近いほうへ丸めるが、その値は 0.3 のわずかに上に位置している。
要点はこれで尽きている。浮動小数点の精度は有限で、10 進小数が 2 進のグリッドにぴったり乗ることはめったにない。しかも演算のたびに丸めが繰り返される。
これは JavaScript の癖でも CPU の欠陥でもない。Python、Java、C、Go、Rust、Swift、そして SQL の FLOAT 列でもまったく同じ結果が出る。すべてが IEEE 754 の上で動いているからだ。IEEE 754 浮動小数点コンバーターに任意の値を貼り付ければ、正確なビット列と、桁ひとつまで正確な格納値を確認できる。
厄介なのはここから先だ。誤差が生まれる場所、言語がそれを隠す仕組み、== に頼らない比較の書き方、精度が金額に直結する場面での型選び。どれもこの 1 つの事実から導かれる。
浮動小数点の精度を 60 秒で
驚きの大半は、次の 3 点で説明がつく。
- 2 進浮動小数点数が表現できるのは m × 2ⁿ の形の数、すなわち 2 のべき乗の和だけである。
0.1、0.2、0.3のような 10 進小数はその形ではないので、取り込む時点で丸められる。- 算術演算はすべて、その結果を最も近い表現可能な値へふたたび丸める。
| 10 進数 | 正確に表現できるか | 理由 |
|---|---|---|
| 0.5 | できる | 2⁻¹ |
| 0.25 | できる | 2⁻² |
| 0.75 | できる | 2⁻¹ + 2⁻² |
| 2.5 | できる | 2 + 2⁻¹ |
| 100 | できる | 2⁵³ 未満の任意の整数 |
| 0.1 | できない | 1/10、分母に 5 の因数が残る |
| 0.2 | できない | 1/5、同じ理由 |
| 0.3 | できない | 3/10、やはり 5 が残る |
経験則はこうだ。分数を約分して分母が 2 のべき乗だけになれば、その値は正確に表現できる。5 の因数が残っていれば、近似として格納される。
なぜ 0.1 は正確に格納できないのか
2 進小数点より下のビットは、1/2、1/4、1/8、1/16、1/32……という重みを担っている。これらを組み合わせて 0.1 を作ろうとしても、決してぴったりにはならない。1/16 = 0.0625、そこに 1/32 を足して 0.09375、さらに 1/256 を足して 0.09765625 と、一歩ごとに近づきはするが、正確にはならない。2進数で書くと、10 分の 1 は 0.0001100110011… という循環小数になり、0011 が永遠に繰り返す。
10 進法も、別の分数で同じ問題を抱えている。1/3 を基数 10 で書けば 0.333… という循環小数になり、有限桁の数字列でこれを言い当てることはできない。それを 10 進法のバグと呼ぶ人はいない。基数 2 は単に線を引く場所が違うだけで、1/10 がたまたまその向こう側に落ちてしまった。長い説明が読みたければ、Python 公式ドキュメントの浮動小数点演算:その問題と制限が同じ展開をたどっている。
2 進法は基数 2 にすぎず、16 進法や 8 進法も同じ位取り算術で動く。2進数・16進数・10進数・8進数変換を使えば値が基数のあいだをどう移動するか分かるし、基数変換ガイド:2進数・16進数・8進数・10進数の相互変換と実践では整数側を詳しく扱っている。基数 2 が痛みはじめるのは小数の側だ。
double が実際に格納しているもの
64 ビットの double は 3 つのフィールドに分かれる。符号 1 ビット、指数部 11 ビット、仮数部 52 ビットである。仮数部には格納されない暗黙の先頭 1 が付くので、有効数字は 53 ビット分、10 進に直せばおよそ 15.95 桁の浮動小数点精度になる。指数部は 1023 のバイアスを付けて格納される。
どの言語でも、通常の表示より多くの桁を要求すれば近似が顔を出す。
>>> 0.1
0.1
>>> f"{0.1:.20f}"
'0.10000000000000000555'
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
(0.1).toFixed(20); // '0.10000000000000000555'
(0.1).toPrecision(20); // '0.10000000000000000555'
Decimal(0.1) は正直な表示だ。すでに手元にある double を再丸めせずに変換し、55 桁すべてを出力する。IEEE 754 浮動小数点コンバーターの精度パネルは、入力した任意の数値について同じ展開を、入力値に対する符号付きの丸め誤差と並べて表示する。
0.30000000000000004 の裏にある厳密な算術
では、なぜ結果はあの特定の数なのか。惜しい候補ならほかにもいくらでもある。
丸めをいっさい入れずに 2 つの格納値を厳密に足すと、こうなる。
0.1 → 0.1000000000000000055511151231257827021181583404541015625
0.2 → 0.200000000000000011102230246251565404236316680908203125
sum → 0.3000000000000000166533453693773481063544750213623046875
この和自体は表現可能な double ではない。2 進グリッド上で隣り合う 2 つの値のあいだに位置する。
below: 0.299999999999999988897769753748434595763683319091796875 (prints as 0.3)
above: 0.3000000000000000444089209850062616169452667236328125 (prints as 0.30000000000000004)
ここで両側との距離を測ると、妙なことが分かる。厳密な和は下の隣接値より 2⁻⁵⁵ ≈ 2.776e-17 だけ上にあり、上の隣接値より 2⁻⁵⁵ だけ下にある。どちらにも寄っていない、ちょうど真ん中の完全な同点だ。
最近接への丸めはここで頼れる距離を持たないので、IEEE 754 はタイブレーク規則を適用する。偶数への丸め(round-half-to-even)、つまり仮数部の最下位ビットが 0 になるほうを選ぶ規則だ。下の隣接値の仮数は 5404319552844595 で奇数。上のほうは 5404319552844596 で偶数だ。偶数側が勝ち、ビットパターンは 0x3FD3333333333334 になる。これはリテラル 0.3 が対応する double より 1 ULP 上の値であり、0.30000000000000004 と表示される。
なぜ偶数を優先するのか。半端をつねに切り上げると、長い総和が一方向へ偏ってしまう。偶数側へ交互に寄せることで、多数の演算にわたるドリフトをほぼゼロに保てる。会計士が使う銀行家丸めと同じものをシリコンの上で標準化したわけで、インターネットでもっとも有名な浮動小数点の丸め誤差が ...04 で終わる直接の理由がこれだ。
丸めをまったく必要としない和と比べてみるとよい。
0.5 + 0.25 === 0.75; // true
0.1 + 0.2 === 0.3; // false
0.5、0.25、0.75 はそれぞれ 2⁻¹、2⁻²、2⁻¹ + 2⁻² だ。3 つとも正確で、その和も正確なので、等値比較は学校の算数が示すとおりに振る舞う。コンバーターの隣接値パネルは、入力した値の前後にある表現可能な値と ULP 間隔を表示するので、0.3 の周辺のグリッドを自分で確かめることもできる。
なぜ言語はときに誤差を隠すのか
この話が薄気味悪く見える一因はここにある。0.1 は 0.1 と表示されるのに、0.1 + 0.2 は 17 桁で表示される。格納形式は同じなのに、出力はまるで違う。
現代のランタイムは**最短往復(shortest round-trip)**形式を使う。パースし直すと同一の double に戻る、最短の 10 進文字列を出力するという方式だ。"0.1" はすでに 0.1 のために格納された値へ一意に往復するので、それがそのまま表示される。和 0.1 + 0.2 は "0.3" が対応する double とは別物なので、フォーマッタは文字列があいまいでなくなるまで桁を足し続けるしかなく、それには 17 桁すべてが必要になる。この系譜は David Gay が 1990 年に発表した正しい丸めの研究から始まり、Grisu、続いて Ryu があらゆる標準ライブラリに載る速度まで引き上げた。言語は嘘をついていない。その値を一意に特定できる最短のラベルを見せているだけだ。
言語ごとの挙動
| 言語 | 既定の浮動小数点型 | 0.1 + 0.2 の表示 | 厳密な 10 進の選択肢 |
|---|---|---|---|
| JavaScript | number(double のみ) | 0.30000000000000004 | 標準にはない。整数セントかライブラリ |
| Python | float(double) | 0.30000000000000004 | decimal.Decimal、fractions.Fraction |
| Java | double | 0.30000000000000004 | BigDecimal(文字列から構築する) |
| C# | double | 0.30000000000000004 | decimal(128 ビット、基数 10) |
| Go | float64 | 0.30000000000000004(変数経由) | math/big.Rat |
| Rust | f64 | 0.30000000000000004 | rust_decimal クレート |
| SQL | FLOAT / DOUBLE PRECISION | 0.30000000000000004 | NUMERIC / DECIMAL |
このうち 2 行には罠が潜んでいる。
Go は定数を任意精度で畳み込む。リテラル式はコンパイル時に厳密に評価され、そのあとで初めて変換される。だから定数式 0.1 + 0.2 は float64 になる前にきっかり 0.3 になる。
package main
import "fmt"
func main() {
fmt.Println(0.1 + 0.2) // 0.3 — constant folded exactly, then converted
a, b := 0.1, 0.2
fmt.Println(a + b) // 0.30000000000000004
fmt.Println(a+b == 0.3) // false
}
Java の BigDecimal は、double を渡すと誤差ごと受け継ぐ。コンストラクタが渡されたビットを忠実に変換するからだ。
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
// 0.3
SQL は方言ではなく列の型で分かれる。
-- PostgreSQL: a bare decimal literal is NUMERIC, which is exact
SELECT 0.1 + 0.2 = 0.3; -- t
-- Cast to binary floating point and equality fails
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8; -- f
-- SQLite: REAL is a double, and the printed value hides it
SELECT 0.1 + 0.2; -- 0.3
SELECT 0.1 + 0.2 = 0.3; -- 0 (false)
この SQLite の 2 行は矛盾しているように見える。表示された結果は 0.3 だと言い、比較はそうではないと言う。どちらの言い分も正しい。
浮動小数点数の比較:== が失敗する理由と Number.EPSILON が許容誤差ではない理由
0.1 + 0.2 === 0.3 が false になるのは、左辺が別の double だからだ。代わりに許容誤差を使って比較しろというのが定番の助言だが、いちばん繰り返し語られている版は間違っている。
Math.abs(a - b) < Number.EPSILON; // ⚠️ not a general-purpose comparison
Number.EPSILON は 2.220446049250313e-16、つまり 2⁻⁵² である。MDN の定義では、1 と、1 より大きい最小の double との差とされている。もう一度読んでほしい。これは 1.0 におけるグリッド間隔であって、万能の誤差予算ではない。浮動小数点の間隔は 2 のべき乗ごとに倍になるので、定数のしきい値は、それを測った近傍を離れたとたんどこでも誤りになる。
1.0 の近くではたまたまうまくいく。
Math.abs((0.1 + 0.2) - 0.3); // 5.551115123125783e-17
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true
同じ計算を 10 億倍にスケールすると崩れる。
const a = (0.1 + 0.2) * 1e9;
const b = 0.3 * 1e9;
a === b; // false
Math.abs(a - b); // 5.960464477539063e-8
Math.abs(a - b) < Number.EPSILON; // false — yet a and b are adjacent doubles
1e9 のあたりでは、隣り合う double の間隔はおよそ 1.19e-7 になる。1e9 は [2²⁹, 2³⁰) に入るので、そこでの ULP は 2²⁹⁻⁵² = 2⁻²³ ≈ 1.19e-7 であり、Number.EPSILON のおよそ 5 億倍だ。その大きさで実際に生じる丸め誤差はしきい値を軽々と超えるので、この判定は永遠に false を返す。逆に 1e-20 のあたりでは、同じ定数が甘すぎて、明らかに異なる値を等しいと宣言してしまう。
絶対許容誤差・相対許容誤差・ULP 距離
使える道具は 3 つあり、守備範囲はそれぞれ違う。
- 絶対許容誤差は
|a − b| <= atol。値の大きさが事前に分かっているときに正しく、ゼロとの比較で唯一機能する方法でもある。0 の周りでは相対許容誤差がつねに 0 になってしまうからだ。 - 相対許容誤差は
|a − b| <= rtol × max(|a|, |b|)。桁をまたいでデータに合わせてスケールするが、ゼロ付近で破綻する。 - ULP 距離は、2 つのビットパターンのあいだに表現可能な値がいくつあるかを数える。もっとも精密で、もっとも読みにくい。
前の 2 つを組み合わせると、まともな数値計算ライブラリがどれも採っている形になる。
function nearlyEqual(a, b, rtol = 1e-9, atol = 1e-12) {
if (a === b) return true; // handles Infinity === Infinity
const diff = Math.abs(a - b);
return diff <= Math.max(rtol * Math.max(Math.abs(a), Math.abs(b)), atol);
}
nearlyEqual(0.1 + 0.2, 0.3); // true
nearlyEqual((0.1 + 0.2) * 1e9, 0.3 * 1e9); // true
nearlyEqual(0, 1e-15); // true
nearlyEqual(1, 1.0001); // false
Python は標準ライブラリでこれを提供しており、NumPy の allclose も同じ式を使っている。
import math
math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9, abs_tol=1e-12) # True
math.isclose(1e9, 1e9 + 1e-7, rel_tol=1e-9) # True
厳密版が必要なら、ビットを単調な整数順序へ写してから引き算する。
const view = new DataView(new ArrayBuffer(8));
function toOrdinal(x) {
view.setFloat64(0, x);
const i = view.getBigInt64(0);
return i >= 0n ? i : -(1n << 63n) - i; // make negatives order correctly
}
function ulpDistance(a, b) {
const d = toOrdinal(a) - toOrdinal(b);
return d < 0n ? -d : d;
}
ulpDistance(0.1 + 0.2, 0.3); // 1n
ulpDistance((0.1 + 0.2) * 1e9, 0.3 * 1e9); // 1n
ulpDistance(-0, 0); // 0n
ちなみに、厳密な == がつねに誤りというわけではない。2⁵³ 未満の整数値を持つ double、代入したきり計算していない定数、そして値がちょうどゼロかどうかの判定には問題なく使える。
精度が金額に直結するとき:正しい数値型の選び方
通貨と 2 進浮動小数点数は相性が悪い。困るのは個々の誤差の大きさではなく、その誤差が系統的で再現性を持ち、しかも監査人に指摘されるまで表に出ないことだ。
19.99 * 100; // 1998.9999999999998
Math.round(19.99 * 100); // 1999
(1.005).toFixed(2); // '1.00' — 1.005 is stored as 1.00499999999999989...
2 つめは毎年どこかのチームを引っかける。toFixed の丸めが間違っていたのではない。渡された値がすでに 1.005 を下回っていたのだ。
整数の最小単位
ドルではなくセントで持つ。1999 は $19.99 を意味する。算術は整数のまま走らせ、割り算は表示する瞬間まで遅らせる。
const priceCents = 1999; // $19.99
const subtotal = priceCents * 3; // 5997 — exact
const totalCents = 1000; // $10.00 split three ways
const share = Math.floor(totalCents / 3); // 333
const remainder = totalCents - share * 3; // 1 cent to allocate
注意点が 2 つある。Number.MAX_SAFE_INTEGER(9007199254740991、つまり 2⁵³ − 1)を超えると JavaScript の整数は正確でなくなるので、BigInt を使うこと。それに、整数にしたところで丸めポリシーが決まるわけではない。除算、パーセンテージ、税の按分では、余った 1 セントをどこへ寄せるかを明示的に決めるルールが依然として要る。
10 進型
10 進型は基数 10 の桁を格納するので、10 進小数がぴったり収まる。生成するときは必ず文字列からにすること。float から作れば、始める前から 2 進の誤差を受け継いでしまう。
from decimal import Decimal
Decimal("0.1") + Decimal("0.2") == Decimal("0.3") # True
Decimal(0.1) # 0.1000000000000000055511151231257827021181583404541015625
Console.WriteLine(0.1m + 0.2m); // 0.3
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
Java には BigDecimal、C# にはネイティブの 128 ビット decimal、SQL には NUMERIC(12,2) がある。どれも 10 進小数は直してくれる。しかし 1/3 はどれも直せない。10 進型もやはり浮動小数点形式であり、基数を 2 から 10 へ移しただけだからだ。
判断マトリクス
| ユースケース | 推奨する型 | 理由 |
|---|---|---|
| 金額・請求・税 | 整数の最小単位または 10 進型 | 厳密な 10 進算術、監査可能な丸め |
| 科学計算・物理 | double(FP64) | 15〜16 桁で十分、ライブラリの対応も最良 |
| 幾何・グラフィックス | float または double + 許容誤差 | もともと近似、イプシロンで比較する |
| ML の学習 | bfloat16 | FP32 の範囲を半分のメモリで |
| ML の推論・保存 | FP16 | 値が正規化されていれば仮数ビットが多い |
| カウンタ・ID・キー | 64 ビット整数または BigInt | そもそも float に置くものではない |
誤差の蓄積と桁落ち
丸め誤差 1 つは 1e-17 で無害だ。それが 1 万個集まればサポートチケットになる。
let total = 0;
for (let i = 0; i < 10000; i++) total += 0.1;
total; // 1000.0000000001588
total - 1000; // 1.588205122970976e-10
ドリフトはおおむね演算回数に比例して育つ。このくらいのループでは見えないが、数百万行を毎晩集計するとなればそうはいかない。
桁落ち(catastrophic cancellation)
もっと厄介な失敗は減算だ。ほぼ等しい 2 つの数を引くと、一致していた上位桁が打ち消し合い、それまでに蓄積していた誤差が結果を支配する。絶対誤差は増えない。爆発するのは相対誤差だ。結果が小さくなったのに、誤差の大きさは変わらないからである。
教科書に載っている一巡分散の公式 E[x²] − (E[x])² は、まっすぐこの罠へ踏み込む。
const data = [1e9, 1e9 + 1, 1e9 + 2];
const mean = data.reduce((s, x) => s + x, 0) / data.length;
// One-pass: E[x²] − (E[x])²
data.reduce((s, x) => s + x * x, 0) / data.length - mean * mean; // 0
// Two-pass: centre the data first
data.reduce((s, x) => s + (x - mean) ** 2, 0) / data.length; // 0.6666666666666666
真の母分散は 2/3 だ。一巡の公式はきっかりゼロを返す。少しずれているのではなく、まったく別の答えである。どちらの被演算数も 1e18 前後で、両者の差が最後の格納ビットより下に沈んでいるからだ。Welford のオンラインアルゴリズムは、平均と偏差平方和を逐次更新することでこれを避ける。
二次方程式の解の公式も b² ≫ 4ac のとき同じ弱点を持ち、直し方も同じ流儀だ。
const a = 1, b = 1e8, c = 1;
const disc = Math.sqrt(b * b - 4 * a * c);
(-b + disc) / (2 * a); // -7.450580596923828e-9 — about 25% wrong
(2 * c) / (-b - disc); // -1e-8 — correct
どちらの行も同じ根を計算している。1 行目はほぼ等しい 2 つの数を引き、2 行目は引かずに済むよう代数を組み替えている。桁落ちと誤差限界の解説としては、Goldberg の What Every Computer Scientist Should Know About Floating-Point Arithmetic がいまも基準となる文献だ。
Kahan の総和
Kahan の補正付き総和は、各加算で捨てられた下位ビットを記録し続け、それを次の反復で戻してやる。
function kahanSum(values) {
let sum = 0;
let compensation = 0;
for (const value of values) {
const y = value - compensation;
const t = sum + y;
compensation = (t - sum) - y; // the bits that fell off
sum = t;
}
return sum;
}
kahanSum(new Array(10000).fill(0.1)); // 1000 — exactly
Python ではこれがただで手に入る。math.fsum([0.1] * 10_000) は 1000.0 を返すし、CPython 3.12 以降は組み込みの sum() も Neumaier 補正を使うので、sum([0.1] * 10_000) は正確になる。一方で、明示的な += ループはいまも 1000.0000000001588 までドリフトする。
長い総和、金額の集計、数値積分では補正に手を伸ばすとよい。値が数個しかない場合や、すでに厳密な型へ移った場合は不要だ。
前提を壊す特殊な値
IEEE 754 は、期待どおりの規則に従わない値のためにビットパターンを予約している。
NaN === NaN; // false — mandated by the standard
Number.isNaN(NaN); // true
[NaN].indexOf(NaN); // -1 (uses ===)
[NaN].includes(NaN); // true (uses SameValueZero)
-0 === 0; // true
Object.is(-0, 0); // false
1 / -0; // -Infinity
1e308 * 10; // Infinity — overflow never throws
Infinity - Infinity; // NaN
Number.MIN_VALUE; // 5e-324 — the smallest subnormal double
NaN は自分自身も含めて何とも等しくならない。これは設計上そうなっており、失敗した計算が正しい結果になりすますことがないようにするためだ。判定には Number.isNaN() か Python の math.isnan() を使う。
オーバーフローはもっと静かで、もっと危険だ。±Infinity を返してそのまま進み、誰かがグラフの空白に気づくまで下流へ伝播していく。負のゼロは別個のビットパターンでありながら、=== では +0 と等しくなる。負の値のアンダーフローや -1 * 0 から生まれ、除算か Object.is でしかその正体が見えない。
非正規化数(subnormal)は、ゼロと最小の正規化数のあいだの隙間を埋める。指数部が全ビット 0 のとき暗黙の先頭 1 が外れ、仮数部が段階的にゼロへ縮んでいく。おかげで、等しくない 2 つの float の差がちょうどゼロに丸められることは決してない。代償は精度の低下と、ハードウェアによっては急激な性能低下だ。IEEE 754 浮動小数点コンバーターの特殊値チップは ±0、±Infinity、NaN、最小の非正規化数を読み込むので、それぞれのビットパターンを直接調べられる。
float・double・FP16・bfloat16 の比較
同じ規格でも、範囲と浮動小数点精度への予算配分が違う。
| 形式 | 総ビット数 | 指数部 | 仮数部 | 10 進の概算桁数 | 有限の最大値 | 典型的な用途 |
|---|---|---|---|---|---|---|
| binary16 (FP16) | 16 | 5 | 10 | ~3.3 | 65504 | GPU 推論、コンパクトな保存 |
| bfloat16 | 16 | 8 | 7 | ~2.4 | ~3.39 × 10³⁸ | ML の学習 |
| binary32 (float) | 32 | 8 | 23 | ~7.2 | ~3.40 × 10³⁸ | グラフィックス、センサー、GPU |
| binary64 (double) | 64 | 11 | 52 | ~15.9 | ~1.80 × 10³⁰⁸ | それ以外のあらゆる場面の既定 |
指数ビットは範囲を買い、仮数ビットは精度を買う。FP16 は予算を精度に振ったぶん上限が 65504 で頭打ちになる。これは実際の勾配の値に近すぎて、Infinity へのオーバーフローが日常的な危険になる。bfloat16 は逆の取引をした。FP32 の 8 ビットの指数部をそのまま残し、仮数部 7 ビットで我慢する。おかげで FP16 ならあふれる値も素通りし、学習でロススケーリングが必要になることはめったにない。
既定は double にしておく。メモリか帯域のボトルネックを実測してから、より狭い形式へ落とすこと。狭めた代償は、同じ値を各形式でコンバーターに入力すれば確認できる。
浮動小数点の精度を扱う実践的なルール
- 計算結果の float に
==を使わない。データの規模に合わせた、絶対+相対の複合許容誤差を使う。 Number.EPSILONをしきい値として扱わない。1.0 におけるグリッド間隔を表すだけの定数だ。- 金額は整数の最小単位か 10 進型で持ち、10 進値はつねに文字列から構築する。
- 長い総和は Kahan か Neumaier、あるいは
math.fsumで補正する。 - ほぼ等しい量を引く式は組み替える。許容誤差をきつくして凌ごうとしない。
- 人間向けの整形は明示的に行う。
toFixed、f-string、printfを使い、既定のreprをそのままユーザーに出さない。 - サービス境界をまたぐ数値は文字列か整数で送る。JSON を double 経由で往復させると、静かに情報が落ちる。
- 通貨の列には
FLOATではなくNUMERICを選ぶ。ローンチ後にこれを直すとなれば、マイグレーションと突合作業が待っている。 - 値がありえない見た目になったらビットで考える。同じ習慣はビット演算完全ガイド:AND・OR・XOR・シフトとビットマスクの実践の土台でもあり、浮動小数点の謎を検算できる算術へ変えてくれる。
FAQ
浮動小数点演算は壊れているのか
浮動小数点演算は壊れていない。IEEE 754 のとおりに正確に動いている。この規格は実数を有限個の 2進数字で表現するもので、0.1 のような 10 進小数には正確な 2 進表現が存在しないため、最も近い表現可能な値が格納される。壊れているのは、どんな 10 進数でも収まるはずだという期待のほうだ。
Python は 0.1 を 0.1 と表示するのに、0.1 + 0.2 を 0.30000000000000004 と表示するのはなぜか
Python の repr は最短往復形式を使う。パースし直すと同一の double に戻る、最短の 10 進文字列を表示するということだ。"0.1" はすでに自分の double を一意に特定している。和 0.1 + 0.2 は "0.3" が対応する double とは別の double なので、あいまいさを残さないためにフォーマッタは 17 桁すべてを出力しなければならない。
2 つの float の比較に Number.EPSILON を使ってはいけないのはなぜか
Number.EPSILON(およそ 2.22e-16)は 1 と次の double とのあいだの隙間であって、万能の誤差予算ではない。浮動小数点の間隔は 2 のべき乗ごとに倍になるので、1e9 の近くでは隣り合う double が約 1.19e-7 離れている。そこで生じる本物の丸め誤差は EPSILON を超えてしまい、比較はつねに false になる。相対許容誤差か、複合の許容誤差を使うこと。
最後に丸めるなら金額に浮動小数点数を使ってもよいか
最後に丸めても浮動小数点の金額は救えない。誤差は加算、乗算、多段階の按分にわたって蓄積し、丸めるタイミングによって合計そのものが変わる。19.99 * 100 の時点ですでに 1998.9999999999998 になる。監査には再現可能な厳密算術が要る。整数セント、decimal、BigDecimal、あるいは SQL の NUMERIC だ。
double に正確に格納できる 10 進数はどれか
その形式の精度に収まる整数 m と n を使って m / 2ⁿ と書ける値だけだ。0.5、0.25、0.125、0.75、2.5 がこれに当たり、2⁵³ までのすべての整数も含まれる。まず約分すること。0.1、0.2、0.3、0.7 のように分母に 5 の因数が残るなら、その値は近似される。
0.1 + 0.2 === 0.3 は false なのに 0.5 + 0.25 === 0.75 が true なのはなぜか
0.5、0.25、0.75 がそれぞれ 2⁻¹、2⁻²、2⁻¹ + 2⁻² であり、すべて正確に表現でき、その和にも丸めが要らないからだ。0.1、0.2、0.3 はいずれも近似として格納されており、はじめの 2 つの和を丸めると、0.3 が対応する double より 1 ULP 上に着地する。
これはどのプログラミング言語でも起きるのか
IEEE 754 の 2 進浮動小数点数の上に作られた言語であれば、どれも同じ挙動を見せる。JavaScript、Python、Java、C、C++、Go、Rust、Swift、そして SQL の FLOAT だ。違うのは既定の表示精度と、厳密な 10 進型が標準で付いてくるかどうか(C# の decimal や Python の decimal モジュールなど)である。