なぜBigDecimalを使わなければならないのか

「floatやdoubleでも別に計算できるよ」「なんでめんどくさいBigDecimalなんて使うのか?」といった疑問にお答えします。

「floatやdoubleでも別に計算できるよ」「なんでめんどくさいBigDecimalなんて使うのか?」といった疑問にお答えします。

この記事ではBigDecimalを使う理由について書かれています。BigDecimalの使い方についてはこちらをご覧下さい。

意図した計算をすることが正しい計算

floatやdoubleで勘定系の計算をするプログラムを組んでいると正しい計算をしてくれなくてバグになることがある。このときコンピュータが単純な計算ミスをしているわけではない。計算結果が我々の意図通りになっていないだけ。ただ意図しない結果はバグと判断される。

意図通りの結果にするには意図通りの計算をしなくてはいけない。例えばなんらかの割り算の処理を行う場合には無限小数の可能性があるので、小数○位で四捨五入というような決めごとがプログラミングされていなければならないことになる。

floatやdoubleによる計算ではこれができない。floatやdoubleの仕様に従って勝手に丸めを行う。なのでBigDecimalを使うという流れになる。

float/doubleでは意図した計算ができない

float/doubleでもちゃんと計算はしているが意図した結果にできない。意図しない計算は意図しない誤差を生んでバグと判断される。これが問題。まず意図した計算ができない例をみていく。

計算に丸め方を指定できない

これは次の例から理解できる。

double result = 10d / 6d;
System.out.println(result);
出力
1.6666666666666667

本来は1.6666666…のように無限に続くはずであるが、丸められてしまう。

この計算結果は無限小数なのでいずれは丸めなければ数値表現はできない。よって意図した計算をするには、丸め方法を指定することが必要である。ただdobuleやfloat等のプリミティブ型の計算ではそれを指定する方法がない。floatやdoubleの仕様に従って勝手に丸められてしまう。

これが意図した計算ができない1つ目の理由。丸めをコントロール(指定)できないので、コントロールできない誤差が生まれてしまう。

そもそも表現できない10進小数がある

これも例を出すと次のような数の場合

double result = 1.0000000000000000005d;
System.out.println(result);
出力
1.0

メモリに乗せただけで丸められてしまう。これは浮動小数点方式であることに起因する。浮動小数点方式で困る点は、例えば次の2つ。

  • 2進数では循環する10進小数は、丸められてしまう。

    • (10進)小数には2進数にすると循環してしまう数値がある。例えば「0.1」は、2進数にすると「0.001100110011…」。無限に続いてしまうので、どっかで丸めることになる。

  • 桁数に制限がある。

    • 浮動小数点方式は固定長なので、限界が来ると丸めが発生する。

浮動小数点は工夫された2進数の組合せみたいなモノ。結局2進数の問題は引き継いでしまうし、桁制限もされている。さらになぜ丸められてしまったのかの理由も特定できない。

これが意図した計算ができない2つ目の理由。計算する以前に意図しない丸めが発生してしまえば、意図した計算はできない。

そもそもfloat/doubleは勘定系の計算をするためのものではない

前述の2つの問題からも、float/doubleでは意図した計算をするには問題があることはご理解いただけたと思う。

ただfloat/double側から考えたらそういうために作られたわけではないのである。意図した計算をするには明らかに足りない。どちらかと言えば計算の速さなどの効率優先で作られているものなので、勘定系の計算に使おうとすることが間違っている。

じゃあどうするかということになるので、BigDecimalが登場する。

意図した計算をする仕組みが整えられたBigDecimal

double/floatでは意図した計算をするのが無謀なので、Javaではそれに対応するためBigDecimalが用意されている。

意図しない丸めを許さない

double/floatのプリミティブの計算では丸めが指定できなかったが、BigDecimalでは計算が全てメソッドなので引数として丸め方法を渡すことができる。

小数点2位で四捨五入する例
BigDecimal six = BigDecimal.valueOf(6);
BigDecimal ten = BigDecimal.TEN;
//RoundingMode.HALF_UPは四捨五入
BigDecimal result = ten.divide(six, 2, RoundingMode.HALF_UP);
System.out.println(result);
出力
1.67

丸めモード無指定でも計算できるが、計算結果が無限小数になるとArithmeticExceptionが投げられるので、計算結果が得られない。

BigDecimal six = BigDecimal.valueOf(6);
BigDecimal ten = BigDecimal.TEN;
// 計算結果が無限小数になり丸めモードの指定が無いため、ArithmeticException
BigDecimal result = ten.divide(six);

無限小数になると例外になるので、その可能性がわずかでもあれば(特に除算は)丸めを指定しなければならなくなる。このようにBigDecimalは仕組みとして、意図しない丸めしないし許さない。

整数と10のマイナス乗で表す

浮動小数点方式により丸めてしまう問題については、整数と10のマイナス乗による表現で回避されている。BigDecimalは内部的には次のように値を保持している。

unscaledValue×10 -scale

整数であれば小数と違い2進数で循環することはないので、それによる丸めは心配ない。10のマイナス乗で表現することで値としても小数と同値を表現可能になっている。これによって、メモリに乗せただけで丸められてしまうこともなくなる。

桁数についても200億桁ぐらいまでいけるようなので、ほんとうに特殊な分野以外の人は考慮する必要も無い。

かといって、全てBigDecimalということにもならない

ここまでの話の流れからすると、全部BigDecimalにすればいいと感じるがそうはならない。仕様によって判断する。

この程度の誤差は本質的に無視できるモノが存在する。例えばゲームにおける座標計算。ゲームのキャラクターがジャンプした先の座標を計算するのに小数何十桁の精度はいらない。人間が感知できないからである。私たちはコンピュータに対して絶対の正確性や精度を要求したがるが、ある程度の誤差であれば全然問題ない場面も多い。double/floatの誤差はその範囲には収まるレベル。

これにBigDecimalはいちいち丸めを指定することでコードが複雑になったり、計算が遅かったりということが加わるので、BigDecimal1択とは限らない。

一方お金の計算をするいわゆる勘定系は、1円単位の間違えでも信用を失う。ここではとにかく正確さなのでコードが複雑になろうがBigDecimal1択となる。こういったところではBigDecimalをしっかり使えないといけない。コンパイルが通り計算式も正しいが1円単位で誤差がでるなんてことになり、とてもやっかい。テストで見つけられるとも限らない。

結局は仕様によって判断する。悪なのはdouble/floatにおけるプリミティブ型計算の特徴を知らずに、なんとなく計算してしまうこと。知っているか知らないかの問題なので、今まで知らなかったらこれから直していけばいい。