Tech Learning Daily

2026-10-10 (Sat) — 第 85 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Lang 🌿 基礎 ⏱ 約 7 分

0.1 と 0.2 を足しただけなのに、なぜ 0.3 にならないのか?

ブラウザのコンソールで 0.1 + 0.2 を打つと 0.30000000000000004 が返ってくる。JavaScript がおかしいのかと思って Ruby や Python で試しても、まったく同じ結果になる。言語のバグでないなら、この 4 はどこから来たのだろうか。

🎯 3 行まとめ
  • ほとんどの言語の小数(JS の Number、Ruby の Float、Go の float64)は IEEE 754 の 64 ビット二進浮動小数点数で、0.1 は二進数で割り切れないため「0.1 に一番近い値」として保存される。
  • 0.1 + 0.2 は、近似値どうしの和がさらに丸められた結果が、0.3 と書いたときの近似値とは別の値に着地するため一致しない。
  • 金額は整数(最小単位)か十進型(PostgreSQL の numeric、Ruby の BigDecimal)で扱い、253 を超える整数 ID は JSON で文字列として渡す。

0.1 は二進数では「1/3」になる

十進数で 1/3 を書こうとすると 0.3333… と 3 が永遠に続き、有限の桁では正確に書けない。分母が 2 と 5 以外の素因数を含む分数は、十進数では割り切れないからだ。

コンピュータの小数は二進数で保存される。二進数で割り切れるのは分母が 2 の累乗の分数(1/2、1/4、3/8 など)だけで、1/10 は分母に 5 を含むため割り切れない。二進数の 0.1 は 0.000110011001100… と 0011 が無限に繰り返す。有限のビットに収めるには、どこかで打ち切って丸めるしかない。

その結果、ソースコードに 0.1 と書いた瞬間に保存される値は、正確には 0.1000000000000000055511151231257827021181583404541015625 である。画面に 0.1 と表示されるのは、言語が「この値を一意に特定できる最短の十進表記」で印字しているからにすぎない。誤差は計算の途中で生まれるのではなく、リテラルを書いた時点ですでに入っている。

64 ビットの中身と「目盛り」の間隔

JavaScript の Number、Ruby の Float、Go の float64、PostgreSQL の double precision は、いずれも IEEE 754 という規格の倍精度(binary64)形式で表される。64 ビットは符号 1 ビット、指数 11 ビット、仮数 52 ビットに分かれる。仮数とは有効数字の部分のことで、先頭の 1 を暗黙に持つため実質 53 ビットの精度がある。

 binary64 の内訳
┌─┬─────────┬──────────────────────┐
│S│exponent │ fraction (mantissa)  │
│1│ 11 bit  │ 52 bit               │
└─┴─────────┴──────────────────────┘
 値 = ±1.fraction × 2^(exponent-1023)

 隣り合う表現可能な値の間隔
 1 付近     : 2^-52 (約 2.2e-16)
 1000 付近  : 2^-43 (約 1.1e-13)
 2^53 付近  : 2

「浮動」小数点と呼ばれるのは、指数によって小数点の位置が動くからだ。有効数字の桁数は固定なので、値が大きくなるほど表現できる値どうしの間隔(目盛り)も広がる。1 のすぐ上の値との差は 2-52 で、JavaScript ではこれが Number.EPSILON として定義されている。

この目盛りの広がりは小数だけの問題ではない。253 付近では間隔が 2 になり、奇数を表現できなくなる。JavaScript の Number.MAX_SAFE_INTEGER が 253−1(9007199254740991)なのはこのためで、それを超えると MAX_SAFE_INTEGER + 1 === MAX_SAFE_INTEGER + 2 が true になる。

🍱 たとえるなら

浮動小数点数は、目盛りの位置が決まっている物差しだ。どんな長さを測っても、記録できるのは一番近い目盛りの値だけになる。しかもこの物差しは 0 の近くほど目盛りが細かく、遠くへ行くほど目盛りが粗くなる。0.1 も 0.3 もちょうど目盛りの上には乗っておらず、「近くの目盛り」に読み替えられて記録される。

なぜ「近似どうしの和」が 0.3 の近似と食い違うのか

0.1 も 0.3 も近似なら、足し算の結果も「0.3 の近似」に落ち着きそうに思える。しかし実際は、どちら側に丸められたかが値ごとに違う。0.1 と 0.2 はどちらも本当の値よりわずかに大きい目盛りに丸められ、0.3 はわずかに小さい目盛りに丸められる。

 保存される値(先頭 20 桁)
 0.1 → 0.10000000000000000555
 0.2 → 0.20000000000000001110
 和  → 0.30000000000000004440
 0.3 → 0.29999999999999998889
        ▲ 隣の目盛りに着地 → 不一致

上振れした 2 つを足すと誤差も足し合わされ、加算結果をさらに最も近い目盛りに丸めた時点で、0.3 の目盛りより 1 つ上の目盛りに着地する。0.30000000000000004 は、この「1 つ上の目盛り」を一意に表す最短表記である。一方で 0.1 * 3 も同じ値になるが、0.5 + 0.25 のように分母が 2 の累乗の値どうしなら丸めが起きず、結果は正確に 0.75 になる。

