BigDecimal.valueOf(float) の罠 — 浮動小数点の誤差が切り捨てで表面化する話
数値計算で「本来 2527 になるはずの値が 2526 になる」という 1 ズレのバグに遭遇した。原因を追っていくと、BigDecimal に float を渡していたことが根本にあった。よくある「浮動小数点の誤差」の話なのだが、BigDecimal と切り捨て処理が絡むと誤差が見えづらい形で最終結果に載る。同じ落とし穴にハマる人は多いと思うので、整理しておく。
この記事では以下がわかるようにまとめてみた。
- [What]
BigDecimal.valueOf(float)は何をしていて、なぜ誤差が入るのか - [Why] なぜ「わずかな誤差」が最終結果を 1 ズラすのか
- [How] 正しくはどう書くべきか
- [Deep Dive] 再発をどう機械的に防ぐか(バイトコードからの検出)
なお BigDecimal.valueOf(float) という表記は、後述のとおり valueOf(float) というオーバーロードが実在しないため、正確には「valueOf に float を渡す」ことの略記として使っている。
なお本文中のコードと出力はすべて JDK 11(OpenJDK)で実際に確認したものである。
発端: 「95% を掛ける」だけで結果がズレる
たとえば「2660 に 95% を掛けて、小数点以下は切り捨てる」という計算を考える。数学的には 2660 × 0.95 = 2527.0 なので、答えは 2527 のはずである。
ところがコードでは、掛率が float の 0.95f として渡っていた。これを BigDecimal に変換して計算し、最後に切り捨てたところ、結果は 2526 になっていた。
BigDecimal rule = BigDecimal.valueOf(2660);
// 掛率が float 0.95f だった場合
BigDecimal p = rule.multiply(BigDecimal.valueOf(0.95f));
System.out.println(p); // 2526.999968290328860
System.out.println(p.setScale(0, RoundingMode.FLOOR)); // 2526 ← 本来は 2527
0.95 を掛けたつもりが 2526.999... になり、切り捨てで 1 落ちている。犯人は 0.95f だ。
なぜ float は 0.95 を正確に表せないのか
float / double は 2 進の浮動小数点で、値を「2 の冪の和」で表す。0.5(= 2⁻¹)や 0.25(= 2⁻²)のような 2 の冪の和で書ける値だけが厳密に表現でき、0.1 や 0.95 のような値は厳密には表せず、最も近い表現可能値に丸められる。
しかも float は仮数部が 23bit しかなく、double(52bit)よりずっと粗い。0.95f が実際に保持している値を 10 進で展開するとこうなる。
System.out.println(new BigDecimal(0.95f)); // 0.949999988079071044921875
System.out.println(new BigDecimal(0.95)); // 0.9499999999999999555910790149937383830547332763671875
float の 0.95 は真の 0.95 より約 1.19×10⁻⁸ 小さい。double でも厳密ではないが、誤差は約 4.4×10⁻¹⁷ と桁違いに小さい。この「誤差の大きさの差」が後で効いてくる。
BigDecimal.valueOf(float) の落とし穴
ここが一番の勘所である。BigDecimal.valueOf には float を受けるオーバーロードが存在しない。あるのは valueOf(long) と valueOf(double) だけだ。したがって valueOf(0.95f) は次のように解決される。
valueOf(0.95f)
→ float が double へ暗黙昇格(値は 0.949999988079071044921875 のまま)
→ valueOf(double) が呼ばれる
→ 実装は new BigDecimal(Double.toString(val))
→ Double.toString(0.949999988079071044921875) = "0.949999988079071"
→ BigDecimal("0.949999988079071")
ポイントは、valueOf(double) の中身が Double.toString を通していることだ。double リテラルを渡す分にはこれが効いて、きれいな値に戻る。
System.out.println(BigDecimal.valueOf(0.95)); // 0.95 ← double を渡せば安全
System.out.println(BigDecimal.valueOf(0.95f)); // 0.949999988079071 ← float だと誤差が露出
Double.toString は「その double に戻る最短の 10 進文字列」を返す。double の 0.95 に対しては "0.95" を返すが、float 由来の値(double として見ると 0.95 とは別の点)に対しては "0.949999988079071" を返してしまう。float を valueOf に渡した瞬間、float 精度の誤差がそのまま桁として現れるというわけだ。
どんな値が危ないのか
float の値が「2 の冪の和で厳密に表せるか」で結果が変わる。実際に代表値で確認するとこうなる。
static void show(float f) {
System.out.printf("%-6s -> %s%n", f, BigDecimal.valueOf(f).toPlainString());
}
| 渡した float | valueOf(float) の結果 | 誤差 |
|---|---|---|
25f / 100f / 1f | 25 / 100 / 1 | なし(整数は厳密) |
0.5f / 12.5f / 17.5f | 0.5 / 12.5 / 17.5 | なし(2 の冪の和) |
0.1f | 0.10000000149011612 | あり(上振れ) |
0.2f | 0.20000000298023224 | あり(上振れ) |
0.7f | 0.699999988079071 | あり(下振れ) |
整数や 0.5 刻みは無害だが、0.1 や 0.7 のような値は誤差が乗る。しかも誤差の符号が値によって違うのが厄介だ。
なぜ「1」ズレるのか — 誤差が切り捨てで増幅される
2526.999... が 2526 になったのは、最後の処理が切り捨てだったからだ。切り捨て(RoundingMode.FLOOR や intValue())は、整数の境界を下からほんの少しでも割った瞬間、まるごと 1 つ下の整数に落ちる。
2526.9999968… 切り捨て → 2526 (誤差 −3×10⁻⁵ が −1 に増幅)
2526.9999968… 四捨五入 → 2527 (誤差が吸収される)
つまり実害が出るのは、次の 3 つが揃ったときだけである。
- 非 2 進小数の float(
0.1,0.7,0.95など)が使われている - その誤差が
BigDecimalの厳密演算で保持される(純粋な float/double 演算だと乗算結果が最近接値に丸め戻され、誤差が消えることが多い) - 最後に 切り捨てがあり、四捨五入なら吸収される誤差が増幅される
さらに、切り捨てで「減る」方向に効くのは float が下振れしているときだ。0.7f(= 0.6999…)や 0.95f(= 0.9499…)のように真値より小さく丸められる値は、切り捨てで 1 落ちやすい。この 3 条件が揃うと、計算結果が 1 ズレる。
ちなみに BigDecimal を経由せず double のまま掛けていれば、乗算結果が 2527.0 に丸め戻されて事なきを得ていた可能性が高い。「BigDecimal は正確」という直感が、逆に誤差を最後まで保存してしまうという皮肉な構図である。
正しい書き方
float から BigDecimal を作るなら Float.toString を通す
float をどうしても文字列化して渡すなら、Double.toString ではなく Float.toString を経由する。Float.toString(0.1f) は「その float に戻る最短文字列」= "0.1" を返すので、意図した 10 進値を厳密に復元できる。
float rate = 0.95f;
new BigDecimal(Float.toString(rate)); // 0.95 ◎ 意図どおり
BigDecimal.valueOf(rate); // 0.949999988079071 ✗ 誤差が乗る
Float.toString はプリミティブを受ける static メソッドなのでボクシングも不要だ(Float ラッパーの toString() を使うために box する必要はない)。
そもそも float を通さないのが最善
より根本的には、誤差が許されない数値は最初から String や BigDecimal で扱い、float/double を経由させないのが正しい。設定値やパラメータを float 型で保持していると、そこが誤差の発生源になる。
new BigDecimal("0.95"); // ◎ float/double を一度も通さない
BigDecimal.valueOf(0.95); // ○ double を渡す分には安全(Double.toString 経由)
new BigDecimal(0.95); // ✗ double の丸め誤差をそのまま取り込む
そして、パイプラインの途中で BigDecimal をわざわざ .floatValue() で float に畳んでから再び BigDecimal.valueOf(...) で戻す、といった float 往復をしていないかも見直したい。内部で BigDecimal 計算ができているなら、BigDecimal のまま受け渡すのが一番安全だ。
まとめると優先度はこうなる。
- 誤差が許されない値は
String→BigDecimal(または最小単位の整数)で持つ - やむを得ず float から作るなら
new BigDecimal(Float.toString(f)) BigDecimal.valueOf(double)は許容(ただし float を渡すな)new BigDecimal(double)とBigDecimal.valueOf(float)は避ける
再発をどう防ぐか — バイトコードから検出する
一度直しても、新しいコードで同じパターンが再び混入すれば意味がない。CI で機械的に弾きたい。だが、これはテキスト検索では捕まえきれない。
new BigDecimal(getRate()); // getRate() が float を返すかは、この行だけ見てもわからない
getRate() の戻り値型を解決しないと危険かどうか判定できないので、grep や正規表現ベースの Linter(Checkstyle など)ではリテラル以外を取りこぼす。実際、上のような「メソッド戻り値経由の float」こそが現場で起きやすい形だった。
一方、コンパイル後のバイトコードなら型が確定している。どんな書き方でも、new BigDecimal(double) は BigDecimal."<init>":(D)V に、float を渡した valueOf は f2d(float→double 変換命令)の直後に BigDecimal.valueOf:(D) として現れる。
# javap -c で逆アセンブルした様子(float を valueOf に渡した場合)
12: f2d
13: invokestatic #5 // Method java/math/BigDecimal.valueOf:(D)Ljava/math/BigDecimal;
これを利用すれば、JDK 同梱の javap でクラスファイルを逆アセンブルし、上記の目印を検出するだけで、変数・メソッド戻り値経由も含めて型正確に検出できる。新規依存もいらない。実際にこれを JUnit テストとして実装し、build/classes を走査して違反があれば fail させるようにしたところ、修正漏れの箇所まで正確に炙り出せた。
補足として、ArchUnit のような静的解析ライブラリでも new BigDecimal(double) は検出できるが、valueOf(float) は valueOf(double) に解決済みのため区別できない。「f2d の直後か」を見るバイトコード走査の方がこの用途には向いていた。
まとめ
BigDecimal.valueOf(float)はvalueOf((double)f)=new BigDecimal(Double.toString((double)f))に解決され、float 精度の誤差が桁として露出する。valueOf(double)は安全だが、そこに float を渡してはいけない。- 整数や
0.5刻みの float は厳密なので無害。0.1や0.7のような非 2 進小数だけが誤差を持つ。 - 「誤差混入(float)× 誤差保持(BigDecimal)× 切り捨て(FLOOR/intValue)」の 3 条件が揃うと、最終結果が 1 ズレる。四捨五入なら吸収される。
- 直すなら
new BigDecimal(Float.toString(f))、根本的には float を通さずString/BigDecimalで扱う。 - 再発防止はテキスト検索では不十分。バイトコード(
javap)を走査すれば型正確に検出でき、CI に組み込める。
「BigDecimal を使っているから正確」という思い込みが、逆に float の誤差を最後まで運んでしまう。誤差を混ぜない入口の設計と、切り捨ての手前に浮動小数点を置かないことが肝心だと思う。