BigDecimal.valueOf(float) の罠 — 浮動小数点の誤差が切り捨てで表面化する話

数値計算で「本来 2527 になるはずの値が 2526 になる」という 1 ズレのバグに遭遇した。原因を追っていくと、BigDecimalfloat を渡していたことが根本にあった。よくある「浮動小数点の誤差」の話なのだが、BigDecimal と切り捨て処理が絡むと誤差が見えづらい形で最終結果に載る。同じ落とし穴にハマる人は多いと思うので、整理しておく。

この記事では以下がわかるようにまとめてみた。

  • [What] BigDecimal.valueOf(float) は何をしていて、なぜ誤差が入るのか
  • [Why] なぜ「わずかな誤差」が最終結果を 1 ズラすのか
  • [How] 正しくはどう書くべきか
  • [Deep Dive] 再発をどう機械的に防ぐか(バイトコードからの検出)

なお BigDecimal.valueOf(float) という表記は、後述のとおり valueOf(float) というオーバーロードが実在しないため、正確には「valueOffloat を渡す」ことの略記として使っている。

なお本文中のコードと出力はすべて JDK 11(OpenJDK)で実際に確認したものである。

発端: 「95% を掛ける」だけで結果がズレる

たとえば「2660 に 95% を掛けて、小数点以下は切り捨てる」という計算を考える。数学的には 2660 × 0.95 = 2527.0 なので、答えは 2527 のはずである。

ところがコードでは、掛率が float0.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.10.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());
}
渡した floatvalueOf(float) の結果誤差
25f / 100f / 1f25 / 100 / 1なし(整数は厳密)
0.5f / 12.5f / 17.5f0.5 / 12.5 / 17.5なし(2 の冪の和)
0.1f0.10000000149011612あり(上振れ)
0.2f0.20000000298023224あり(上振れ)
0.7f0.699999988079071あり(下振れ

整数や 0.5 刻みは無害だが、0.10.7 のような値は誤差が乗る。しかも誤差の符号が値によって違うのが厄介だ。

なぜ「1」ズレるのか — 誤差が切り捨てで増幅される

2526.999...2526 になったのは、最後の処理が切り捨てだったからだ。切り捨て(RoundingMode.FLOORintValue())は、整数の境界を下からほんの少しでも割った瞬間、まるごと 1 つ下の整数に落ちる。

2526.9999968…   切り捨て → 2526   (誤差 −3×10⁻⁵ が −1 に増幅)
2526.9999968…   四捨五入 → 2527   (誤差が吸収される)

つまり実害が出るのは、次の 3 つが揃ったときだけである。

  1. 非 2 進小数の float0.1, 0.7, 0.95 など)が使われている
  2. その誤差が BigDecimal の厳密演算で保持される(純粋な float/double 演算だと乗算結果が最近接値に丸め戻され、誤差が消えることが多い)
  3. 最後に 切り捨てがあり、四捨五入なら吸収される誤差が増幅される

さらに、切り捨てで「減る」方向に効くのは 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 を通さないのが最善

より根本的には、誤差が許されない数値は最初から StringBigDecimal で扱い、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 のまま受け渡すのが一番安全だ。

まとめると優先度はこうなる。

  1. 誤差が許されない値は StringBigDecimal(または最小単位の整数)で持つ
  2. やむを得ず float から作るなら new BigDecimal(Float.toString(f))
  3. BigDecimal.valueOf(double) は許容(ただし float を渡すな
  4. 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 を渡した valueOff2d(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.10.7 のような非 2 進小数だけが誤差を持つ。
  • 「誤差混入(float)× 誤差保持(BigDecimal)× 切り捨て(FLOOR/intValue)」の 3 条件が揃うと、最終結果が 1 ズレる。四捨五入なら吸収される。
  • 直すなら new BigDecimal(Float.toString(f))、根本的には float を通さず String/BigDecimal で扱う。
  • 再発防止はテキスト検索では不十分。バイトコード(javap)を走査すれば型正確に検出でき、CI に組み込める。

BigDecimal を使っているから正確」という思い込みが、逆に float の誤差を最後まで運んでしまう。誤差を混ぜない入口の設計と、切り捨ての手前に浮動小数点を置かないことが肝心だと思う。