この丸めは表示にも漏れ出す。MDN によれば (2.55).toFixed(1) は '2.6' ではなく '2.5' を返す。2.55 に一番近い目盛りが 2.55 よりわずかに小さく、四捨五入の判定がその値に対して行われるからだ。

正確さが要る場面では型を変える

浮動小数点数が誤差を許すのは、ハードウェアで高速に計算でき、桁数の大きく違う値を同じ 8 バイトで扱えるからだ。科学計算や座標、統計値ならこの誤差は問題にならないことが多い。困るのは「1 円たりともずれてはいけない」金額や、桁が丸ごと保持されるべき ID である。

金額の扱いは大きく 2 通りある。1 つは最小単位の整数で持つ方法で、ドルなら 12.34 ドルをセント単位の 1234 として保存する。もう 1 つは十進数で桁を保持する型を使う方法で、PostgreSQL では numeric、Ruby では BigDecimal がこれに当たる。PostgreSQL の公式ドキュメントは、金額のように正確さが必要な値には numeric を使うよう明記しており、real と double precision は「不正確(inexact)」な型だと説明している。

-- double precision: 浮動小数点で丸めが入る
SELECT 0.1::float8 + 0.2::float8;
-- numeric(12,2): 全体 12 桁・小数 2 桁で正確
CREATE TABLE orders (price numeric(12,2));

numeric(12,2) の 12 は全体の桁数(precision)、2 は小数部の桁数(scale)で、小数 3 桁目を含む値を入れると scale に合わせて丸められる。精度もスケールも省略した numeric は桁数の制約が無い列になる。ただし numeric の計算は整数型や浮動小数点型より大幅に遅いとドキュメントにもあるため、速度が要る集計では整数の最小単位で持つ設計も選択肢に入る。

💼 実務でどう出会うか

典型は金額計算だ。1.15 ドルをセントに直そうと Math.floor(1.15 * 100) と書くと、1.15 * 100 が 114.99999999999999 になるため結果は 115 ではなく 114 になる。目盛りがわずかに下にずれた値を切り捨てたせいで、1 セント消えたわけだ。Rails でも t.float :price で作った列は浮動小数点になるので、金額列は t.decimal :price, precision: 12, scale: 2 のように precision と scale を明示して作る。

もう 1 つは巨大な整数 ID だ。Go の int64 や DB の bigint で採番した ID を JSON の数値として返すと、JavaScript の JSON.parse は Number に変換するため 253 を超えた ID の下の桁が化ける。Go の encoding/json ならフィールドに json:"id,string" タグを付けると数値を文字列として出力できる。また Go 自身も、JSON を interface{}(any)にデコードすると数値は float64 になる点に注意が要る。

テストで浮動小数点の結果を == で比較して、環境によって落ちたり通ったりする問題もここから来る。テストフレームワークには許容誤差つきの比較(RSpec の be_within など)が用意されている。

⌨️ 手を動かす(5 分)

0.1 と書いたときに実際に保存されている値と、目盛りの粗さを自分の目で確かめる。Node.js と python3 を使う。

node -e 'console.log(0.1 + 0.2, (0.1 + 0.2) === 0.3)'
node -e 'console.log((0.1).toFixed(55))'
node -e 'console.log(2**53, 2**53 + 1, 1000.1 + 1000.2)'
python3 -c 'from decimal import Decimal as D; print(D(0.1)); print(D(0.3)); print(D(0.1 + 0.2))'
python3 -c 'from decimal import Decimal as D; print(D("0.1") + D("0.2"))'

1 行目は 0.30000000000000004 false になる。Decimal(0.1) のように float を渡すと保存されている二進数の値がそのまま十進展開され、0.1 は上に、0.3 は下にずれていることが分かる。2**53 + 1 は 9007199254740992 のまま変わらない。最後の行のように文字列から作った十進型では、0.3 が正確に返る。

🙅 よくある誤解
  • JavaScript の数値が雑だから起きる — Ruby の Float、Python の float、Go の float64 も同じ IEEE 754 倍精度を使っており、0.1 + 0.2 は同じ値になる。言語ではなく二進数の表現方法の性質である。
  • Math.abs(a - b) < Number.EPSILON で比較すれば安全 — Number.EPSILON は 1 付近の目盛りの間隔にすぎない。MDN も、1000 付近の計算ではこの閾値では誤差を吸収できないと説明しており、扱う値の大きさと精度に合わせた許容誤差を選ぶ必要がある。
  • 表示を toFixed(2) で丸めれば計算は正しくなる — 丸めているのは表示だけで、内部の値は近似のままだ。丸めた値を次の計算に使えば誤差は残り、toFixed 自体も近似値に対して判定するため期待どおりに四捨五入されないことがある。
📖 用語ミニ辞典
IEEE 754
浮動小数点数の表現と演算を定める標準規格。主要な言語・CPU・DB が採用している。
倍精度(binary64)
符号 1・指数 11・仮数 52 ビットの 64 ビット形式。Number / Float / float64 の中身。
仮数
浮動小数点数の有効数字部分。ビット数が精度を決める。
Number.EPSILON
1 と、1 の次に大きい表現可能な値との差(2-52)。
MAX_SAFE_INTEGER
倍精度で全ての整数を正確に表せる上限(253−1)。
numeric / BigDecimal
十進数の桁を保持して正確に計算する型。浮動小数点より遅い。
🔗 もっと深く